Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions apps/www/app/_assets/templates/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* SVG templates for the image generator. Add a template by dropping the
* Illustrator export next to this file (kebab-case file name) and listing it
* here. Templates are loaded on demand – an export with embedded images can
* be megabytes, and it should not weigh down the page until chosen.
*
* In Illustrator: every text object becomes an editable field, and a shape
* whose layer name ends in `[image]` (e.g. "Bilde [image]") becomes an image
* upload that fills the shape. Layer names are used as field labels. The name
* may sit on the object itself or on the layer/group that wraps only it.
*
* Illustrator does not export text alignment, so mark it in the layer name:
* `Navn [center]` or `Dato [right]`. Text without a marker is left-aligned.
*/
export type ImageTemplate = {
/** Shown in the template chooser and used as the download file name. */
name: string;
/** Loads the SVG markup. */
load: () => Promise<string>;
};

const raw = (loader: () => Promise<{ default: string }>) => () =>
loader().then((module) => module.default);

export const imageTemplates = {
'test-template': {
name: 'Testmal (16:9)',
load: raw(() => import('./test-template.svg?raw')),
},
'test-template-bilde': {
name: 'Testmal med bilde',
load: raw(() => import('./test-template-bilde.svg?raw')),
},
'presentasjon-av-to-innledere': {
name: 'Presentasjon av to innledere',
load: raw(() => import('./presentasjon-av-to-innledere.svg?raw')),
},
'presentasjon-av-to-innledere-to': {
name: 'Min min nye mal',
load: raw(() => import('./presentasjon-av-to-innledere.svg?raw')),
},
} satisfies Record<string, ImageTemplate>;

export type ImageTemplateId = keyof typeof imageTemplates;

export const imageTemplateIds = Object.keys(
imageTemplates,
) as ImageTemplateId[];
104 changes: 104 additions & 0 deletions apps/www/app/_assets/templates/presentasjon-av-to-innledere.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions apps/www/app/_assets/templates/test-template-bilde.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions apps/www/app/_assets/templates/test-template.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
/**
* Browser-side export of an illustration: download as SVG, or rasterised to
* PNG/WebP via a canvas, and copy as a PNG image for apps like PowerPoint that
* paste SVG markup as text. Rasters are rendered with a transparent background
* and their longest side at `RASTER_MAX_SIDE` pixels.
* Export of an illustration: download as SVG, PNG or WebP, or copy as a PNG
* image. Rasters are rendered with a transparent background and their longest
* side at `RASTER_MAX_SIDE` pixels.
*/
import {
copySvgAsImage,
rasterizeSvg,
SVG_MIME,
saveBlob,
viewBoxSize,
} from '~/_utils/svg-export';

const formats = {
svg: { label: 'SVG', mime: 'image/svg+xml' },
svg: { label: 'SVG', mime: SVG_MIME },
png: { label: 'PNG', mime: 'image/png' },
webp: { label: 'WebP', mime: 'image/webp' },
} as const;
Expand All @@ -18,101 +25,19 @@ export const downloadFormats = (Object.keys(formats) as DownloadFormat[]).map(

const RASTER_MAX_SIDE = 2048;

const saveBlob = (blob: Blob, fileName: string) => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
link.remove();
// Give the browser a moment to start the download before revoking.
setTimeout(() => URL.revokeObjectURL(url), 10_000);
};

/** Pixel size for the raster, from the SVG's viewBox aspect ratio. */
const rasterSize = (viewBox?: string) => {
const [, , width, height] = (viewBox ?? '')
.trim()
.split(/[\s,]+/)
.map(Number);
if (!width || !height) {
return { width: RASTER_MAX_SIDE, height: RASTER_MAX_SIDE };
}
const scale = RASTER_MAX_SIDE / Math.max(width, height);
const size = viewBoxSize(viewBox);
if (!size) return { width: RASTER_MAX_SIDE, height: RASTER_MAX_SIDE };
const scale = RASTER_MAX_SIDE / Math.max(size.width, size.height);
return {
width: Math.round(width * scale),
height: Math.round(height * scale),
width: Math.round(size.width * scale),
height: Math.round(size.height * scale),
};
};

const loadImage = (svg: string) =>
new Promise<HTMLImageElement>((resolve, reject) => {
const url = URL.createObjectURL(
new Blob([svg], { type: formats.svg.mime }),
);
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(url);
resolve(image);
};
image.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Kunne ikke lese SVG-en.'));
};
image.src = url;
});

