UI designer · Assembly guide
Build your own designer · Beta
BetaWorking examples, component contracts and the application code that turns them into an editor.
Start here
The demos are reference applications. Start with the smaller examples below, then add the behaviour your product needs. Each preview and its copyable source come from the same Vue file.
Use the package in an existing Vue 3 application, or copy the components through the example’s Source option. The examples use Tailwind CSS v4 utilities; keep your app’s Tailwind setup scanning the files you copy. Installation guide.
npm install @getdom/studioimport '@getdom/studio/style.css';Embed the whole designer
If you want the complete canvas rather than your own editor, use DomDesigner from @getdom/studio/designer. It is the editor behind the canvas demo: artboards at several widths, layers, an inspector generated from component props, a Figma-style layout panel, undo, and a two-way Vue code view. The entry is separate from @getdom/studio/vue because it brings the Vue template compiler, so load it only where people design, ideally with defineAsyncComponent.
import * as studio from '@getdom/studio/vue';
import { DomDesigner, catalogFromExports, createHttpStorage } from '@getdom/studio/designer';
// Every Dom* component becomes insertable, with an inspector generated from its props.
const catalog = catalogFromExports(studio);
// GET/PUT {endpoint}/{id}. Omit storage to save in this browser.
const storage = createHttpStorage({ endpoint: '/api/designs' });
// <DomDesigner :catalog="catalog" :storage="storage" storage-key="home-page" />Storage adapters decide where work is kept: createBrowserStorage (the default), createHttpStorage for your API or CMS, createMemoryStorage for tests, and createFileStorage with the file-storage prop to open and save project files. Pass compileCss to compile Tailwind classes that your app’s stylesheet lacks.
The code view is a lightweight editor by default. For a richer one, pass DomMonacoCodeView as code-editor, with a load function that imports Monaco from your own install; DOM Studio does not depend on Monaco. Both editors keep the code and stage in sync: put the caret on a line and its layer is selected on the stage; select a layer and its lines are highlighted and scrolled into view.
import { DomMonacoCodeView } from '@getdom/studio/designer';
// Your app installs monaco-editor and decides how it is bundled.
async function load() {
await import('monaco-editor/min/vs/editor/editor.main.css');
return import('monaco-editor/esm/vs/editor/editor.api');
}
// <DomDesigner :catalog="catalog" :code-editor="DomMonacoCodeView" :code-editor-props="{ load }" />The same entry exports the editor core (documents, operations, history, Vue import and export) for building your own editor on the same model. The rest of this guide assembles editors from the individual components.
Choose the pieces
| Component | Responsibility |
|---|---|
| DomSplitterPanel | Resizes the shell panes. Bind startSize/endSize if your app wants to remember widths. |
| DomDesignerLayers | Displays your tree. Its selected ID is shared with the stage and inspector. |
| DomDesignerStage | Selects marked HTML and emits CSS patches for movement and resizing. Positioning, snapping, spacing snaps, multi-select (v-model:selection), frame-scoped movement and flow reordering (reorder) are opt-in. |
| DomDesignerFrame | A container whose children use flow, flex, grid or absolute layout. Each frame controls only its immediate children. |
| DomFrameLayoutEditor | Compact layout panel for a frame: mode, direction, a 3×3 alignment pad, gap, and padding per side. Emits a new layout through v-model. |
| DomColorSwatch | Edits a CSS colour string. Bind it to the selected layer’s colour or fill. |
| DomBorderEditor | Edits a border record; borderToCss produces the CSS border value. |
| DomShadowEditor | Edits shadow records; shadowListToCss produces box-shadow. |
| DomBackgroundEditor | Edits ordered fills; backgroundImageToCss produces stacked CSS backgrounds. |
| DomBackdropEditor | Edits blur/saturation; backdropToCss produces backdrop-filter. |
1. Connect layers, stage and inspector
Keep stable IDs, content and styles in your own records. Bind one selected ID to both the tree and stage. Mark rendered elements with data-designer-id. The inspector finds that record by ID; apply each style-change { id, style } patch to it. Vue then updates the HTML. The stage does not create records, CSS classes or stylesheet rules.
A minimal HTML editor
Select from the tree or stage, edit the text, drag the outline, resize its handles, or resize the sidebar.
Use Alt plus Up or Down arrow to reorder the focused tree item.
Make something yours.
Real HTML. One shared model.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { computed, ref } from 'vue';
import { DomDesignerLayers, DomDesignerStage, DomSplitterPanel, DomTextInput, DomColorSwatch } from '@getdom/studio';
const selected = ref('heading');
const panelWidth = ref(190);
const layers = ref([
{ id: 'heading', label: 'Heading', text: 'Make something yours.', color: 'var(--canvas-fg)', style: { position: 'absolute', left: '24px', top: '32px', width: '250px', height: '100px', fontSize: '30px' } },
{ id: 'caption', label: 'Caption', text: 'Real HTML. One shared model.', color: 'var(--muted-fg)', style: { position: 'absolute', left: '24px', top: '180px', width: '250px', height: '60px', fontSize: '16px' } },
]);
const active = computed(() => layers.value.find((layer) => layer.id === selected.value));
/** Persists the stage's CSS patch in the same model rendered by Vue. */
function applyStyle({ id, style }) {
const layer = layers.value.find((item) => item.id === id);
if (layer) Object.assign(layer.style, style);
}
</script>
<template>
<div class="w-full overflow-auto">
<DomSplitterPanel v-model:start-size="panelWidth" :min-start="160" :min-main="320" class="h-[360px]">
<template #start>
<aside class="h-full space-y-4 overflow-auto p-3">
<DomDesignerLayers v-model="selected" :items="layers" />
<template v-if="active">
<DomTextInput v-model="active.text" label="Text" />
<DomColorSwatch v-model="active.color" label="Text colour" />
</template>
</aside>
</template>
<template #default>
<DomDesignerStage v-model="selected" positioning snapping class="relative h-full overflow-hidden bg-secondary" @style-change="applyStyle">
<p v-for="layer in layers" :key="layer.id" :data-designer-id="layer.id" :style="{ ...layer.style, color: layer.color }" class="m-0 whitespace-pre-wrap p-2 font-semibold">{{ layer.text }}</p>
</DomDesignerStage>
</template>
</DomSplitterPanel>
</div>
</template>
For repeated gaps, add :spacing-snapping="true" to the stage. This independent, default-off step fills axes without an alignment match while dragging. Try the equal-spacing example and copy its source →
The example scrolls horizontally on narrow screens. The poster demo instead switches between Layers, Poster and Inspect tabs. Use positioning=false for selection without dragging, interactive=true to pass clicks through to preview content, and snapping=true for stage and visible-layer alignment. Hold Control to bypass snapping. Uniform stage scaling is supported; rotated layers and independently transformed ancestors are not supported in this beta.
2. Turn control records into CSS
Controls expose v-model values; serializers turn those values into CSS. In a multi-layer editor, store these records on each layer and bind the controls to the selected layer. Give every layer its own records and arrays. The controls do not apply styles to a selected DOM element themselves.
Compose the appearance controls
Border, shadows, backgrounds and backdrop effects applied to one HTML element.
Behind the layer
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { computed, ref } from 'vue';
import { DomBorderEditor, DomShadowEditor, DomBackgroundEditor, DomBackdropEditor, createDesignerBorder, borderToCss, shadowListToCss, createBackgroundState, backgroundImageToCss, createBackdrop, backdropToCss } from '@getdom/studio';
const border = ref(createDesignerBorder());
const shadows = ref([]);
const background = ref(createBackgroundState({}, { allowEmpty: true }));
const backdrop = ref(createBackdrop());
const style = computed(() => ({
border: borderToCss(border.value),
boxShadow: shadowListToCss(shadows.value),
backgroundImage: backgroundImageToCss(background.value),
backdropFilter: backdropToCss(backdrop.value),
WebkitBackdropFilter: backdropToCss(backdrop.value),
}));
</script>
<template>
<div class="grid w-full gap-6 md:grid-cols-2">
<div class="max-h-[400px] overflow-auto">
<DomBorderEditor v-model="border" />
<DomShadowEditor v-model="shadows" />
<DomBackgroundEditor v-model="background" />
<DomBackdropEditor v-model="backdrop" />
</div>
<div class="relative grid min-h-64 place-items-center overflow-hidden rounded-xl bg-secondary p-6">
<p class="absolute inset-x-4 top-12 text-4xl font-bold text-primary">Behind the layer</p>
<div :style="style" class="relative rounded-xl bg-canvas/60 p-8 text-center font-semibold">Your HTML layer</div>
</div>
</div>
</template>
Background fill order follows CSS: the first image is on top. Backdrop effects need translucent content to be visible. Box shadows and borders do not require a special layer class. Create fill records on the client, or assign deterministic fill IDs when server rendering.
3. Build a poster or post editor on DomDesigner
- Start from DomDesigner rather than a stage of your own. A poster is an ordinary HTML file: a root
<article>with a fixed width and height, and layers placed absolutely by their inline style. The canvas, code view, text editing, multi-select with align and distribute, lock and hide, rulers and the image controls are the designer’s own. - Pass
createWorkspaceto open your starting design as a file with one artboard at the poster’s size. socialDocument.js turns a poster model into that file. - Add what is particular to your product around it. Use the
startandendtoolbar slots for formats and export. Use theinspectorslot for controls on the selected layer, such as each layer’s resize pins. - Change the design only through the designer’s
run, with document operations, so a format switch is one undo step and the code follows. - Export from the document, not the live canvas, so selection outlines and editor attributes stay out. Local uploads are embedded as data URLs unless you pass
uploadImage; linked photos remain external.
To build your own stage instead, keep document coordinates separate from the viewport scale:
<!-- Keep document coordinates separate from viewport scale. -->
<div class="overflow-auto">
<div :style="{ width: width * zoom + 'px', height: height * zoom + 'px' }">
<DomDesignerStage
v-model="selected" positioning snapping
:style="{ width: width + 'px', height: height + 'px', transform: 'scale(' + zoom + ')', transformOrigin: 'top left', position: 'relative' }"
@style-change="applyStyle"
>
<!-- Render your layers here. -->
</DomDesignerStage>
</div>
</div>5. Assemble a flowchart editor
Vue Flow owns the graph viewport, node dragging and connection geometry. DOM Studio provides the surrounding splitter, inputs and actions. There is no public DomFlowchart component: this is a composition with a separate Vue Flow dependency. Install the core and whichever companion components you use.
npm install @vue-flow/core @vue-flow/background @vue-flow/controls @vue-flow/minimapA minimal graph editor
Drag from the bottom dot of one node to the top dot of the other. Move nodes, pan empty space and use the zoom buttons.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { onMounted, ref, useId } from 'vue';
import { VueFlow, addEdge } from '@vue-flow/core';
import { Background } from '@vue-flow/background';
import { DomButton } from '@getdom/studio';
import '@vue-flow/core/dist/style.css';
import '@vue-flow/core/dist/theme-default.css';
const id = useId();
const ready = ref(false);
const graph = ref(null);
const nodes = ref([
{ id: 'idea', label: 'An idea', position: { x: 0, y: 0 } },
{ id: 'draft', label: 'First draft', position: { x: 230, y: 130 } },
]);
const edges = ref([]);
/** Defers the interactive viewport until browser hydration is complete. */
function mount() { ready.value = true; }
onMounted(mount);
/** Captures the instance belonging to this particular editor. */
function init(instance) { graph.value = instance; }
/** Stores new connections in application state, suppressing duplicates. */
function connect(connection) { edges.value = addEdge(connection, edges.value); }
</script>
<template>
<div class="w-full">
<div class="mb-3 flex gap-2">
<DomButton size="sm" :disabled="!graph" @click="graph.zoomOut()">Zoom out</DomButton>
<DomButton size="sm" :disabled="!graph" @click="graph.zoomIn()">Zoom in</DomButton>
<DomButton size="sm" :disabled="!graph" @click="graph.fitView()">Fit</DomButton>
</div>
<div class="h-[320px] rounded-xl border border-border">
<VueFlow v-if="ready" :id="id" v-model:nodes="nodes" v-model:edges="edges" fit-view-on-init :min-zoom="0.1" :max-zoom="3" @init="init" @connect="connect">
<Background pattern-color="var(--border)" />
</VueFlow>
</div>
</div>
</template>
Give the graph a sized parent and an instance ID. Bind nodes and edges to application state; handle connect by adding an edge. Mount the interactive viewport after hydration. The full demo adds a node-step slot with Handle ports, an inspector, Controls and MiniMap. screenToFlowCoordinate converts a pointer or viewport centre into graph coordinates. toObject exports nodes, edges and viewport; fromObject restores a saved graph. Validate imported data in your app before restoring it.
What your application owns
Own the document schema, IDs, history, persistence, asset uploads, import validation and export. The poster, social-card and flowchart demos keep edits in memory until exported; they do not provide a storage service or run workflows. DomDesigner saves through the storage adapter you pass; your server still owns authentication, validation and the stored schema. For production, save the model rather than the live editor DOM, restore stable IDs, and keep selection and viewport preferences separate from document changes. The beta APIs may change.
Full demo source
These are the actual repository files, including local imports and route links. Use the starter examples above for a standalone component; use these files to inspect the complete application wiring. Poster needs its three files plus the shared editor core, designer components and serializers.
Social post studio (poster and social card)
<script setup>
import { computed, ref, shallowRef, watch } from 'vue';
import { DomButton, DomDialog, DomNativeSelect } from '../../lib/vue';
import DomDesigner from '../core/DesignerWorkspace.vue';
import { cloneValue } from '../core/editorDocument.js';
import { WORKSPACE_VERSION, exportFile, importFile, workspaceId } from '../canvas/canvasWorkspace.js';
import PosterThumbnail from '../poster/PosterThumbnail.vue';
import { pinXOptions, pinYOptions, resizePoster, socialFormats } from '../poster/posterResize.js';
import InspectorSelect from '../_shared/InspectorSelect.vue';
import { loadTemplates, templateIndustries, templateKinds } from '../poster/socialTemplates.js';
import PostThumbnail from './PostThumbnail.vue';
import { exportPostHtml, formatOperations, postFontLinks, postMarkup, postSize, posterToFile, resizedDocument } from './socialDocument.js';
/**
* Social post studio: DomDesigner with a post as its file.
*
* Everything on the canvas and in the code is the designer's own (two-way
* code, selection sync, text edited in place, bold and other marks). This
* adds the post's formats (each layer follows its pins), the template
* gallery, saved templates and HTML export.
*/
const props = defineProps({
/** Creates the starting poster (see posterModel.js). */
create: { type: Function, required: true },
templates: { type: Array, default: () => [] },
/** Storage adapter for templates saved from the studio. */
templateStorage: { type: Object, default: null },
credit: { type: Object, default: null },
title: { type: String, default: 'Social post studio' },
fileName: { type: String, default: 'dom-studio-social-post.html' },
/** Workspace storage, passed to DomDesigner; this browser by default. */
storage: { type: Object, default: null },
storageKey: { type: String, default: 'dom-studio-social-v1' },
codeEditor: { type: [Object, Function], default: null },
codeEditorProps: { type: Object, default: () => ({}) },
codeOpen: { type: Boolean, default: false },
embedded: { type: Boolean, default: false },
});
const designer = ref(null);
const showFormats = ref(true);
const showTemplates = ref(false);
const templateIndustry = ref('all');
const templateKind = ref('all');
const savedTemplates = ref([]);
function createWorkspace() {
const file = posterToFile(props.create());
const { width, height } = postSize(file.doc);
return { version: WORKSPACE_VERSION, files: [file], artboards: [{ id: workspaceId('board'), fileId: file.id, name: 'Post', x: 0, y: 0, width, height }] };
}
const file = computed(() => designer.value?.activeFile || null);
const size = computed(() => (file.value ? postSize(file.value.doc) : { width: 1080, height: 1350 }));
const formatOptions = computed(() => {
const current = `${size.value.width}x${size.value.height}`;
const presets = socialFormats.map((format) => ({ label: `${format.label} · ${format.width} × ${format.height}`, value: `${format.width}x${format.height}` }));
return presets.some((preset) => preset.value === current) ? presets : [{ label: `Custom · ${size.value.width} × ${size.value.height}`, value: current }, ...presets];
});
/* Formats ------------------------------------------------------------------------ */
/**
* Formats derive from the design as last edited, never from the previous
* format, so switching back and forth never drifts (see posterResize.js).
*/
let formatBase = null;
function baseDoc() {
const current = file.value?.doc;
return formatBase && formatBase.fileId === file.value?.id && formatBase.produced === JSON.stringify(current) ? formatBase.doc : cloneValue(current);
}
function setFormat(value) {
const target = file.value;
if (!target) return;
const [width, height] = value.split('x').map(Number);
if (width === size.value.width && height === size.value.height) return;
const base = baseDoc();
const boards = designer.value.workspace.artboards.filter((board) => board.fileId === target.id);
designer.value.run([...formatOperations(base, target.id, { width, height }), ...boards.flatMap((board) => [{ op: 'setArtboard', id: board.id, key: 'width', value: width }, { op: 'setArtboard', id: board.id, key: 'height', value: height }])]);
formatBase = { fileId: target.id, doc: base, produced: JSON.stringify(target.doc) };
designer.value.fit();
}
/** Every format, resized from the design with each layer's pins; redrawn at most once a frame, so previews follow a drag. */
const previews = shallowRef([]);
let previewFrame = 0;
watch(() => [showFormats.value, file.value?.doc], () => {
if (previewFrame || typeof requestAnimationFrame !== 'function') return;
previewFrame = requestAnimationFrame(() => {
previewFrame = 0;
const target = file.value;
if (!target || !showFormats.value) return;
const base = { ...target, doc: baseDoc() };
const fonts = postFontLinks(base.doc);
previews.value = socialFormats.map((format) => {
const markup = postMarkup({ ...base, doc: resizedDocument(base, format) });
return { ...format, value: `${format.width}x${format.height}`, current: format.width === size.value.width && format.height === size.value.height, markup, fonts };
});
});
}, { immediate: true, deep: true });
/* Export ------------------------------------------------------------------------- */
function downloadText(text, name, type) {
const url = URL.createObjectURL(new Blob([text], { type }));
const link = document.createElement('a');
link.href = url; link.download = name; link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function download(format = null) {
if (!file.value) return;
const target = format ? { ...file.value, doc: resizedDocument({ ...file.value, doc: baseDoc() }, format) } : file.value;
downloadText(exportPostHtml(target), format ? props.fileName.replace(/\.html$/, `-${format.width}x${format.height}.html`) : props.fileName, 'text/html');
designer.value.notify('HTML exported. Linked photos still need an internet connection.');
}
/* Templates ---------------------------------------------------------------------- */
/** Saved templates: designs saved from the studio as HTML, and older ones saved as poster JSON. */
async function listSaved() {
const adapter = props.templateStorage;
if (!adapter) return [];
const items = await adapter.list();
const values = await Promise.all(items.map((item) => adapter.load(item.id)));
return values.flatMap((value, index) => {
if (!value) return [];
const id = items[index].id;
if (typeof value.source === 'string') return [{ id, title: String(value.title || id), industry: value.industry || 'other', kind: value.kind || 'announcement', source: value.source, saved: true }];
return loadTemplates([{ ...value, id }]).map((template) => ({ ...template, saved: true }));
});
}
watch(showTemplates, async (open) => {
if (!open) return;
try { savedTemplates.value = await listSaved(); }
catch (error) { designer.value.notify(`Saved templates could not load: ${error.message}`); }
});
const galleryTemplates = computed(() => [...savedTemplates.value, ...props.templates]);
const industryOptions = computed(() => [{ label: `All industries (${galleryTemplates.value.length})`, value: 'all' }, ...templateIndustries.filter((item) => galleryTemplates.value.some((template) => template.industry === item.value)).map((item) => ({ label: `${item.label} (${galleryTemplates.value.filter((template) => template.industry === item.value).length})`, value: item.value }))]);
const kindOptions = computed(() => [{ label: 'All post types', value: 'all' }, ...templateKinds.filter((item) => galleryTemplates.value.some((template) => template.kind === item.value && (templateIndustry.value === 'all' || template.industry === templateIndustry.value))).map((item) => ({ label: item.label, value: item.value }))]);
watch(templateIndustry, () => { if (!kindOptions.value.some((item) => item.value === templateKind.value)) templateKind.value = 'all'; });
/** A template as a document at the current format. */
function templateDoc(template) {
if (template.poster) return posterToFile(resizePoster(template.poster, size.value)).doc;
const { file: imported } = importFile(template.source, 'Template.vue');
const from = postSize(imported.doc);
return from.width === size.value.width && from.height === size.value.height ? imported.doc : resizedDocument(imported, size.value);
}
const visibleTemplates = computed(() => (showTemplates.value ? galleryTemplates.value : [])
.filter((template) => (templateIndustry.value === 'all' || template.industry === templateIndustry.value) && (templateKind.value === 'all' || template.kind === templateKind.value))
.map((template) => {
const label = [template.saved && 'Yours', templateIndustries.find((item) => item.value === template.industry)?.label].filter(Boolean).join(' · ');
if (template.poster) return { ...template, label, preview: resizePoster(template.poster, size.value) };
const doc = templateDoc(template);
return { ...template, label, markup: postMarkup({ doc }), fonts: postFontLinks(doc) };
}));
/** Replaces the design with a template at the current format; undo brings it back. */
function applyTemplate(template) {
const target = file.value;
if (!target) return;
const doc = templateDoc(template);
designer.value.run([{ op: 'setFile', id: target.id, key: 'doc', value: doc }]);
formatBase = null;
const first = doc.nodes[doc.rootId].children.find((id) => doc.nodes[id]?.type === 'html:p');
if (first) designer.value.select(first);
showTemplates.value = false;
designer.value.notify(`${template.title} applied. Undo to go back to your design.`);
}
/** Saves the design as a new template; existing templates are never overwritten. */
async function saveAsTemplate() {
const adapter = props.templateStorage;
const target = file.value;
if (!adapter || !target) return;
try {
const title = target.doc.nodes[target.doc.rootId]?.props?.['aria-label'] || 'Post';
const stem = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'post';
const taken = new Set([...props.templates.map((template) => template.id), ...(await adapter.list()).map((item) => item.id)]);
let id = stem;
for (let copy = 2; taken.has(id); copy += 1) id = `${stem}-${copy}`;
await adapter.save(id, { id, title, industry: templateIndustry.value === 'all' ? 'other' : templateIndustry.value, kind: templateKind.value === 'all' ? 'announcement' : templateKind.value, ...size.value, source: exportFile(target) });
savedTemplates.value = await listSaved();
designer.value.notify(`Saved as a new template (${id}).`);
} catch (error) {
designer.value.notify(`The template could not be saved: ${error.message}`);
}
}
</script>
<template>
<div class="flex min-h-0 flex-col bg-canvas text-canvas-fg" :class="embedded ? 'h-full' : 'h-dvh'">
<DomDesigner
ref="designer"
class="min-h-0 flex-1"
:title="title"
badge="Beta"
:create-workspace="createWorkspace"
:storage="storage"
:storage-key="storageKey"
:code-editor="codeEditor"
:code-editor-props="codeEditorProps"
:code-open="codeOpen"
code-layout="below"
:embedded="embedded"
:rulers="!embedded"
>
<template #start><slot name="start" /></template>
<template #inspector="{ node, parent, setProp }">
<section v-if="parent && !parent.parent" class="space-y-2 border-b border-border px-4 py-3">
<h3 class="text-[11px] font-semibold">Resize pins</h3>
<div class="grid grid-cols-2 gap-1.5">
<InspectorSelect label="Horizontal pin" :options="pinXOptions" :model-value="node.props['data-pin-x'] || 'auto'" @update:model-value="setProp('data-pin-x', $event === 'auto' ? undefined : $event)" />
<InspectorSelect label="Vertical pin" :options="pinYOptions" :model-value="node.props['data-pin-y'] || 'auto'" @update:model-value="setProp('data-pin-y', $event === 'auto' ? undefined : $event)" />
</div>
<p class="text-[11px] leading-4 text-muted-fg">How this layer moves when the post changes format. Auto reads it from where the layer sits.</p>
</section>
</template>
<template #end>
<select class="h-7 max-w-52 rounded-md border-0 bg-secondary/60 pl-2 pr-6 text-xs text-canvas-fg outline-none hover:ring-1 hover:ring-border focus:ring-1 focus:ring-primary" aria-label="Post format" :value="`${size.width}x${size.height}`" @change="setFormat($event.target.value)">
<option v-for="option in formatOptions" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
<DomButton size="sm" variant="ghost" :aria-pressed="showFormats" @click="showFormats = !showFormats; designer.fit()">Formats</DomButton>
<DomButton v-if="templates.length || templateStorage" size="sm" variant="ghost" @click="showTemplates = true">Templates</DomButton>
<DomButton size="sm" variant="secondary" @click="download()">Export HTML</DomButton>
</template>
</DomDesigner>
<div v-if="showFormats" class="min-h-[164px] shrink-0 border-t border-border bg-canvas p-3">
<div class="mb-2 flex items-center justify-between gap-3"><h2 class="text-xs font-semibold">Every format <span class="font-normal text-muted-fg">· live previews that follow each layer's pins (data-pin-x, data-pin-y)</span></h2><a v-if="credit" class="shrink-0 text-[10px] text-muted-fg underline underline-offset-2" :href="credit.href" target="_blank" rel="noopener noreferrer">{{ credit.label }}</a></div>
<div class="flex gap-4 overflow-x-auto pb-1">
<div v-for="preview in previews" :key="preview.value" class="flex shrink-0 flex-col items-start gap-1.5">
<button type="button" class="rounded-md p-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" :class="preview.current ? 'ring-2 ring-primary' : 'hover:bg-secondary'" :aria-label="`Edit as ${preview.label}`" :aria-pressed="preview.current" @click="setFormat(preview.value)"><PostThumbnail :markup="preview.markup" :width="preview.width" :height="preview.height" :fonts="preview.fonts" :size="96" /></button>
<div class="flex w-full items-center justify-between gap-2 px-1"><span class="truncate text-[11px]" :class="preview.current ? 'font-semibold' : 'text-muted-fg'">{{ preview.label }}</span><button type="button" class="shrink-0 text-[11px] text-primary underline underline-offset-2" :aria-label="`Export ${preview.label} as HTML`" @click="download(preview)">HTML</button></div>
</div>
</div>
</div>
<DomDialog v-model="showTemplates" title="Templates" :description="`Every template opens at ${size.width} × ${size.height} and stays editable. Your current design is replaced; undo brings it back. Saving always adds a new template.`" width="min(1100px, 95vw)">
<div class="flex flex-wrap items-end gap-3 pb-4"><div class="w-60 max-w-full"><DomNativeSelect v-model="templateIndustry" :options="industryOptions" label="Industry" /></div><div class="w-48 max-w-full"><DomNativeSelect v-model="templateKind" :options="kindOptions" label="Post type" /></div><div class="ml-auto flex flex-wrap gap-2"><DomButton v-if="templateStorage" size="sm" variant="secondary" title="Save this design as a new template, tagged with the selected industry and post type. Existing templates are never overwritten." @click="saveAsTemplate">Save as new template</DomButton></div></div>
<ul class="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-4" aria-label="Templates">
<li v-for="template in visibleTemplates" :key="template.id"><button type="button" class="flex w-full flex-col items-center gap-2 rounded-lg p-2 text-left hover:bg-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" @click="applyTemplate(template)"><PosterThumbnail v-if="template.preview" :poster="template.preview" :height="Math.min(220, Math.round(170 * size.height / size.width))" /><PostThumbnail v-else :markup="template.markup" :fonts="template.fonts" :width="size.width" :height="size.height" :size="Math.min(220, Math.round(170 * size.height / size.width))" /><span class="w-full"><span class="block truncate text-sm font-medium">{{ template.title }}</span><span class="block truncate text-xs text-muted-fg">{{ template.label }}</span></span></button></li>
</ul>
</DomDialog>
</div>
</template>
Posts as designer files: formats, pins and export
import { exportFile, importFile } from '../canvas/canvasWorkspace.js';
import { escapeHtml, posterLayerStyle, safePosterImage } from '../poster/posterModel.js';
import { resizeLayer } from '../poster/posterResize.js';
import { googleFontLinks } from '../typography-editor/googleFonts.js';
/**
* A social post as a designer file.
*
* The Social post studio is DomDesigner: a post is an ordinary HTML file
* whose root `<article>` is the post and whose layers are absolutely placed
* by their own inline style. Canvas editing, the code view, text editing and
* marks are the designer's own. This module adds what is particular to posts:
* opening a poster or template as a file, resizing to another format with
* each layer's pins (`data-pin-x`, `data-pin-y`), and exporting a standalone
* HTML document.
*/
/** Serializes camel-case styles the way the poster export does. */
function styleAttribute(style) {
return escapeHtml(Object.entries(style).map(([key, value]) => `${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}:${value}`).join(';'));
}
/**
* A poster (or template poster) as Vue source. Line breaks in text become
* `<br>`, since the template compiler folds whitespace.
*
* @param {object} poster Poster model.
* @returns {string} Vue SFC with one `<article>`.
*/
export function posterToSource(poster) {
const layers = poster.layers.filter((layer) => !layer.hidden).map((layer, index) => {
const pins = [['data-pin-x', layer.pinX], ['data-pin-y', layer.pinY]].filter(([, pin]) => pin && pin !== 'auto').map(([name, pin]) => ` ${name}="${pin}"`).join('');
// Line breaks are written as <br>, and the exported markup is indented, so text does not keep source whitespace (no pre-wrap).
// eslint-disable-next-line no-unused-vars
const { whiteSpace, ...css } = posterLayerStyle(layer, index);
const style = styleAttribute(css);
if (layer.type === 'image') return `\t<img src="${escapeHtml(safePosterImage(layer.value))}" alt="${escapeHtml(layer.name)}"${pins} style="${style}">`;
if (layer.type === 'text') return `\t<p${pins} style="${style}">${escapeHtml(layer.value).replace(/\n/g, '<br>')}</p>`;
return `\t<div${pins} style="${style}"></div>`;
});
return `<template>\n<article aria-label="${escapeHtml(poster.title)}" style="position:relative;width:${poster.width}px;height:${poster.height}px;overflow:hidden;background:${escapeHtml(poster.background)}">\n${layers.join('\n')}\n</article>\n</template>\n`;
}
/**
* Opens a poster as a designer file, with its layer names and locks.
*
* @param {object} poster Poster model.
* @param {string} [name] File name.
* @returns {object} Designer file.
*/
export function posterToFile(poster, name = 'Post.vue') {
const { file } = importFile(posterToSource(poster), name);
const root = file.doc.nodes[file.doc.rootId];
poster.layers.filter((layer) => !layer.hidden).forEach((layer, index) => {
const node = file.doc.nodes[root.children[index]];
if (!node) return;
node.label = layer.name;
if (layer.locked) node.locked = true;
});
return file;
}
/** A pixel length from inline style, or null. */
function px(value) {
const number = Number.parseFloat(value);
return Number.isFinite(number) && /^\s*-?[\d.]+(px)?\s*$/.test(String(value)) ? number : null;
}
/** The post's size, read from its root element's inline style. */
export function postSize(doc) {
const root = doc.nodes[doc.rootId];
return { width: px(root?.style?.width) || 1080, height: px(root?.style?.height) || 1080 };
}
/** A layer's box: where the designer placed it, else where its style puts it. */
function boxOf(node) {
const style = node.style || {};
const placed = node.placements?.absolute || {};
const pick = (key) => (Number.isFinite(placed[key]) && (placed.sized !== false || key === 'left' || key === 'top') ? placed[key] : px(style[key]));
return { x: pick('left'), y: pick('top'), width: pick('width'), height: pick('height') };
}
/**
* Operations that move a post into another format, as one undo step. Each
* absolutely placed layer follows its pins (see posterResize.js); type size,
* letter spacing and corner radius scale with the smaller frame ratio.
*
* @param {object} doc Document to resize from (the design as last edited).
* @param {string} fileId File the operations apply to.
* @param {{ width: number, height: number }} to New size.
* @returns {Array<object>} Operations.
*/
export function formatOperations(doc, fileId, to) {
const root = doc.nodes[doc.rootId];
const from = postSize(doc);
const operations = [];
for (const id of root.children) {
const node = doc.nodes[id];
if (!node || node.type === 'text' || String(node.style?.position).trim() !== 'absolute') continue;
const box = boxOf(node);
if ([box.x, box.y, box.width, box.height].some((value) => value === null)) continue;
const style = node.style;
const layer = { ...box, pinX: node.props?.['data-pin-x'] || 'auto', pinY: node.props?.['data-pin-y'] || 'auto', fontSize: px(style['font-size']) ?? undefined, letterSpacing: px(style['letter-spacing']) ?? undefined, radius: px(style['border-radius']) || 0 };
const next = resizeLayer(layer, from, to);
const value = { ...style, left: `${next.x}px`, top: `${next.y}px`, width: `${next.width}px`, height: `${next.height}px` };
if (next.fontSize !== undefined) value['font-size'] = `${next.fontSize}px`;
if (next.letterSpacing !== undefined) value['letter-spacing'] = `${next.letterSpacing}px`;
if (next.radius !== undefined) value['border-radius'] = `${next.radius}px`;
operations.push({ file: fileId, op: 'setField', id, key: 'style', value });
if (node.placements?.absolute) {
const { absolute, ...rest } = node.placements;
operations.push({ file: fileId, op: 'setField', id, key: 'placements', value: rest });
}
}
operations.push({ file: fileId, op: 'setField', id: root.id, key: 'style', value: { ...root.style, width: `${to.width}px`, height: `${to.height}px` } });
return operations;
}
/**
* The file's document at another format, for previews and export. Only the
* resized nodes are copied; the rest are shared with `file.doc`, so this is
* cheap enough to run for every format on every frame of a drag. Clone it
* before editing it.
*
* @param {object} file Designer file.
* @param {{ width: number, height: number }} to Size.
* @returns {object} Document.
*/
export function resizedDocument(file, to) {
const doc = { ...file.doc, nodes: { ...file.doc.nodes } };
for (const operation of formatOperations(file.doc, file.id, to)) {
doc.nodes[operation.id] = { ...doc.nodes[operation.id], [operation.key]: operation.value };
}
return doc;
}
/**
* The post's markup: the file's template without the `<template>` wrapper.
*
* @param {object} file Designer file.
* @returns {string} HTML.
*/
export function postMarkup(file) {
const source = exportFile(file);
const match = /<template>([\s\S]*)<\/template>/.exec(source);
return (match ? match[1] : source).trim();
}
/** Google Fonts stylesheets for the fonts a post uses. */
export function postFontLinks(doc) {
return googleFontLinks(Object.values(doc.nodes).filter((node) => node.style?.['font-family']).map((node) => ({ fontFamily: node.style['font-family'], fontWeight: node.style['font-weight'] })));
}
/**
* A standalone HTML document for a post, as the poster export writes one.
*
* @param {object} file Designer file.
* @returns {string} HTML document.
*/
export function exportPostHtml(file) {
const root = file.doc.nodes[file.doc.rootId];
const title = root?.props?.['aria-label'] || 'Social post';
const fonts = postFontLinks(file.doc).map((href) => `<link rel="stylesheet" href="${escapeHtml(href)}">`).join('');
return `<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)}</title>${fonts}<style>body{margin:0}*{box-sizing:border-box}@media print{@page{margin:0}body{print-color-adjust:exact}}</style></head>\n<body>\n${postMarkup(file)}\n</body>\n</html>`;
}
Starting designs
import { createBackgroundState, backgroundImageToCss } from '../background-editor/backgroundModel.js';
import { createBackdrop, backdropToCss } from '../backdrop-editor/backdropModel.js';
import { createDesignerBorder, borderToCss } from '../border-editor/borderModel.js';
import { shadowListToCss } from '../shadow-editor/shadowModel.js';
import { typographyToStyle } from '../typography-editor/typographyModel.js';
import { googleFontLinks } from '../typography-editor/googleFonts.js';
export const posterImage = 'https://images.unsplash.com/photo-1609674750700-33895b9b7ce1?auto=format&fit=crop&w=1080&q=85';
export const fontOptions = [
{ label: 'Condensed', value: 'Impact, "Arial Narrow", sans-serif' },
{ label: 'Sans serif', value: 'Arial, Helvetica, sans-serif' },
{ label: 'Serif', value: 'Georgia, "Times New Roman", serif' },
{ label: 'Monospace', value: 'monospace' },
];
/** Creates a complete HTML layer with deterministic, caller-supplied identity. */
export function createPosterLayer(type, id, overrides = {}) {
return {
id, type, name: type === 'text' ? 'Text' : type === 'image' ? 'Image' : 'Shape',
value: type === 'text' ? 'Your next adventure' : type === 'image' ? posterImage : '',
x: 80, y: 480, width: 920, height: type === 'text' ? 260 : 600, pinX: 'auto', pinY: 'auto',
background: createBackgroundState({}, { allowEmpty: true }), backdrop: createBackdrop(), border: createDesignerBorder({ enabled: false }), locked: false, hidden: false, opacity: 100, radius: 0, shadows: [],
color: '#171717', fill: type === 'shape' ? '#f4d35e' : 'transparent',
fontFamily: fontOptions[0].value, fontSize: 100, fontWeight: '400', lineHeight: 1.1, letterSpacing: -2,
textAlign: 'center', textTransform: 'uppercase', fontStyle: 'normal', textDecoration: 'none',
objectFit: 'cover', objectY: 50, blur: 0, saturation: 100, brightness: 100, sepia: 0,
...overrides,
};
}
/** Returns the initial portrait poster; the photo is locked so text remains easy to select. */
export function createPoster() {
return {
version: 1, title: 'A weekend in the Highlands', width: 1080, height: 1920, background: '#ffffff',
layers: [
createPosterLayer('image', 'photo', { name: 'Glencoe, Scottish Highlands', x: 0, y: 0, width: 1080, height: 1920, locked: true }),
createPosterLayer('text', 'headline', { name: 'Headline', value: 'A WEEKEND IN\nTHE HIGHLANDS', x: 40, y: 90, width: 1000, height: 350, fontSize: 120 }),
],
};
}
/** Returns a link-preview card at the Open Graph size, built from the same layers as the poster. */
export function createSocialCard() {
const sans = fontOptions[1].value;
const text = { fontFamily: sans, textAlign: 'left', textTransform: 'none', letterSpacing: 0 };
return {
version: 1, title: 'Small ideas. Worth sharing.', width: 1200, height: 630, background: '#f4f1ea',
layers: [
createPosterLayer('shape', 'accent', { name: 'Accent', x: 0, y: 0, width: 24, height: 630, fill: '#e4572e' }),
createPosterLayer('text', 'brand', { name: 'Brand', value: 'DOM STUDIO / FIELD NOTES', x: 80, y: 72, width: 640, height: 40, ...text, fontSize: 24, fontWeight: '700', letterSpacing: 4, lineHeight: 1.2, color: '#e4572e' }),
createPosterLayer('text', 'headline', { name: 'Headline', value: 'Small ideas.\nWorth sharing.', x: 80, y: 160, width: 1000, height: 260, ...text, fontSize: 104, fontWeight: '900', letterSpacing: -3, lineHeight: 1.02 }),
createPosterLayer('text', 'caption', { name: 'Caption', value: 'Made with real HTML. Ready for your next idea.', x: 80, y: 470, width: 900, height: 60, ...text, fontSize: 32, lineHeight: 1.3, color: '#525252' }),
],
};
}
/**
* Returns an Instagram post built to be resized: the photo fills the frame,
* the brand and badge hold the top corners, and the copy sits on a scrim
* pinned to the bottom edge and stretched between the sides.
*/
export function createSocialPost() {
const sans = fontOptions[1].value;
const text = { fontFamily: sans, textAlign: 'left', textTransform: 'none', letterSpacing: 0, lineHeight: 1.2, color: '#ffffff' };
return {
version: 1, title: 'Five days in the Highlands', width: 1080, height: 1350, background: '#0f172a',
layers: [
createPosterLayer('image', 'photo', { name: 'Glencoe, Scottish Highlands', x: 0, y: 0, width: 1080, height: 1350, objectY: 40, locked: true }),
createPosterLayer('shape', 'scrim', { name: 'Scrim', x: 0, y: 760, width: 1080, height: 590, fill: '#0f172a', opacity: 72 }),
createPosterLayer('text', 'brand', { name: 'Brand', value: 'NORTHBOUND TRAVEL', x: 64, y: 64, width: 600, height: 40, ...text, fontSize: 28, fontWeight: '700', letterSpacing: 4 }),
createPosterLayer('shape', 'badge', { name: 'Badge', x: 856, y: 48, width: 176, height: 176, radius: 88, fill: '#f4d35e' }),
createPosterLayer('text', 'badge-label', { name: 'Badge label', value: 'NEW\nROUTE', x: 856, y: 96, width: 176, height: 80, ...text, fontSize: 30, fontWeight: '900', lineHeight: 1.1, textAlign: 'center', color: '#171717' }),
createPosterLayer('text', 'headline', { name: 'Headline', value: 'Five days in\nthe Highlands', x: 64, y: 840, width: 952, height: 250, ...text, fontSize: 104, fontWeight: '900', letterSpacing: -2, lineHeight: 1.02, pinX: 'stretch' }),
createPosterLayer('text', 'details', { name: 'Details', value: 'Departs 14 March · From £890 · Small groups', x: 64, y: 1150, width: 952, height: 60, ...text, fontSize: 34, color: '#e2e8f0', pinX: 'stretch' }),
],
};
}
/** Projects a layer into ordinary CSS, shared by the visible HTML and exported document. */
export function posterLayerStyle(layer, index) {
return {
position: 'absolute', boxSizing: 'border-box', margin: '0', left: `${layer.x}px`, top: `${layer.y}px`,
width: `${layer.width}px`, height: `${layer.height}px`, zIndex: index,
opacity: layer.opacity / 100, borderRadius: `${layer.radius}px`, boxShadow: shadowListToCss(layer.shadows), border: borderToCss(layer.border),
...(layer.hidden ? { display: 'none' } : {}),
...(layer.type === 'image' ? {
objectFit: layer.objectFit, objectPosition: `50% ${layer.objectY}%`,
filter: `blur(${layer.blur}px) saturate(${layer.saturation}%) brightness(${layer.brightness}%) sepia(${layer.sepia}%)`,
} : { backgroundColor: layer.fill, backgroundImage: backgroundImageToCss(layer.background), backdropFilter: backdropToCss(layer.backdrop), WebkitBackdropFilter: backdropToCss(layer.backdrop) }),
...(layer.type === 'text' ? {
...typographyToStyle(layer), whiteSpace: 'pre-wrap', overflowWrap: 'break-word',
} : {}),
};
}
/** Escapes text and quoted attributes so user content cannot become executable markup. */
export function escapeHtml(value) {
return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
}
/** Accepts browser image URLs and raster data URLs, rejecting executable or vector payloads. */
export function safePosterImage(value) {
if (/^data:image\/(png|jpeg|webp|gif);base64,[a-z0-9+/=\s]+$/i.test(value)) return value;
try {
const url = new URL(value);
return ['https:', 'http:'].includes(url.protocol) ? url.href : '';
} catch { return ''; }
}
/** Serializes camel-case styles as escaped HTML attributes. */
function styleAttribute(style) {
return escapeHtml(Object.entries(style).map(([key, value]) => `${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}:${value}`).join(';'));
}
/** Returns selectable source lines without any editor overlays, selection IDs or hidden layers. */
export function posterHtmlLines(poster) {
return [
{ id: 'frame', text: `<article aria-label="${escapeHtml(poster.title)}" style="position:relative;width:${poster.width}px;height:${poster.height}px;overflow:hidden;background:${escapeHtml(poster.background)}">` },
...poster.layers.flatMap((layer, index) => {
if (layer.hidden) return [];
const style = styleAttribute(posterLayerStyle(layer, index));
const text = layer.type === 'image'
? `<img src="${escapeHtml(safePosterImage(layer.value))}" alt="${escapeHtml(layer.name)}" style="${style}">`
: `<${layer.type === 'text' ? 'p' : 'div'} style="${style}">${layer.type === 'text' ? escapeHtml(layer.value) : ''}</${layer.type === 'text' ? 'p' : 'div'}>`;
return [{ id: layer.id, text: `\t${text}` }];
}),
{ id: 'frame', text: '</article>' },
];
}
/** Generates a standalone HTML document with the original poster dimensions. */
export function exportPosterHtml(poster) {
return `<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(poster.title)}</title>${googleFontLinks(poster.layers.filter((layer) => layer.type === 'text')).map((href) => `<link rel="stylesheet" href="${escapeHtml(href)}">`).join('')}<style>body{margin:0}*{box-sizing:border-box}@media print{@page{margin:0}body{print-color-adjust:exact}}</style></head>\n<body>\n${posterHtmlLines(poster).map((line) => line.text).join('\n')}\n</body>\n</html>`;
}
/** Applies finite CSS geometry emitted by the shared stage; locked layers cannot be moved. */
export function applyPosterGeometry(layer, style) {
if (!layer || layer.locked) return;
for (const [css, key] of [['left', 'x'], ['top', 'y'], ['width', 'width'], ['height', 'height']]) {
const value = Number.parseFloat(style[css]);
if (Number.isFinite(value)) layer[key] = ['width', 'height'].includes(key) ? Math.max(16, value) : value;
}
}
Social card preset
<script setup>
import SocialStudio from '../social/SocialStudio.vue';
import DomMonacoCodeView from '../core/MonacoCodeView.vue';
import { loadMonaco } from '../core/loadMonaco.js';
import { createSocialCard } from '../poster/posterModel.js';
// The social card is the Social post studio starting from a link preview, with its code open.
</script>
<template>
<SocialStudio :code-editor="DomMonacoCodeView" :code-editor-props="{ load: loadMonaco }" :create="createSocialCard" title="Social card" storage-key="dom-studio-social-card-v2" file-name="dom-studio-social-card.html" code-open>
<template #start><RouterLink to="/designer" class="text-sm text-muted-fg hover:text-canvas-fg">← UI designer</RouterLink></template>
</SocialStudio>
</template>
Flowchart editor
<script setup>
import { computed, nextTick, onMounted, ref } from 'vue';
import { VueFlow, Handle, Position, MarkerType, useVueFlow } from '@vue-flow/core';
import { Background } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls';
import { MiniMap } from '@vue-flow/minimap';
import { DomButton, DomSplitterPanel, DomTextInput, DomTextareaInput } from '../../lib/vue';
import '@vue-flow/core/dist/style.css';
import '@vue-flow/controls/dist/style.css';
import '@vue-flow/minimap/dist/style.css';
const ready = ref(false);
const showInspector = ref(false);
const board = ref(null);
const panelWidth = ref(260);
const notice = ref('Edits stay in this tab. Export JSON before leaving.');
let nextId = 5;
const nodes = ref([
{ id: '1', type: 'step', position: { x: 60, y: 80 }, data: { label: 'An idea arrives', detail: 'Capture a new idea for the next campaign.' } },
{ id: '2', type: 'step', position: { x: 380, y: 80 }, data: { label: 'Create a draft', detail: 'Turn the idea into a first version.' } },
{ id: '3', type: 'step', position: { x: 700, y: -40 }, data: { label: 'Review together', detail: 'Gather feedback and refine the details.' } },
{ id: '4', type: 'step', position: { x: 700, y: 210 }, data: { label: 'Ready to share', detail: 'Publish something worth sharing.' } },
]);
const edges = ref([
{ id: '1-2', source: '1', target: '2' },
{ id: '2-3', source: '2', target: '3' },
{ id: '3-4', source: '3', target: '4' },
]);
const { addEdges, addNodes, removeNodes, removeEdges, findNode, addSelectedNodes, removeSelectedElements, screenToFlowCoordinate, fitView, toObject } = useVueFlow('designer-flowchart');
const selectedNode = computed(() => nodes.value.find((node) => node.selected));
const selectedEdge = computed(() => edges.value.find((edge) => edge.selected));
const edgeDefaults = { type: 'smoothstep', markerEnd: { type: MarkerType.ArrowClosed, color: 'var(--muted-fg)' }, style: { stroke: 'var(--muted-fg)', strokeWidth: 2 } };
/** Mounts the browser-only graph after SSR hydration. */
function mount() { ready.value = true; }
onMounted(mount);
/** Selects a node from the sidebar using the same selection state as the stage. */
function selectNode(id) {
removeSelectedElements();
const node = findNode(id);
if (node) addSelectedNodes([node]);
}
/** Adds a step near the visible viewport centre, in graph coordinates. */
async function addStep() {
await revealStage();
const bounds = board.value.getBoundingClientRect();
const position = screenToFlowCoordinate({ x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2 });
position.x -= 110;
position.y -= 50;
const id = String(nextId++);
addNodes([{ id, type: 'step', position, data: { label: 'New step', detail: 'Describe what happens here.' } }]);
selectNode(id);
}
/** Reveals the mobile stage and allows its viewport measurement to catch up. */
async function revealStage() {
showInspector.value = false;
await nextTick();
await new Promise(requestAnimationFrame);
}
/** Fits all nodes after revealing the stage when the mobile inspector was open. */
async function fitGraph() {
await revealStage();
fitView({ padding: 0.2 });
}
/** Adds an arrow between two distinct nodes; Vue Flow suppresses duplicate connections. */
function connect(connection) {
if (connection.source !== connection.target) addEdges([connection]);
}
/** Deletes the selected node and its connections, or the selected connection alone. */
function removeSelection() {
if (selectedNode.value) removeNodes([selectedNode.value.id]);
else if (selectedEdge.value) removeEdges([selectedEdge.value.id]);
}
/** Saves graph data and viewport position as a portable JSON document. */
function exportGraph() {
const url = URL.createObjectURL(new Blob([JSON.stringify(toObject(), null, '\t')], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.download = 'dom-studio-flowchart.json';
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
notice.value = 'Flowchart exported as JSON.';
}
</script>
<template>
<main class="flowchart flex h-dvh min-h-0 flex-col bg-canvas text-fg">
<header class="flex shrink-0 flex-wrap items-center gap-3 border-b border-border px-4 py-3">
<RouterLink to="/designer" class="text-sm text-muted-fg">← UI designer</RouterLink><RouterLink to="/designer/assembly#flowchart" class="text-sm text-primary underline underline-offset-4">Assembly guide</RouterLink>
<h1 class="text-sm font-semibold">Flowchart designer</h1>
<span class="rounded bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase text-primary">Beta</span>
<div class="ml-auto flex gap-2">
<DomButton size="sm" variant="outline" :disabled="!ready" @click="addStep">+ Step</DomButton>
<DomButton size="sm" variant="outline" :disabled="!ready" @click="fitGraph">Fit</DomButton>
<DomButton size="sm" variant="outline" :disabled="!ready" @click="exportGraph">Export JSON</DomButton>
<DomButton size="sm" variant="outline" class="lg:hidden" :aria-pressed="showInspector" @click="showInspector = !showInspector">{{ showInspector ? 'Stage' : 'Inspector' }}</DomButton>
</div>
</header>
<DomSplitterPanel v-model:start-size="panelWidth" :min-start="220" :min-main="280" class="flow-panels min-h-0 flex-1" :data-inspector="showInspector">
<template #start>
<aside class="h-full overflow-auto border-r border-border p-4" aria-label="Flowchart inspector">
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-fg">Steps</h2>
<div class="mb-6 space-y-1">
<button v-for="node in nodes" :key="node.id" type="button" class="w-full rounded px-3 py-2 text-left text-sm hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring" :class="node.selected ? 'bg-primary/10 text-primary' : ''" :aria-pressed="Boolean(node.selected)" @click="selectNode(node.id)">{{ node.data.label }}</button>
</div>
<div v-if="selectedNode" class="space-y-4">
<DomTextInput v-model="selectedNode.data.label" label="Step name" />
<DomTextareaInput v-model="selectedNode.data.detail" label="Description" />
</div>
<p v-else class="text-sm leading-6 text-muted-fg">{{ selectedEdge ? 'Connection selected.' : 'Select a step to edit its name and description.' }}</p>
<DomButton v-if="selectedNode || selectedEdge" size="sm" variant="outline" class="mt-4" @click="removeSelection">Delete {{ selectedNode ? 'step' : 'connection' }}</DomButton>
<p class="mt-6 text-xs leading-5 text-muted-fg">Drag a step to move it. Drag between the dots to connect steps. Drag empty space to pan; scroll or pinch to zoom. Select a connection to delete it.</p>
<p class="mt-4 text-xs leading-5 text-muted-fg">Powered by <a href="https://vueflow.dev/" target="_blank" rel="noopener noreferrer" class="underline">Vue Flow</a>. An open-ended workspace with HTML nodes and SVG connections.</p>
</aside>
</template>
<template #default>
<div ref="board" class="h-full min-h-0" aria-label="Flowchart stage">
<VueFlow v-if="ready" id="designer-flowchart" v-model:nodes="nodes" v-model:edges="edges" :default-edge-options="edgeDefaults" :min-zoom="0.1" :max-zoom="3" fit-view-on-init :delete-key-code="null" @connect="connect">
<Background :gap="24" :size="1" pattern-color="var(--border)" />
<Controls position="bottom-left" :show-interactive="false">
<template #icon-zoom-in><span aria-hidden="true">+</span><span class="sr-only">Zoom in</span></template>
<template #icon-zoom-out><span aria-hidden="true">−</span><span class="sr-only">Zoom out</span></template>
<template #icon-fit-view><span aria-hidden="true">⛶</span><span class="sr-only">Fit flowchart</span></template>
</Controls>
<MiniMap class="hidden sm:block" pannable zoomable node-color="var(--primary)" mask-color="var(--secondary)" />
<template #node-step="{ data, selected }">
<div class="w-[220px] rounded-xl border bg-canvas p-4 text-fg shadow-sm" :class="selected ? 'border-primary ring-2 ring-primary/20' : 'border-border'">
<Handle type="target" :position="Position.Left" aria-label="Input connection" />
<div class="mb-2 text-[10px] font-semibold uppercase tracking-wider text-primary">Step</div>
<h2 class="break-words text-sm font-semibold">{{ data.label }}</h2>
<p class="mt-2 whitespace-pre-wrap break-words text-xs leading-5 text-muted-fg">{{ data.detail }}</p>
<Handle type="source" :position="Position.Right" aria-label="Output connection" />
</div>
</template>
</VueFlow>
</div>
</template>
</DomSplitterPanel>
<footer class="shrink-0 border-t border-border px-4 py-2 text-xs text-muted-fg" role="status">{{ notice }}</footer>
</main>
</template>
<style scoped>
.flowchart :deep(.vue-flow__handle) { width: 10px; height: 10px; border: 2px solid var(--canvas); background: var(--primary); }
.flowchart :deep(.vue-flow__connection-path) { stroke: var(--primary); }
.flowchart :deep(.vue-flow__edge.selected .vue-flow__edge-path) { stroke: var(--primary) !important; }
.flowchart :deep(.vue-flow__controls-button) { background: var(--canvas); color: var(--fg); fill: currentColor; border-color: var(--border); }
.flowchart :deep(.vue-flow__controls-button:hover) { background: var(--secondary); }
.flowchart :deep(.vue-flow__minimap) { background: var(--canvas); border: 1px solid var(--border); }
/* Preserve a full-width stage on small screens; the inspector is an explicit tab. */
@media (max-width: 1023px) {
.flow-panels { display: block; min-width: 0 !important; }
.flow-panels :deep(> button), .flow-panels :deep(> section:first-child) { display: none; }
.flow-panels :deep(> section) { height: 100%; }
.flow-panels[data-inspector="true"] :deep(> section:first-child) { display: block; }
.flow-panels[data-inspector="true"] :deep(> section:last-child) { display: none; }
}
</style>
4. Start from another design
The poster, the social card and the Social post studio are one component, SocialStudio, with a different starting design, title and storage key. Each layer’s pins (
data-pin-x,data-pin-y) decide how it follows a change of format, and the Formats strip previews every size live. The code view is two-way: edit the HTML and the design follows.