const rasterize = async (
svg: string,
viewBox: string | undefined,
format: Exclude<DownloadFormat, 'svg'>,
): Promise<Blob> => {
const { width, height } = rasterSize(viewBox);
// The generated SVGs have no width/height; browsers need them to size the
// image before it is drawn onto the canvas.
const sizedSvg = svg.replace(
/<svg\b/,
`<svg width="${width}" height="${height}"`,
);
const image = await loadImage(sizedSvg);

const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) throw new Error('Kunne ikke opprette bildet.');
context.drawImage(image, 0, 0, width, height);

const { mime } = formats[format];
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, mime),
);
// Browsers without an encoder for the format silently fall back to PNG.
if (!blob || blob.type !== mime) {
throw new Error(
`Nettleseren din kan ikke lage ${formats[format].label}-bilder. Prøv SVG eller PNG.`,
);
}
return blob;
};

/**
* Put the illustration on the clipboard as a PNG image. Office apps paste this
* as a picture, whereas SVG markup on the clipboard is pasted as text.
*/
export const copyIllustrationImage = async (svg: string, viewBox?: string) => {
if (typeof ClipboardItem === 'undefined' || !navigator.clipboard?.write) {
throw new Error(
'Nettleseren din kan ikke kopiere bilder. Last ned PNG i stedet.',
);
}
// Pass the pending blob rather than awaiting it first: Safari only allows
// clipboard writes while the click is still "current".
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': rasterize(svg, viewBox, 'png') }),
]);
};
export const copyIllustrationImage = (svg: string, viewBox?: string) =>
copySvgAsImage(svg, rasterSize(viewBox));

export const downloadIllustration = async ({
svg,
Expand All @@ -128,7 +53,10 @@ export const downloadIllustration = async ({
}) => {
const blob =
format === 'svg'
? new Blob([svg], { type: formats.svg.mime })
: await rasterize(svg, viewBox, format);
? new Blob([svg], { type: SVG_MIME })
: await rasterizeSvg(svg, {
...rasterSize(viewBox),
mime: formats[format].mime,
});
saveBlob(blob, `${fileName}.${format}`);
};
139 changes: 139 additions & 0 deletions apps/www/app/_components/image-generator/image-generator.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
.container {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--ds-size-8);
align-items: start;
max-width: 100%;
margin-block: var(--ds-size-8);

@media (max-width: 900px) {
grid-template-columns: minmax(0, 1fr);
}
}

.loading {
display: grid;
place-items: center;
min-height: 12rem;
}

.form {
display: flex;
flex-direction: column;
gap: var(--ds-size-5);
}

.preview {
display: flex;
flex-direction: column;
gap: var(--ds-size-4);

@media (min-width: 901px) {
position: sticky;
top: calc(var(--header-height) + var(--ds-size-4));
}
}

/* Checkerboard so transparent areas of a template are visible. */
.canvas {
border: 1px solid var(--ds-color-neutral-border-subtle);
border-radius: var(--ds-border-radius-md);
overflow: clip;
background-color: var(--ds-color-neutral-background-tinted);
background-image:
linear-gradient(
45deg,
var(--ds-color-neutral-surface-hover) 25%,
transparent 25%
),
linear-gradient(
-45deg,
var(--ds-color-neutral-surface-hover) 25%,
transparent 25%
),
linear-gradient(
45deg,
transparent 75%,
var(--ds-color-neutral-surface-hover) 75%
),
linear-gradient(
-45deg,
transparent 75%,
var(--ds-color-neutral-surface-hover) 75%
);
background-size: 20px 20px;
background-position:
0 0,
0 10px,
10px -10px,
-10px 0;

& svg {
display: block;
width: 100%;
height: auto;
}
}

.actions {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--ds-size-3);
}

.size {
width: auto;
min-width: 12rem;
}

.generator {
display: flex;
flex-direction: column;
gap: var(--ds-size-6);
max-width: 100%;
margin-block: var(--ds-size-8);

& > .container {
margin-block: 0;
}
}

.templatePicker {
max-width: 24rem;
}

.imageField {
display: flex;
flex-direction: column;
gap: var(--ds-size-2);
}

.dropZone {
--dsc-file-upload-padding: var(--ds-size-5) var(--ds-size-4);
}

.imageChosen {
display: flex;
align-items: center;
gap: var(--ds-size-3);
padding: var(--ds-size-2);
border: 1px solid var(--ds-color-neutral-border-subtle);
border-radius: var(--ds-border-radius-md);
}

.imageThumbnail {
flex-shrink: 0;
width: var(--ds-size-14);
height: var(--ds-size-14);
border-radius: var(--ds-border-radius-sm);
object-fit: cover;
}

.imageFileName {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
Loading
Loading