clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

merge: ship the editor starting experience

Dami committed 2439e67
commit
2439e670c9b17be0ddac5dff0f03066c9708c210
parent
cbaf57eaf2ffabc7392d4108fb375e61a2f7e851
parent
8d5a76c8b8848bbf4ff0e0be6f29beb893c719b9
author
Dami <dami.etoile@gmail.com>
27 files changed, +2560 -454
Mapps/docs/docs/collaboration/working-and-saved-state.md9+-
Mapps/docs/docs/create/index.md23+-
Mapps/docs/docusaurus.config.js2+-
Mapps/editor/PERSISTENCE.md17+-
Mapps/editor/index.html2+-
Aapps/editor/src/bootstrap.js16+
Aapps/editor/src/command-palette.js113+++++
Mapps/editor/src/example-defaults.js14+
Aapps/editor/src/format-icons.js39++
Aapps/editor/src/launcher-catalog.js125++++++
Aapps/editor/src/launcher-previews.js128++++++
Aapps/editor/src/launcher.js570++++++++++++++++++++++++++
Aapps/editor/src/local-documents.js181++++++++
Mapps/editor/src/main.js624+++++++++++++++++++---------
Aapps/editor/src/mobile-panel.js10+
Mapps/editor/src/preview.js70+++-
Mapps/editor/src/style.css655+++++++++++++++++++++---------
Mapps/editor/src/type-drafts.js9+-
Aapps/editor/test/command-palette.test.js51++
Mapps/editor/test/example-defaults.test.js22+
Aapps/editor/test/format-icons.test.js11+
Aapps/editor/test/launcher-catalog.test.js46++
Aapps/editor/test/launcher-previews.test.js62+++
Aapps/editor/test/local-documents.test.js75+++
Aapps/editor/test/mobile-panel.test.js22+
Mapps/editor/test/preview-routing.test.js77++++
Mapps/editor/test/type-drafts.test.js41+-
M apps/docs/docs/collaboration/working-and-saved-state.md +7 -2
M apps/docs/docs/create/index.md +15 -8
@@ -35,11 +35,15 @@ being guessed or silently converted. Imported input is limited to 5 MiB.
3535 3636 ## Choose a type3737 38-Use the filter on the [diagram type list](/) to search by name, format, or purpose. The list has one syntax page and one style page for every supported type.38+Use the launcher at [diagram.zip](https://diagram.zip/) to filter by name,39+extension, or purpose. Choose **Create** to start a new document from that40+format's worked example.3941 40-When you choose a type in the editor, diagram.zip records it in the page URL as41-`?type={type}`. Reloading or copying that working URL keeps the same active type.42-Saved read and edit links remain alias-based and do not depend on this query.42+When you change format in the editor, diagram.zip preserves the current source,43+title, description, and presentation while choosing a different renderer.44+Renderer-specific options reset because they may not apply to the new format.45+The active type is recorded in the page URL as `?type={type}`. Saved read and46+edit links remain alias-based and do not depend on this query.4347 4448 ## Source, Details, and preview4549
@@ -61,7 +65,9 @@ Read [General presentation settings](/style/presentation/) for the Details docum
6165 6266 ## Local drafts6367 64-Open diagrams keep unsaved changes in local browser storage. An edited example shows a reset icon that restores its bundled source and details.68+Open diagrams keep unsaved changes in local browser storage. The launcher lists69+recent local documents and published links on this device. Local documents can70+be reopened, duplicated, or explicitly removed.6571 6672 Anonymous drafts default to local work. **Save as File** downloads an editable6773 SVG without creating a server alias. The downloaded file can be reopened on
@@ -83,6 +89,7 @@ a self-contained packed SVG URL; published diagrams use their stable alias
8389 render. Read [Share a diagram](/collaboration/sharing/) before you send an edit8490 link.8591 86-The save dropdown selects **Publish**, **Encrypt & Publish**, or **Save as File**87-as the main button action. Selecting an action does not run it. Copy actions in88-the dropdown run immediately.92+Open the command palette with **Command-K** on macOS or **Control-K** elsewhere.93+It includes file export, publishing, copy/share actions, format changes, preview94+commands, opening an editable SVG, and documentation. **Command-S / Control-S**95+saves an editable SVG file.
M apps/docs/docusaurus.config.js +1 -1
M apps/editor/PERSISTENCE.md +12 -5
@@ -216,6 +216,12 @@ They do not write to D1 or R2. Locked drafts are deliberately not written to
216216 local storage as plaintext; they persist only when the user explicitly217217 publishes.218218 219+The root route is a local-first launcher. Its recent index stores metadata only;220+local document bodies use separate keys and published aliases are refetched.221+Changing format preserves the parked per-format drafts inside the local document222+body. The legacy single anonymous draft is migrated into this model on first223+boot.224+219225 The left pane separates diagram Source from schema-validated Details JSON.220226 Details owns title, description, and presentation values. Invalid Details text221227 remains recoverable while file export, publishing, sharing, privacy, and type
@@ -226,7 +232,7 @@ changes stay blocked.
226232 URL and create no alias.227233 - New draft: **Publish** creates an alias and changes the browser to its read URL.228234 - Writable alias: **Publish** updates with the last revision.229-- Anonymous example: the reset icon discards its local draft after confirmation.235+- Local document: the launcher can reopen, duplicate, or explicitly delete it.230236 - Read-only alias: edits form a local fork; **Publish a Copy** creates a new alias.231237 - Conflict: offer **Reload published** or **Publish as new**. Never overwrite silently.232238 - Share, open: copy read link, copy edit link, SVG link, or Markdown.
@@ -234,10 +240,11 @@ changes stay blocked.
234240 link. SVG and Markdown embeds are visibly disabled with the explanation that235241 encrypted diagrams cannot be embedded.236242 237-The header dropdown selects Publish, Encrypt & Publish, or Save as File as the238-main button action without executing it. Encrypt & Publish opens the password239-dialog only when the user clicks the selected main action. Copy actions remain240-immediate commands.243+The compact header keeps title, local-change state, render status, and a format244+button. The format button or **Command-K / Control-K** opens the command palette.245+The palette owns format changes, file export, publishing, copy/share actions,246+view commands, open, new, and documentation. **Command-S / Control-S** always247+means Save as File; publishing remains an explicit command.241248 242249 The password and edit capability should be sent through separate channels when243250 practical. The edit link itself is a bearer credential and the UI must label it
M apps/editor/index.html +1 -1
A apps/editor/src/bootstrap.js +16 -0
A apps/editor/src/command-palette.js +113 -0
@@ -0,0 +1,113 @@
1+/**2+ * A DOM-free command palette model.3+ *4+ * Groups are objects with a `commands` array. A command may contain `id`,5+ * `label`, `description`, `keywords`, `shortcut`, `disabled`, and `run`.6+ * Group and command order is retained throughout the model's lifetime.7+ */8+9+const asText = value => value == null ? '' : String(value)10+11+function keywordsText(keywords) {12+ if (Array.isArray(keywords)) return keywords.map(asText).join(' ')13+ return asText(keywords)14+}15+16+function searchableText(command) {17+ return [18+ command.id,19+ command.label,20+ command.description,21+ keywordsText(command.keywords),22+ command.shortcut,23+ ].map(asText).join(' ').toLocaleLowerCase()24+}25+26+function queryTokens(query) {27+ return asText(query).toLocaleLowerCase().trim().split(/\s+/).filter(Boolean)28+}29+30+function commandsForGroup(group) {31+ if (Array.isArray(group)) return group32+ return Array.isArray(group?.commands) ? group.commands : []33+}34+35+/** Flatten groups while retaining references and stable source order. */36+export function flattenCommandGroups(groups = []) {37+ const source = Array.isArray(groups) ? groups : []38+ return source.flatMap(group => commandsForGroup(group))39+}40+41+/**42+ * Create a command palette model. Methods return command objects (or null)43+ * and never interact with the DOM.44+ */45+export function createCommandPaletteModel(groups = []) {46+ const commands = flattenCommandGroups(groups)47+ const byId = new Map()48+ for (const command of commands) {49+ if (command && command.id != null && !byId.has(command.id)) byId.set(command.id, command)50+ }51+52+ let query = ''53+ let selectedId = null54+55+ const matchingCommands = () => {56+ const tokens = queryTokens(query)57+ if (!tokens.length) return commands.filter(Boolean)58+ return commands.filter(command => {59+ if (!command) return false60+ const text = searchableText(command)61+ return tokens.every(token => text.includes(token))62+ })63+ }64+65+ const enabled = command => command && command.disabled !== true66+ const enabledResults = () => matchingCommands().filter(enabled)67+68+ const select = command => {69+ selectedId = command?.id ?? null70+ return command ?? null71+ }72+73+ const selectFirst = (nextQuery = query) => {74+ query = asText(nextQuery)75+ return select(enabledResults()[0])76+ }77+78+ const move = (direction, nextQuery = query) => {79+ query = asText(nextQuery)80+ const results = matchingCommands()81+ const available = results.filter(enabled)82+ if (!available.length) return select(null)83+84+ const currentIndex = available.findIndex(command => command.id === selectedId)85+ if (currentIndex < 0) return select(available[direction > 0 ? 0 : available.length - 1])86+ const start = currentIndex87+ return select(available[(start + direction + available.length) % available.length])88+ }89+90+ return {91+ groups,92+ commands,93+ get query() { return query },94+ get selectedId() { return selectedId },95+ get selected() { return byId.get(selectedId) ?? null },96+ filter(nextQuery = '') {97+ query = asText(nextQuery)98+ return matchingCommands()99+ },100+ results() { return matchingCommands() },101+ selectFirst,102+ next: (nextQuery = query) => move(1, nextQuery),103+ previous: (nextQuery = query) => move(-1, nextQuery),104+ prev: (nextQuery = query) => move(-1, nextQuery),105+ selectById(id) {106+ const command = byId.get(id) ?? null107+ return select(enabled(command) ? command : null)108+ },109+ getById(id) { return byId.get(id) ?? null },110+ }111+}112+113+export default createCommandPaletteModel
M apps/editor/src/example-defaults.js +14 -0
A apps/editor/src/format-icons.js +39 -0
@@ -0,0 +1,39 @@
1+const ICON_KIND_BY_TYPE = Object.freeze({2+ plantuml: 'sequence', mermaid: 'flow', seqdiag: 'sequence', actdiag: 'flow', blockdiag: 'flow', bpmn: 'flow',3+ c4plantuml: 'layers', structurizr: 'layers', d2: 'graph', graphviz: 'graph', nomnoml: 'classes', umlet: 'classes',4+ dbml: 'database', erd: 'database', vega: 'chart', vegalite: 'chart',5+ nwdiag: 'network', packetdiag: 'packet', rackdiag: 'rack', bytefield: 'packet', wavedrom: 'wave', symbolator: 'chip', wireviz: 'cable',6+ excalidraw: 'drawing', diagramsnet: 'canvas', ditaa: 'terminal', goat: 'terminal', svgbob: 'terminal', pikchr: 'drawing', tikz: 'typeset',7+})8+9+const ICON_PATHS = Object.freeze({10+ sequence: '<path d="M12 7v38M30 7v38M48 7v38M12 16h18l-4-4m4 4-4 4M48 34H30l4-4m-4 4 4 4"/>',11+ flow: '<rect x="7" y="23" width="13" height="10" rx="2"/><rect x="40" y="9" width="13" height="10" rx="2"/><rect x="40" y="37" width="13" height="10" rx="2"/><path d="M20 28h10V14h10M30 28v14h10"/>',12+ layers: '<rect x="12" y="12" width="31" height="22" rx="2"/><rect x="17" y="17" width="31" height="22" rx="2"/><rect x="22" y="22" width="31" height="22" rx="2"/><path d="M28 30h14M28 36h10"/>',13+ graph: '<circle cx="12" cy="29" r="5"/><circle cx="30" cy="13" r="5"/><circle cx="48" cy="29" r="5"/><circle cx="30" cy="45" r="5"/><path d="m16 25 10-9m8 0 10 9m0 8-10 9m-8 0-10-9m1-4h26"/>',14+ classes: '<rect x="6" y="10" width="20" height="36" rx="2"/><rect x="38" y="18" width="16" height="28" rx="2"/><path d="M6 20h20M6 29h20M38 28h16M26 27h7l5-5m-5 5 5 5"/>',15+ database: '<ellipse cx="18" cy="13" rx="10" ry="5"/><path d="M8 13v28c0 3 4 5 10 5s10-2 10-5V13m-20 9c0 3 4 5 10 5s10-2 10-5m0 9c0 3-4 5-10 5S8 34 8 31"/><rect x="38" y="19" width="16" height="26" rx="2"/><path d="M38 28h16M38 36h16M28 31h10"/>',16+ chart: '<path d="M8 47V9m0 38h46"/><rect x="15" y="32" width="7" height="15" rx="1"/><rect x="28" y="22" width="7" height="25" rx="1"/><rect x="41" y="12" width="7" height="35" rx="1"/>',17+ network: '<circle cx="10" cy="29" r="5"/><circle cx="30" cy="12" r="5"/><circle cx="50" cy="29" r="5"/><circle cx="30" cy="46" r="5"/><path d="m14 25 12-10m8 0 12 10m0 8L34 43m-8 0L14 33"/>',18+ packet: '<path d="M7 17h46v25H7zM18 17v25M30 17v25M42 17v25M7 29h46"/><path d="m23 11 7-5 7 5"/>',19+ rack: '<rect x="13" y="7" width="34" height="46" rx="3"/><path d="M13 18h34M13 29h34M13 40h34M20 13h16M20 24h20M20 35h13M20 46h18"/>',20+ wave: '<path d="M5 18h9V8h10v20h10V13h10v15h11M5 42h8l4-12 7 24 8-24 7 12h16"/>',21+ chip: '<rect x="14" y="13" width="32" height="34" rx="3"/><path d="M22 23h16v14H22zM7 20h7M7 29h7M7 40h7M46 20h7M46 29h7M46 40h7M21 7v6M30 7v6M39 7v6M21 47v6M30 47v6M39 47v6"/>',22+ cable: '<path d="M8 17h12v10H8zM40 33h12v10H40zM20 22h7a8 8 0 0 1 8 8v3h5M13 27v12c0 4 3 7 7 7h11"/><circle cx="31" cy="46" r="2"/>',23+ drawing: '<path d="m11 45 4-14L40 6l10 10-25 25-14 4Z"/><path d="m16 30 10 10M36 10l10 10M11 45l9-3-6-6-3 9Z"/>',24+ canvas: '<rect x="7" y="10" width="46" height="39" rx="3"/><circle cx="19" cy="23" r="5"/><path d="m10 43 13-12 8 7 8-10 11 15M43 16h4M45 14v4"/>',25+ terminal: '<rect x="6" y="10" width="48" height="40" rx="4"/><path d="m14 22 7 6-7 6M27 36h14"/>',26+ typeset: '<path d="M13 12h34M30 12v36M21 48h18M14 21h11M35 39h11"/><path d="m42 21 5 5-5 5"/>',27+})28+29+export function formatIconKind(type) {30+ return ICON_KIND_BY_TYPE[type] ?? 'canvas'31+}32+33+export function hasFormatIcon(type) {34+ return Object.hasOwn(ICON_KIND_BY_TYPE, type)35+}36+37+export function formatIconMarkup(type) {38+ return `<svg viewBox="0 0 60 60" aria-hidden="true" focusable="false">${ICON_PATHS[formatIconKind(type)]}</svg>`39+}
A apps/editor/src/launcher-catalog.js +125 -0
@@ -0,0 +1,125 @@
1+import { diagramTypes } from './diagram-types.js'2+3+// The launcher is deliberately data-only. Keep this catalog independent from4+// the DOM so it can be used by both the initial launcher and the command5+// palette without duplicating format metadata.6+const GROUP_DEFINITIONS = [7+ {8+ id: 'flow-sequence',9+ label: 'Flow & sequence',10+ description: 'Processes, interactions, and timelines.',11+ types: ['plantuml', 'mermaid', 'seqdiag', 'actdiag', 'blockdiag', 'bpmn'],12+ },13+ {14+ id: 'architecture',15+ label: 'Architecture',16+ description: 'Systems, components, relationships, and software structure.',17+ types: ['c4plantuml', 'structurizr', 'd2', 'graphviz', 'nomnoml', 'umlet'],18+ },19+ {20+ id: 'data-charts',21+ label: 'Data & charts',22+ description: 'Data models, plots, and technical visualizations.',23+ types: ['dbml', 'erd', 'vega', 'vegalite'],24+ },25+ {26+ id: 'network-hardware',27+ label: 'Network & hardware',28+ description: 'Networks, packets, wiring, and low-level hardware layouts.',29+ types: ['nwdiag', 'packetdiag', 'rackdiag', 'bytefield', 'wavedrom', 'symbolator', 'wireviz'],30+ },31+ {32+ id: 'drawing-ascii',33+ label: 'Drawing & ASCII',34+ description: 'Freeform drawings, markup diagrams, and text-based art.',35+ types: ['excalidraw', 'diagramsnet', 'ditaa', 'goat', 'svgbob', 'pikchr', 'tikz'],36+ },37+]38+39+const DESCRIPTIONS = {40+ plantuml: 'Text-based UML and software diagrams.',41+ mermaid: 'Markdown-friendly flowcharts and diagrams.',42+ graphviz: 'Graphs and relationship diagrams using DOT.',43+ d2: 'Declarative diagrams with a modern, readable syntax.',44+ c4plantuml: 'C4 architecture diagrams in PlantUML syntax.',45+ blockdiag: 'Block and dependency diagrams.',46+ seqdiag: 'Simple sequence diagrams.',47+ actdiag: 'Activity and process diagrams.',48+ nwdiag: 'Network topology diagrams.',49+ packetdiag: 'Packet and protocol layout diagrams.',50+ rackdiag: 'Rack and infrastructure diagrams.',51+ bpmn: 'Business process model and notation diagrams.',52+ bytefield: 'Structured byte and protocol field diagrams.',53+ dbml: 'Database schemas from DBML.',54+ diagramsnet: 'Diagrams.net editable drawings.',55+ ditaa: 'ASCII art converted into diagrams.',56+ erd: 'Entity-relationship database diagrams.',57+ excalidraw: 'Hand-drawn style editable diagrams.',58+ goat: 'ASCII-art diagrams using the GoAT syntax.',59+ nomnoml: 'UML-style diagrams with a lightweight syntax.',60+ pikchr: 'Text-based technical illustrations.',61+ structurizr: 'C4 architecture diagrams from a workspace model.',62+ svgbob: 'ASCII diagrams rendered as SVG.',63+ symbolator: 'HDL and digital logic symbol diagrams.',64+ tikz: 'Precise TeX-based technical drawings.',65+ umlet: 'Lightweight UML diagrams.',66+ vega: 'Programmable data visualizations with Vega.',67+ vegalite: 'Concise declarative data visualizations.',68+ wavedrom: 'Digital timing and signal diagrams.',69+ wireviz: 'Cables and wiring harness diagrams.',70+}71+72+const EXTENSIONS = {73+ plantuml: ['.puml', '.pu'], mermaid: ['.mmd'], graphviz: ['.dot', '.gv'], d2: ['.d2'],74+ c4plantuml: ['.puml'], blockdiag: ['.diag'], seqdiag: ['.diag'], actdiag: ['.diag'],75+ nwdiag: ['.diag'], packetdiag: ['.diag'], rackdiag: ['.diag'], bpmn: ['.bpmn'],76+ bytefield: ['.bf'], dbml: ['.dbml'], diagramsnet: ['.drawio'], ditaa: ['.ditaa'],77+ erd: ['.erd'], excalidraw: ['.excalidraw'], goat: ['.goat'], nomnoml: ['.nomnoml'],78+ pikchr: ['.pikchr'], structurizr: ['.dsl'], svgbob: ['.bob'], symbolator: ['.sv'],79+ tikz: ['.tex'], umlet: ['.uxf'], vega: ['.json'], vegalite: ['.json'], wavedrom: ['.json'],80+ wireviz: ['.yaml', '.yml'],81+}82+83+const labels = new Map(diagramTypes.map(type => [type.id, type.label]))84+85+function normalizeEntry(type, group) {86+ return Object.freeze({87+ id: type,88+ label: labels.get(type),89+ description: DESCRIPTIONS[type],90+ extensions: Object.freeze([...(EXTENSIONS[type] ?? [])]),91+ groupId: group.id,92+ groupLabel: group.label,93+ })94+}95+96+export const launcherCatalogGroups = Object.freeze(GROUP_DEFINITIONS.map(group => Object.freeze({97+ id: group.id,98+ label: group.label,99+ description: group.description,100+ items: Object.freeze(group.types.map(type => normalizeEntry(type, group))),101+})))102+103+const entriesById = new Map(launcherCatalogGroups.flatMap(group => group.items).map(entry => [entry.id, entry]))104+105+// Flat data follows diagramTypes order, which keeps keyboard and URL ordering106+// stable even though the launcher presents formats by category.107+export const launcherCatalog = Object.freeze(diagramTypes.map(type => entriesById.get(type.id)))108+109+export function launcherEntryFor(id) {110+ return entriesById.get(id) ?? null111+}112+113+export function filterLauncherCatalog(query = '') {114+ const needle = String(query).trim().toLocaleLowerCase()115+ if (!needle) return launcherCatalog116+ return launcherCatalog.filter(entry => [entry.id, entry.label, entry.description, ...entry.extensions]117+ .some(value => value.toLocaleLowerCase().includes(needle)))118+}119+120+// Short aliases make the module convenient for callers that do not need the121+// launcher-specific name while retaining the explicit exports above.122+export const catalogGroups = launcherCatalogGroups123+export const flatCatalog = launcherCatalog124+export const catalogEntryFor = launcherEntryFor125+export const filterCatalog = filterLauncherCatalog
A apps/editor/src/launcher-previews.js +128 -0
@@ -0,0 +1,128 @@
1+import { clientAdapterFor } from './client-renderers.js'2+import { canonicalizeSvg, materializeSvg, supportedAppearances } from './client-svg.js'3+import { httpRendererUnitFor } from './renderer-units.js'4+5+const MAX_CONCURRENT_RENDERS = 46+7+export function createLauncherPreviewRenderer({8+ fetchImpl = fetch,9+ clientAdapterForImpl = clientAdapterFor,10+ endpointFor = httpRendererUnitFor,11+ createObjectURL = value => URL.createObjectURL(value),12+ revokeObjectURL = value => URL.revokeObjectURL(value),13+ transformSvg = materializeLauncherPreview,14+ maxConcurrent = MAX_CONCURRENT_RENDERS,15+} = {}) {16+ const cache = new Map()17+ const pending = new Map()18+ const queued = []19+ let active = 020+21+ function render(state, { priority = false, appearance = 'light-transparent' } = {}) {22+ const key = previewKey(state, appearance)23+ if (cache.has(key)) return Promise.resolve(cache.get(key))24+ if (pending.has(key)) return pending.get(key)25+26+ const promise = schedule(() => renderState(state), priority)27+ .then(rendered => {28+ const svg = transformSvg(rendered, state, appearance)29+ const url = createObjectURL(new Blob([svg], { type: 'image/svg+xml' }))30+ cache.set(key, url)31+ pending.delete(key)32+ return url33+ })34+ .catch(error => {35+ pending.delete(key)36+ throw error37+ })38+ pending.set(key, promise)39+ return promise40+ }41+42+ function schedule(task, priority) {43+ return new Promise((resolve, reject) => {44+ const item = { task, resolve, reject }45+ if (priority) queued.unshift(item)46+ else queued.push(item)47+ drain()48+ })49+ }50+51+ function drain() {52+ while (active < maxConcurrent && queued.length) {53+ const item = queued.shift()54+ active++55+ Promise.resolve()56+ .then(item.task)57+ .then(item.resolve, item.reject)58+ .finally(() => {59+ active--60+ drain()61+ })62+ }63+ }64+65+ async function renderState({ type, source, options = {}, meta = {}, presentation = {} }) {66+ if (!type || typeof source !== 'string' || !source.trim()) throw new Error('A diagram source is required for its preview.')67+ const clientAdapter = clientAdapterForImpl(type)68+ if (clientAdapter) {69+ const result = await clientAdapter.render({ type, source, options }, new AbortController().signal)70+ return { body: validateSvg(result.body), version: result.version ?? result.build ?? '' }71+ }72+73+ const endpoint = endpointFor(type)74+ if (!endpoint) throw new Error(`No renderer is registered for ${type}.`)75+ const response = await fetchImpl(endpoint, {76+ method: 'POST',77+ headers: { Accept: 'image/svg+xml', 'Content-Type': 'application/json' },78+ body: JSON.stringify({79+ source,80+ format: 'svg',81+ options,82+ metadata: meta,83+ presentation: {84+ background: presentation.background ?? '',85+ padding: presentation.padding ?? 0,86+ frame: presentation.frame ?? false,87+ },88+ }),89+ })90+ if (!response.ok) throw new Error(`Preview render failed with HTTP ${response.status}.`)91+ return {92+ body: validateSvg(await response.text()),93+ version: response.headers?.get?.('X-Diagram-Engine-Version')94+ ?? response.headers?.get?.('X-Renderer-Build')95+ ?? '',96+ }97+ }98+99+ function dispose() {100+ for (const url of cache.values()) revokeObjectURL(url)101+ cache.clear()102+ }103+104+ return { render, dispose }105+}106+107+export function previewKey({ type, source, options = {} }, appearance = 'light-transparent') {108+ return JSON.stringify({ type, source, options, appearance })109+}110+111+export function materializeLauncherPreview({ body, version = '' }, { type, meta = {} }, requestedAppearance) {112+ const canonical = canonicalizeSvg(body, meta, type, version)113+ const appearances = supportedAppearances(canonical)114+ const appearance = appearances.includes(requestedAppearance)115+ ? requestedAppearance116+ : appearances.find(value => value.endsWith('-transparent')) ?? 'raw'117+ return appearance === 'raw' ? canonical : materializeSvg(canonical, appearance)118+}119+120+function validateSvg(source) {121+ if (typeof source !== 'string') throw new Error('The renderer did not return an SVG preview.')122+ const documentStart = source123+ .replace(/^\uFEFF/, '')124+ .trimStart()125+ .replace(/^<\?xml[\s\S]*?\?>\s*/i, '')126+ if (!/^<svg(?:\s|>)/i.test(documentStart)) throw new Error('The renderer did not return an SVG preview.')127+ return source128+}
A apps/editor/src/launcher.js +570 -0
@@ -0,0 +1,570 @@
1+import { exampleStateFor } from './examples.js'2+import { importEditableSvgFile } from './editable-svg-input.js'3+import {4+ filterLauncherCatalog,5+ launcherCatalogGroups,6+ launcherEntryFor,7+} from './launcher-catalog.js'8+import { formatIconMarkup } from './format-icons.js'9+import { createLauncherPreviewRenderer } from './launcher-previews.js'10+import { createLocalDocuments } from './local-documents.js'11+import { DEFAULT_DIAGRAM_TYPE } from './state.js'12+13+const systemColorScheme = matchMedia('(prefers-color-scheme: dark)')14+const localDocuments = createLocalDocuments()15+const previewRenderer = createLauncherPreviewRenderer()16+document.documentElement.dataset.theme = systemColorScheme.matches ? 'dark' : 'light'17+document.querySelector('meta[name="theme-color"]').content = systemColorScheme.matches ? '#18191b' : '#ffffff'18+19+const requestedType = new URLSearchParams(location.search).get('type') ?? ''20+let recents = localDocuments.loadRecents()21+let selected = recents[0]22+ ? { kind: 'recent', id: recentId(recents[0]) }23+ : { kind: 'format', id: DEFAULT_DIAGRAM_TYPE }24+let activeGroup = 'all'25+let listMode = false26+27+document.querySelector('#app').innerHTML = `28+ <main class="launcher-shell">29+ <header class="launcher-header">30+ <a class="launcher-brand" href="/" aria-label="Diagram.zip home"><img src="/icon.svg?v=4" alt=""><span>diagram.zip</span></a>31+ <span class="header-spacer"></span>32+ <a class="launcher-docs" href="https://docs.diagram.zip/" aria-label="Documentation" title="Documentation">33+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3.5 5.5c3.25-.75 6-.1 8.5 2v12c-2.5-2.1-5.25-2.75-8.5-2V5.5Zm17 0c-3.25-.75-6-.1-8.5 2v12c2.5-2.1 5.25-2.75 8.5-2V5.5Z"/><path d="M12 7.5v12"/></svg>34+ </a>35+ </header>36+ <div class="launcher-layout">37+ <aside class="launcher-rail" aria-label="Launcher navigation">38+ <div class="launcher-rail-scroll">39+ <h2>Start</h2>40+ <button type="button" data-launcher-action="new"><span class="launcher-action-icon">๏ผ‹</span><span>New diagram</span><kbd>โŒ˜N</kbd></button>41+ <button type="button" data-launcher-action="open"><span class="launcher-action-icon">โŒ‘</span><span>Open fileโ€ฆ</span><kbd>โŒ˜O</kbd></button>42+ <button type="button" data-launcher-action="paste"><span class="launcher-action-icon">โ–ค</span><span>Paste source</span><kbd>โŒ˜V</kbd></button>43+ <h2>Formats</h2>44+ <div id="launcher-groups"></div>45+ <h2>Recent</h2>46+ <div class="launcher-recents" id="launcher-recents"></div>47+ </div>48+ <div class="launcher-storage" id="launcher-storage"></div>49+ </aside>50+ <section class="launcher-catalog-pane" aria-label="Diagram formats">51+ <div class="launcher-filter-bar">52+ <label class="launcher-filter">53+ <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m16.5 16.5 4 4"/></svg>54+ <span class="sr-only">Filter formats</span>55+ <input id="launcher-filter" type="search" placeholder="Filter formats" autocomplete="off" spellcheck="false">56+ <kbd>/</kbd>57+ </label>58+ <div class="launcher-view-toggle" role="group" aria-label="Format view">59+ <button type="button" data-view="grid" aria-pressed="true">Grid</button>60+ <button type="button" data-view="list" aria-pressed="false">List</button>61+ </div>62+ </div>63+ <div class="launcher-catalog-scroll" id="launcher-catalog"></div>64+ </section>65+ <aside class="launcher-detail" id="launcher-detail" aria-live="polite"></aside>66+ </div>67+ </main>68+ <input id="launcher-file" type="file" accept=".svg,image/svg+xml" hidden>69+ <dialog class="paste-dialog" id="paste-dialog" aria-labelledby="paste-title">70+ <form class="dialog-card" id="paste-form">71+ <div class="dialog-header"><h1 id="paste-title">Paste diagram source</h1><button class="icon-button" type="button" data-paste-cancel aria-label="Close">ร—</button></div>72+ <p class="dialog-copy">Paste source and weโ€™ll pick the most likely format. You can correct it before creating the diagram.</p>73+ <div class="details-fields">74+ <label><span>Source</span><textarea id="paste-source" required spellcheck="false"></textarea></label>75+ <label><span>Format</span><select id="paste-type"></select></label>76+ </div>77+ <div class="dialog-actions"><button class="primary-action" type="submit">Create diagram</button></div>78+ </form>79+ </dialog>80+`81+82+const filterInput = document.querySelector('#launcher-filter')83+filterInput.value = requestedType84+for (const entry of filterLauncherCatalog()) document.querySelector('#paste-type').add(new Option(entry.label, entry.id))85+86+document.querySelector('[data-launcher-action="new"]').addEventListener('click', selectDefaultFormat)87+document.querySelector('[data-launcher-action="open"]').addEventListener('click', () => document.querySelector('#launcher-file').click())88+document.querySelector('[data-launcher-action="paste"]').addEventListener('click', openPasteDialog)89+document.querySelector('#launcher-file').addEventListener('change', openEditableFile)90+document.querySelector('[data-paste-cancel]').addEventListener('click', () => document.querySelector('#paste-dialog').close())91+document.querySelector('#paste-form').addEventListener('submit', createPastedDiagram)92+document.querySelector('#paste-source').addEventListener('input', event => {93+ const detected = detectDiagramType(event.target.value)94+ if (detected) document.querySelector('#paste-type').value = detected95+})96+filterInput.addEventListener('input', () => {97+ activeGroup = 'all'98+ const matches = visibleFormats()99+ if (matches.length) selected = { kind: 'format', id: matches[0].id }100+ render()101+})102+document.querySelector('#launcher-groups').addEventListener('click', event => {103+ const button = event.target.closest('[data-group]')104+ if (!button) return105+ activeGroup = button.dataset.group106+ filterInput.value = ''107+ const first = visibleFormats()[0]108+ if (first) selected = { kind: 'format', id: first.id }109+ render()110+})111+document.querySelector('#launcher-recents').addEventListener('click', event => {112+ const button = event.target.closest('[data-recent-id]')113+ if (!button) return114+ selected = { kind: 'recent', id: button.dataset.recentId }115+ render()116+})117+document.querySelector('#launcher-catalog').addEventListener('click', event => {118+ const button = event.target.closest('[data-format-id]')119+ if (!button) return120+ selected = { kind: 'format', id: button.dataset.formatId }121+ render()122+})123+document.querySelector('.launcher-view-toggle').addEventListener('click', event => {124+ const button = event.target.closest('[data-view]')125+ if (!button) return126+ listMode = button.dataset.view === 'list'127+ render()128+})129+document.querySelector('#launcher-detail').addEventListener('click', handleDetailAction)130+document.querySelector('#launcher-storage').addEventListener('click', event => {131+ if (!event.target.closest('[data-clear-recents]')) return132+ if (!confirm('Clear the recent list and delete its local-only diagrams from this browser? Published edit links will be kept.')) return133+ localDocuments.clearRecents()134+ recents = []135+ selected = { kind: 'format', id: DEFAULT_DIAGRAM_TYPE }136+ render()137+})138+139+window.addEventListener('keydown', event => {140+ if (document.querySelector('#paste-dialog').open) return141+ const primary = event.metaKey || event.ctrlKey142+ const key = event.key.toLowerCase()143+ const typing = /^(input|textarea|select)$/i.test(document.activeElement?.tagName)144+ if (event.key === '/' && !typing) {145+ event.preventDefault()146+ filterInput.focus()147+ } else if (primary && key === 'n') {148+ event.preventDefault()149+ selectDefaultFormat()150+ } else if (primary && key === 'o') {151+ event.preventDefault()152+ document.querySelector('#launcher-file').click()153+ } else if (primary && key === 'v' && !typing) {154+ event.preventDefault()155+ openPasteDialog()156+ } else if (event.key === 'Escape' && filterInput.value) {157+ event.preventDefault()158+ filterInput.value = ''159+ render()160+ } else if (event.key.startsWith('Arrow') && !typing) {161+ event.preventDefault()162+ moveFormatSelection(event.key)163+ } else if (event.key === 'Enter' && !typing) {164+ event.preventDefault()165+ activateSelection()166+ }167+})168+169+render()170+if (requestedType) filterInput.focus()171+window.addEventListener('pagehide', () => previewRenderer.dispose(), { once: true })172+173+function render() {174+ renderGroups()175+ renderRecents()176+ renderCatalog()177+ renderDetail()178+ renderStorage()179+ for (const button of document.querySelectorAll('[data-view]')) {180+ button.setAttribute('aria-pressed', String((button.dataset.view === 'list') === listMode))181+ }182+}183+184+function renderGroups() {185+ const root = document.querySelector('#launcher-groups')186+ root.replaceChildren(groupButton('all', 'All', filterLauncherCatalog().length))187+ for (const group of launcherCatalogGroups) root.append(groupButton(group.id, group.label, group.items.length))188+}189+190+function groupButton(id, label, count) {191+ const button = document.createElement('button')192+ button.type = 'button'193+ button.dataset.group = id194+ button.className = 'launcher-group'195+ button.dataset.selected = String(activeGroup === id)196+ button.innerHTML = '<span></span><small></small>'197+ button.querySelector('span').textContent = label198+ button.querySelector('small').textContent = count199+ return button200+}201+202+function renderRecents() {203+ const root = document.querySelector('#launcher-recents')204+ root.replaceChildren()205+ if (!recents.length) {206+ const empty = document.createElement('p')207+ empty.className = 'launcher-empty'208+ empty.textContent = 'Nothing on this device yet'209+ root.append(empty)210+ return211+ }212+ for (const entry of recents) {213+ const button = document.createElement('button')214+ button.type = 'button'215+ button.dataset.recentId = recentId(entry)216+ button.dataset.selected = String(selected.kind === 'recent' && selected.id === recentId(entry))217+ button.innerHTML = '<span class="recent-glyph"></span><span class="recent-copy"><strong></strong><small></small></span><span class="recent-dot" hidden></span>'218+ button.querySelector('.recent-glyph').textContent = formatMonogram(entry.type)219+ button.querySelector('strong').textContent = entry.title || 'Locked diagram'220+ button.querySelector('small').textContent = `${entry.mode === 'locked' ? 'encrypted' : diagramLabel(entry.type)} ยท ${relativeTime(entry.updatedAt)}`221+ button.querySelector('.recent-dot').hidden = !entry.dirty222+ root.append(button)223+ }224+}225+226+function renderCatalog() {227+ const root = document.querySelector('#launcher-catalog')228+ root.dataset.view = listMode ? 'list' : 'grid'229+ root.replaceChildren()230+ const visible = new Set(visibleFormats().map(entry => entry.id))231+ if (!visible.size) {232+ const empty = document.createElement('div')233+ empty.className = 'launcher-no-match'234+ const title = document.createElement('strong')235+ title.textContent = `No format called โ€œ${filterInput.value}โ€`236+ const copy = document.createElement('p')237+ copy.textContent = `Diagram.zip renders ${filterLauncherCatalog().length} formats. Clear the filter to see them all.`238+ const clear = document.createElement('button')239+ clear.type = 'button'240+ clear.textContent = 'Clear filter'241+ clear.addEventListener('click', () => { filterInput.value = ''; filterInput.focus(); render() })242+ empty.append(title, copy, clear)243+ root.append(empty)244+ return245+ }246+ for (const group of launcherCatalogGroups) {247+ const items = group.items.filter(entry => visible.has(entry.id))248+ if (!items.length) continue249+ const heading = document.createElement('div')250+ heading.className = 'launcher-catalog-heading'251+ heading.innerHTML = '<h2></h2><span></span>'252+ heading.querySelector('h2').textContent = group.label253+ root.append(heading)254+ const grid = document.createElement('div')255+ grid.className = 'launcher-format-grid'256+ for (const entry of items) {257+ const button = document.createElement('button')258+ button.type = 'button'259+ button.dataset.formatId = entry.id260+ button.dataset.selected = String(selected.kind === 'format' && selected.id === entry.id)261+ button.innerHTML = '<span class="format-icon"></span><span class="format-copy"><strong></strong><small></small></span>'262+ button.querySelector('.format-icon').innerHTML = formatIconMarkup(entry.id)263+ button.querySelector('strong').textContent = entry.label264+ button.querySelector('small').textContent = entry.description265+ grid.append(button)266+ }267+ root.append(grid)268+ }269+}270+271+function renderDetail() {272+ const root = document.querySelector('#launcher-detail')273+ root.replaceChildren()274+ const recent = selected.kind === 'recent' ? recents.find(entry => recentId(entry) === selected.id) : null275+ if (recent) renderRecentDetail(root, recent)276+ else renderFormatDetail(root, launcherEntryFor(selected.id) ?? launcherEntryFor(DEFAULT_DIAGRAM_TYPE))277+}278+279+function renderFormatDetail(root, entry) {280+ const state = exampleStateFor(entry.id)281+ root.append(detailPreview(entry, state), detailBody({282+ title: entry.label,283+ description: entry.description,284+ tags: [entry.groupLabel, ...entry.extensions],285+ facts: [['Extension', entry.extensions.join(', ') || 'text'], ['Starts from', state.meta.title || `${entry.label} example`]],286+ source: state.source,287+ }), detailFooter([288+ ['create', 'Create', true],289+ ]))290+}291+292+function renderRecentDetail(root, entry) {293+ const document = entry.kind === 'local' ? localDocuments.loadLocalDocument(entry.localId) : null294+ const title = entry.title || 'Locked diagram'295+ root.append(detailPreview(launcherEntryFor(entry.type), document?.state, {296+ unavailableLabel: entry.mode === 'locked' ? 'Encrypted preview' : 'Open to load preview',297+ }), detailBody({298+ title,299+ tags: [entry.kind === 'alias' ? 'published' : 'local only', entry.dirty ? 'unsaved' : '', entry.mode === 'locked' ? 'locked' : diagramLabel(entry.type)].filter(Boolean),300+ facts: [['Edited', relativeTime(entry.updatedAt)], ['Size', entry.bytes ? formatBytes(entry.bytes) : 'remote'], ['Stored', entry.kind === 'local' ? 'this browser only' : 'published link']],301+ source: document?.state.source ?? (entry.mode === 'locked' ? 'Encrypted source is never stored in recents.' : 'Open this diagram to load its latest published source.'),302+ }), detailFooter([303+ ['open', 'Open', true],304+ ...(entry.kind === 'local' ? [['duplicate', 'Duplicate', false]] : []),305+ ['delete', 'Remove', false],306+ ]))307+}308+309+function detailPreview(entry, state, { unavailableLabel = 'Preview unavailable' } = {}) {310+ return createPreviewFrame({311+ className: 'launcher-detail-preview',312+ state,313+ alt: state ? `${entry?.label ?? 'Diagram'} template preview` : '',314+ unavailableLabel,315+ })316+}317+318+function createPreviewFrame({ className, state, alt, unavailableLabel = 'Preview unavailable' }) {319+ const frame = document.createElement('span')320+ frame.className = `launcher-preview-frame ${className}`321+ frame.dataset.previewState = state ? 'loading' : 'unavailable'322+ frame.innerHTML = `323+ <span class="launcher-preview-placeholder" aria-hidden="true">324+ <svg viewBox="0 0 48 32" focusable="false">325+ <rect x="3" y="5" width="13" height="8" rx="2"></rect>326+ <rect x="32" y="19" width="13" height="8" rx="2"></rect>327+ <path d="M16 9h7a5 5 0 0 1 5 5v5h4m-7-2 3 3 3-3"></path>328+ </svg>329+ <small></small>330+ </span>331+ <img draggable="false" hidden>332+ `333+ frame.querySelector('img').alt = alt334+ frame.querySelector('small').textContent = state ? 'Rendering previewโ€ฆ' : unavailableLabel335+ if (state) loadPreviewImage(frame, state)336+ return frame337+}338+339+async function loadPreviewImage(frame, state) {340+ if (frame.dataset.previewStarted) return341+ const image = frame.querySelector('img')342+ frame.dataset.previewStarted = 'true'343+ try {344+ image.src = await previewRenderer.render(state, {345+ appearance: systemColorScheme.matches ? 'dark-transparent' : 'light-transparent',346+ priority: true,347+ })348+ if (!image.isConnected) return349+ await image.decode().catch(() => {})350+ if (!image.isConnected) return351+ image.hidden = false352+ frame.dataset.previewState = 'ready'353+ } catch {354+ if (!image.isConnected) return355+ frame.dataset.previewState = 'error'356+ frame.querySelector('.launcher-preview-placeholder small').textContent = 'Preview unavailable'357+ }358+}359+360+function detailBody({ title, description = '', tags = [], facts = [], source = '' }) {361+ const body = document.createElement('div')362+ body.className = 'launcher-detail-body'363+ const heading = document.createElement('h1')364+ heading.textContent = title365+ body.append(heading)366+ if (description) {367+ const copy = document.createElement('p')368+ copy.textContent = description369+ body.append(copy)370+ }371+ const tagList = document.createElement('div')372+ tagList.className = 'launcher-tags'373+ for (const value of tags) {374+ const tag = document.createElement('span')375+ tag.textContent = value376+ tagList.append(tag)377+ }378+ body.append(tagList)379+ const factsList = document.createElement('dl')380+ for (const [term, value] of facts) {381+ const dt = document.createElement('dt'); dt.textContent = term382+ const dd = document.createElement('dd'); dd.textContent = value383+ factsList.append(dt, dd)384+ }385+ body.append(factsList)386+ const snippet = document.createElement('pre')387+ snippet.textContent = source.slice(0, 1200)388+ body.append(snippet)389+ return body390+}391+392+function detailFooter(actions) {393+ const footer = document.createElement('footer')394+ footer.className = 'launcher-detail-footer'395+ for (const [action, label, primary] of actions) {396+ const button = document.createElement('button')397+ button.type = 'button'398+ button.dataset.detailAction = action399+ button.className = primary ? 'primary-action' : 'secondary-action'400+ button.textContent = label401+ footer.append(button)402+ }403+ return footer404+}405+406+function renderStorage() {407+ const count = recents.length408+ const bytes = recents.reduce((total, entry) => total + (entry.bytes || 0), 0)409+ const root = document.querySelector('#launcher-storage')410+ root.replaceChildren()411+ const summary = document.createElement('span')412+ summary.textContent = count ? `${count} diagram${count === 1 ? '' : 's'} - ${formatBytes(bytes)} on this device` : 'Nothing stored on this device yet'413+ root.append(summary)414+ if (count) {415+ const clear = document.createElement('button')416+ clear.type = 'button'417+ clear.dataset.clearRecents = ''418+ clear.textContent = 'Clear recentsโ€ฆ'419+ root.append(clear)420+ }421+}422+423+function visibleFormats() {424+ const matches = new Set(filterLauncherCatalog(filterInput?.value ?? requestedType).map(entry => entry.id))425+ return launcherCatalogGroups.flatMap(group => group.items).filter(entry => matches.has(entry.id) && (activeGroup === 'all' || entry.groupId === activeGroup))426+}427+428+function handleDetailAction(event) {429+ const button = event.target.closest('[data-detail-action]')430+ if (!button) return431+ const action = button.dataset.detailAction432+ const recent = selected.kind === 'recent' ? recents.find(entry => recentId(entry) === selected.id) : null433+ if (action === 'create') return navigateToFormat(selected.id)434+ if (!recent) return435+ if (action === 'open') return openRecent(recent)436+ if (action === 'duplicate' && recent.kind === 'local') return duplicateLocal(recent)437+ if (action === 'delete') removeRecent(recent)438+}439+440+function activateSelection() {441+ const recent = selected.kind === 'recent' ? recents.find(entry => recentId(entry) === selected.id) : null442+ if (recent) openRecent(recent)443+ else navigateToFormat(selected.id)444+}445+446+function navigateToFormat(type) {447+ const url = new URL('/', location.origin)448+ url.searchParams.set('type', type)449+ location.assign(`${url.pathname}${url.search}`)450+}451+452+function openRecent(entry) {453+ if (entry.kind === 'local') location.assign(`/?doc=${encodeURIComponent(entry.localId)}`)454+ else location.assign(`/d/${entry.aliasId}${entry.mode !== 'locked' && entry.type ? `?type=${encodeURIComponent(entry.type)}` : ''}`)455+}456+457+function duplicateLocal(entry) {458+ const document = localDocuments.loadLocalDocument(entry.localId)459+ if (!document) return460+ const title = `${document.state.meta?.title || entry.title || 'Untitled'} copy`461+ const state = { ...document.state, meta: { ...document.state.meta, title } }462+ const localId = localDocuments.saveLocalDocument({ ...document, state, title, dirty: true })463+ if (localId) location.assign(`/?doc=${encodeURIComponent(localId)}`)464+}465+466+function removeRecent(entry) {467+ const label = entry.kind === 'local' ? 'Delete this local-only diagram? This cannot be undone.' : 'Remove this published diagram from recents? The published link will keep working.'468+ if (!confirm(label)) return469+ if (entry.kind === 'local') localDocuments.deleteLocalDocument(entry.localId)470+ else localDocuments.removeRecent({ aliasId: entry.aliasId })471+ recents = localDocuments.loadRecents()472+ selected = recents[0] ? { kind: 'recent', id: recentId(recents[0]) } : { kind: 'format', id: DEFAULT_DIAGRAM_TYPE }473+ render()474+}475+476+async function openEditableFile(event) {477+ const file = event.target.files?.[0]478+ event.target.value = ''479+ if (!file) return480+ try {481+ const state = await importEditableSvgFile(file)482+ const localId = localDocuments.saveLocalDocument({ state, detailsSource: null, fileSnapshot: null, drafts: {}, dirty: true })483+ if (!localId) throw new Error('The file could not be stored on this device.')484+ location.assign(`/?doc=${encodeURIComponent(localId)}`)485+ } catch (error) {486+ alert(error instanceof Error ? error.message : 'Could not open this editable SVG.')487+ }488+}489+490+function openPasteDialog() {491+ const dialog = document.querySelector('#paste-dialog')492+ document.querySelector('#paste-source').value = ''493+ document.querySelector('#paste-type').value = DEFAULT_DIAGRAM_TYPE494+ dialog.showModal()495+ document.querySelector('#paste-source').focus()496+}497+498+function createPastedDiagram(event) {499+ event.preventDefault()500+ const type = document.querySelector('#paste-type').value501+ const source = document.querySelector('#paste-source').value502+ if (!source.trim()) return503+ const state = { ...exampleStateFor(type), source, meta: { title: '', description: '' } }504+ const localId = localDocuments.saveLocalDocument({ state, detailsSource: null, fileSnapshot: null, drafts: {}, dirty: true })505+ if (!localId) return alert('This diagram could not be stored on this device.')506+ location.assign(`/?doc=${encodeURIComponent(localId)}`)507+}508+509+function detectDiagramType(source) {510+ const value = source.trim()511+ if (!value) return null512+ if (/^@startuml\b/m.test(value)) return 'plantuml'513+ if (/^(?:flowchart|graph|sequenceDiagram|classDiagram|stateDiagram|gantt)\b/m.test(value)) return 'mermaid'514+ if (/^(?:di)?graph\s+[\w"]*\s*\{/m.test(value)) return 'graphviz'515+ if (/^workspace\b/m.test(value)) return 'structurizr'516+ if (/^\s*<mxfile\b|<diagram\b/.test(value)) return 'diagramsnet'517+ if (/^\s*<\?xml[\s\S]*<definitions\b/.test(value)) return 'bpmn'518+ if (/^\s*\{[\s\S]*"(?:signal|head|config)"\s*:/.test(value)) return 'wavedrom'519+ if (/^\s*(?:Table|Project|Enum)\b/m.test(value)) return 'dbml'520+ return null521+}522+523+function moveFormatSelection(key) {524+ const formats = visibleFormats()525+ if (!formats.length) return526+ const current = Math.max(0, formats.findIndex(entry => selected.kind === 'format' && entry.id === selected.id))527+ const columns = listMode ? 1 : Math.max(1, Math.floor(document.querySelector('#launcher-catalog').clientWidth / 175))528+ const delta = key === 'ArrowRight' ? 1 : key === 'ArrowLeft' ? -1 : key === 'ArrowDown' ? columns : -columns529+ const index = (current + delta + formats.length) % formats.length530+ selected = { kind: 'format', id: formats[index].id }531+ render()532+ document.querySelector(`[data-format-id="${formats[index].id}"]`)?.scrollIntoView({ block: 'nearest' })533+}534+535+function selectDefaultFormat() {536+ activeGroup = 'all'537+ filterInput.value = ''538+ selected = { kind: 'format', id: DEFAULT_DIAGRAM_TYPE }539+ render()540+ document.querySelector(`[data-format-id="${DEFAULT_DIAGRAM_TYPE}"]`)?.focus()541+}542+543+function recentId(entry) {544+ return entry.kind === 'local' ? `local:${entry.localId}` : `alias:${entry.aliasId}`545+}546+547+function diagramLabel(type) {548+ return launcherEntryFor(type)?.label ?? type ?? 'Diagram'549+}550+551+function formatMonogram(type) {552+ return (launcherEntryFor(type)?.label ?? '?').replace(/[^A-Za-z0-9]/g, '').slice(0, 2).toUpperCase()553+}554+555+function relativeTime(timestamp) {556+ const elapsed = Math.max(0, Date.now() - Number(timestamp || 0))557+ const minute = 60_000558+ const hour = 60 * minute559+ const day = 24 * hour560+ if (elapsed < minute) return 'just now'561+ if (elapsed < hour) return `${Math.floor(elapsed / minute)}m ago`562+ if (elapsed < day) return `${Math.floor(elapsed / hour)}h ago`563+ if (elapsed < 7 * day) return `${Math.floor(elapsed / day)}d ago`564+ return new Date(timestamp).toLocaleDateString()565+}566+567+function formatBytes(bytes) {568+ if (bytes < 1024) return `${bytes} B`569+ return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`570+}
A apps/editor/src/local-documents.js +181 -0
@@ -0,0 +1,181 @@
1+export const RECENTS_KEY = 'diagram.zip:recents:v1'2+export const LOCAL_DOCUMENT_PREFIX = 'diagram.zip:doc:v1:'3+export const LEGACY_DRAFT_KEY = 'diagram.zip:draft:local:v2'4+export const WRITE_CAPABILITY_PREFIX = 'diagram.zip:write:v1:'5+export const MAX_RECENTS = 506+7+const LOCAL_ID = /^l_[0-9a-f]{8}$/8+9+function isObject(value) {10+ return value !== null && typeof value === 'object' && !Array.isArray(value)11+}12+13+function bodyKey(localId) {14+ return `${LOCAL_DOCUMENT_PREFIX}${localId}`15+}16+17+function validState(value) {18+ return isObject(value) && typeof value.type === 'string' && typeof value.source === 'string'19+}20+21+function normalizeBody(value) {22+ if (!isObject(value) || !validState(value.state)) return null23+ return {24+ state: value.state,25+ detailsSource: typeof value.detailsSource === 'string' ? value.detailsSource : null,26+ fileSnapshot: value.fileSnapshot ?? null,27+ drafts: isObject(value.drafts) ? value.drafts : {},28+ }29+}30+31+function normalizeRecent(value) {32+ if (!isObject(value) || typeof value.updatedAt !== 'number') return null33+ if (value.kind === 'local' && LOCAL_ID.test(value.localId)) {34+ return {35+ kind: 'local', localId: value.localId, title: typeof value.title === 'string' ? value.title : '',36+ type: typeof value.type === 'string' ? value.type : '', updatedAt: value.updatedAt,37+ dirty: value.dirty !== false, bytes: Number.isFinite(value.bytes) ? Math.max(0, value.bytes) : 0,38+ }39+ }40+ if (value.kind === 'alias' && typeof value.aliasId === 'string' && value.aliasId) {41+ return {42+ kind: 'alias', aliasId: value.aliasId, title: value.mode === 'locked' ? null : (typeof value.title === 'string' ? value.title : null),43+ type: value.mode === 'locked' ? '' : (typeof value.type === 'string' ? value.type : ''), updatedAt: value.updatedAt,44+ mode: value.mode === 'locked' ? 'locked' : 'open', bytes: 0,45+ }46+ }47+ return null48+}49+50+function parseRecents(storage, maxRecents = MAX_RECENTS) {51+ try {52+ const raw = storage.getItem(RECENTS_KEY)53+ if (!raw) return []54+ const parsed = JSON.parse(raw)55+ const entries = Array.isArray(parsed) ? parsed : parsed?.v === 1 ? parsed.entries : null56+ if (!Array.isArray(entries)) return []57+ return entries.map(normalizeRecent).filter(Boolean).sort((a, b) => b.updatedAt - a.updatedAt).slice(0, maxRecents)58+ } catch {59+ return []60+ }61+}62+63+function createId(cryptoImpl) {64+ const bytes = new Uint8Array(4)65+ if (!cryptoImpl || typeof cryptoImpl.getRandomValues !== 'function') throw new Error('Secure randomness is unavailable.')66+ cryptoImpl.getRandomValues(bytes)67+ return `l_${Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')}`68+}69+70+function isQuotaError(error) {71+ return error?.name === 'QuotaExceededError' || error?.code === 22 || error?.code === 101472+}73+74+export function createLocalDocuments({75+ storage = globalThis.localStorage,76+ cryptoImpl = globalThis.crypto,77+ now = () => Date.now(),78+ maxRecents = MAX_RECENTS,79+} = {}) {80+ if (!storage) throw new Error('Storage is unavailable.')81+82+ const recents = () => parseRecents(storage, maxRecents)83+ const writeRecents = entries => storage.setItem(RECENTS_KEY, JSON.stringify({ v: 1, entries: entries.slice(0, maxRecents) }))84+ const evictOne = currentLocalId => {85+ const entries = recents()86+ const candidate = entries.filter(entry => entry.kind === 'local' && entry.localId !== currentLocalId && entry.dirty === false)87+ .sort((a, b) => a.updatedAt - b.updatedAt)[0]88+ if (!candidate) return false89+ try { storage.removeItem(bodyKey(candidate.localId)) } catch { /* best effort */ }90+ try {91+ writeRecents(entries.filter(entry => !(entry.kind === 'local' && entry.localId === candidate.localId)))92+ return true93+ } catch {94+ return false95+ }96+ }97+ const withQuotaRetry = (operation, currentLocalId = null) => {98+ try { operation(); return true } catch (error) {99+ if (!isQuotaError(error)) return false100+ if (!evictOne(currentLocalId)) return false101+ try { operation(); return true } catch { return false }102+ }103+ }104+ const saveIndexEntry = (entry, currentLocalId) => withQuotaRetry(() => writeRecents([entry, ...recents().filter(item => !(105+ (entry.kind === 'local' && item.kind === 'local' && item.localId === entry.localId)106+ || (entry.kind === 'alias' && item.kind === 'alias' && item.aliasId === entry.aliasId)107+ ))]), currentLocalId)108+109+ return {110+ loadRecents: recents,111+ upsertRecent(entry) {112+ const normalized = normalizeRecent({ ...entry, updatedAt: entry.updatedAt ?? now() })113+ if (!normalized) return false114+ return saveIndexEntry(normalized, normalized.kind === 'local' ? normalized.localId : null)115+ },116+ touchRecent(identity, changes = {}) {117+ const entries = recents()118+ const found = entries.find(entry => identity.localId ? entry.kind === 'local' && entry.localId === identity.localId : entry.kind === 'alias' && entry.aliasId === identity.aliasId)119+ if (!found) return false120+ return saveIndexEntry(normalizeRecent({ ...found, ...changes, updatedAt: now() }), found.kind === 'local' ? found.localId : null)121+ },122+ saveLocalDocument(document, { localId = document.localId ?? createId(cryptoImpl), currentLocalId = localId } = {}) {123+ if (!LOCAL_ID.test(localId)) throw new Error('Invalid local document ID.')124+ const body = normalizeBody(document)125+ if (!body) throw new Error('Invalid local document.')126+ const serializedBody = JSON.stringify(body)127+ const entry = normalizeRecent({128+ kind: 'local',129+ localId,130+ title: document.title ?? body.state.meta?.title ?? '',131+ type: body.state.type,132+ dirty: document.dirty !== false,133+ bytes: new TextEncoder().encode(serializedBody).byteLength,134+ updatedAt: now(),135+ })136+ const ok = withQuotaRetry(() => storage.setItem(bodyKey(localId), serializedBody), currentLocalId)137+ if (!ok) return null138+ if (!saveIndexEntry(entry, currentLocalId)) return null139+ return localId140+ },141+ loadLocalDocument(localId) {142+ if (!LOCAL_ID.test(localId)) return null143+ try { return normalizeBody(JSON.parse(storage.getItem(bodyKey(localId)) ?? 'null')) } catch { return null }144+ },145+ deleteLocalDocument(localId) {146+ if (!LOCAL_ID.test(localId)) return false147+ try { storage.removeItem(bodyKey(localId)) } catch { return false }148+ try { writeRecents(recents().filter(entry => !(entry.kind === 'local' && entry.localId === localId))); return true } catch { return false }149+ },150+ removeRecent(identity) {151+ const remaining = recents().filter(entry => identity.localId152+ ? !(entry.kind === 'local' && entry.localId === identity.localId)153+ : !(entry.kind === 'alias' && entry.aliasId === identity.aliasId))154+ try { writeRecents(remaining); return true } catch { return false }155+ },156+ clearRecents() {157+ const keys = new Set(recents().filter(entry => entry.kind === 'local').map(entry => bodyKey(entry.localId)))158+ if (Number.isInteger(storage.length) && typeof storage.key === 'function') {159+ for (let index = 0; index < storage.length; index++) {160+ const key = storage.key(index)161+ if (key?.startsWith(LOCAL_DOCUMENT_PREFIX)) keys.add(key)162+ }163+ }164+ for (const key of keys) try { storage.removeItem(key) } catch { /* best effort */ }165+ try { writeRecents([]); return true } catch { return false }166+ },167+ migrateLegacyDraft() {168+ let raw169+ try { raw = storage.getItem(LEGACY_DRAFT_KEY) } catch { return null }170+ if (!raw) return null171+ let draft172+ try { draft = JSON.parse(raw) } catch { storage.removeItem(LEGACY_DRAFT_KEY); return null }173+ const localId = this.saveLocalDocument(draft)174+ if (!localId) return null175+ storage.removeItem(LEGACY_DRAFT_KEY)176+ return localId177+ },178+ }179+}180+181+export const localDocumentBodyKey = bodyKey
M apps/editor/src/main.js +425 -199
@@ -1,6 +1,6 @@
11 import { diagramTypeFromQuery, diagramTypes, isKnownDiagramType, urlWithDiagramType } from './diagram-types.js'22 import { exampleStateFor } from './examples.js'3-import { refreshMatchingExampleMetadata } from './example-defaults.js'3+import { exampleStateForTheme } from './example-defaults.js'44 import {55 DETAILS_MODEL_URI,66 detailsStateWithTitle,
@@ -37,90 +37,57 @@ import {
3737 import { PreviewController } from './preview.js'3838 import { exportEditableSvg } from './editable-svg.js'3939 import { importEditableSvgFile, importEditableSvgInput } from './editable-svg-input.js'40+import { createCommandPaletteModel } from './command-palette.js'41+import { launcherCatalogGroups } from './launcher-catalog.js'42+import { createLocalDocuments } from './local-documents.js'43+import { mobilePanelSwitchState } from './mobile-panel.js'4044 import './style.css'4145 42-const LOCAL_DRAFT_KEY = 'diagram.zip:draft:local:v2'4346 const ALIAS_DRAFT_PREFIX = 'diagram.zip:draft:alias:v1:'4447 const WRITE_CAPABILITY_PREFIX = 'diagram.zip:write:v1:'4548 const persistence = new PersistenceClient()49+const localDocuments = createLocalDocuments()4650 const systemColorScheme = matchMedia('(prefers-color-scheme: dark)')51+const initialTemplateTheme = systemColorScheme.matches ? 'dark' : 'light'4752 document.documentElement.dataset.theme = systemColorScheme.matches ? 'dark' : 'light'4853 let renderTimer4954 let detailsCommitTimer5055 let activeType5156 let saving = false52-let primarySaveAction = null5357 let lastFileSnapshot = null58+let localDocumentId = new URLSearchParams(location.search).get('doc')5459 let initialLoadError = ''5560 let restoredDetailsSource = null5661 let remote = emptyRemoteState()57-const typeDrafts = new Map()62+63+function templateStateFor(type) {64+ return exampleStateForTheme(exampleStateFor(type), initialTemplateTheme)65+}5866 5967 document.querySelector('#app').innerHTML = `6068 <main class="app-shell">6169 <header class="app-header">70+ <a class="brand" href="/" aria-label="Open launcher" title="Open launcher"><img class="brand-mark" src="/icon.svg?v=4" alt=""></a>6271 <div class="document-identity">63- <a class="brand" href="/" aria-label="New diagram" title="New diagram"><img class="brand-mark" src="/icon.svg?v=4" alt=""></a>6472 <input class="document-title" id="diagram-title" type="text" maxlength="200" placeholder="Untitled" aria-label="Diagram title" autocomplete="off" spellcheck="false">73+ <button class="document-reset" id="quick-reset" type="button" data-visible="false" aria-hidden="true" tabindex="-1">74+ <span class="unsaved-indicator" id="unsaved-indicator" aria-hidden="true"></span>75+ <svg class="document-reset-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">76+ <path d="M4 9a8 8 0 1 1-.1 6M4 9V4m0 5h5"/>77+ </svg>78+ </button>6579 </div>66- <div class="header-meta">67- <span class="render-status" data-state="idle" role="status">Ready</span>68- <label class="type-picker">69- <span class="sr-only">Diagram type</span>70- <select id="diagram-type"></select>71- </label>72- <div class="header-actions" role="group" aria-label="Diagram actions">73- <a class="header-icon-action" href="https://docs.diagram.zip/" aria-label="Documentation" title="Documentation">74- <svg class="header-action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">75- <path d="M3.5 5.5c3.25-.75 6-.1 8.5 2v12c-2.5-2.1-5.25-2.75-8.5-2V5.5Zm17 0c-3.25-.75-6-.1-8.5 2v12c2.5-2.1 5.25-2.75 8.5-2V5.5Z"/>76- <path d="M12 7.5v12"/>77- </svg>78- </a>79- <button class="header-icon-action" id="open" type="button" aria-label="Open editable SVG" title="Open editable SVG">80- <svg class="header-action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">81- <path d="M3.5 7.5h6l2-2h9v13h-17v-11Z"/>82- <path d="M8 13h8m-4-4v8"/>83- </svg>84- </button>85- <div class="save-split">86- <button class="header-icon-action" id="save" type="button" data-save-state="save" data-dirty="true" aria-label="Save as File" title="Save as File">87- <svg class="header-action-icon save-action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">88- <g data-save-icon="save">89- <path d="M5 3.5h11.5L19 6v14.5H5v-17Z"/>90- <path d="M8 3.5V9h8V3.5M8 20.5v-7h8v7"/>91- </g>92- <g data-save-icon="saving">93- <path d="M19.5 8.5A8 8 0 1 0 20 14"/>94- <path d="M19.5 4.5v4h-4"/>95- </g>96- <g data-save-icon="saved">97- <path d="m5.5 12 4 4 9-9"/>98- </g>99- <g data-save-icon="copy">100- <path d="M8 7.5h11v13H8v-13Z"/>101- <path d="M5 16.5H4v-13h11v1M13.5 11v6M10.5 14h6"/>102- </g>103- </svg>104- </button>105- <button class="save-menu-toggle" id="save-menu-toggle" type="button" popovertarget="save-menu" aria-label="Choose save action" title="Choose save action">โŒ„</button>106- <div class="action-menu save-action-menu" id="save-menu" popover>107- <button type="button" data-primary-save="publish">Publish</button>108- <button type="button" data-primary-save="encrypt-publish">Encrypt &amp; Publish</button>109- <button type="button" data-primary-save="file">Save as File</button>110- <button type="button" data-save-quick-action="svg-url">Copy SVG URL</button>111- <button type="button" data-save-quick-action="markdown">Copy as Markdown</button>112- </div>113- </div>114- <button class="header-icon-action" id="share" type="button" aria-label="Share" title="Share">115- <svg class="header-action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">116- <circle cx="18" cy="5" r="2.5"/>117- <circle cx="6" cy="12" r="2.5"/>118- <circle cx="18" cy="19" r="2.5"/>119- <path d="m8.2 10.8 7.6-4.5M8.2 13.2l7.6 4.5"/>120- </svg>121- </button>122- </div>123- </div>80+ <span class="header-spacer"></span>81+ <span class="render-status" data-state="idle" role="status">Ready</span>82+ <select id="diagram-type" hidden aria-hidden="true"></select>83+ <button class="type-command" id="command-type" type="button" aria-haspopup="dialog" aria-controls="command-dialog">84+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 8h13m-3-3 3 3-3 3M20 16H7m3-3-3 3 3 3"/></svg>85+ <span id="command-type-label">Diagram</span>86+ </button>87+ <button class="command-trigger" id="command-trigger" type="button" aria-haspopup="dialog" aria-controls="command-dialog" aria-label="Open command palette">88+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="7"/><path d="m16.5 16.5 4 4"/></svg>89+ <kbd>โŒ˜K</kbd>90+ </button>12491 </header>12592 12693 <aside class="draft-bar" id="draft-bar" aria-live="polite" hidden>
@@ -137,11 +104,6 @@ document.querySelector('#app').innerHTML = `
137104 </div>138105 </aside>139106 140- <div class="mobile-tabs" role="tablist" aria-label="Workspace panel">141- <button type="button" role="tab" aria-selected="true" aria-controls="editor-panel" data-panel="editor" tabindex="0">Edit</button>142- <button type="button" role="tab" aria-selected="false" aria-controls="preview-panel" data-panel="preview" tabindex="-1">Preview</button>143- </div>144-145107 <div class="workspace" data-mobile-panel="editor">146108 <section class="editor-panel" id="editor-panel" aria-label="Diagram input">147109 <div class="editor-tabs" role="tablist" aria-label="Diagram input">
@@ -200,6 +162,15 @@ document.querySelector('#app').innerHTML = `
200162 </div>201163 </section>202164 </div>165+ <button class="mobile-panel-switch" type="button" data-target-panel="preview" aria-controls="preview-panel" aria-label="Show preview" title="Show preview">166+ <svg data-panel-icon="preview" viewBox="0 0 24 24" aria-hidden="true" focusable="false">167+ <path d="M3 12s3.3-6 9-6 9 6 9 6-3.3 6-9 6-9-6-9-6Z"/>168+ <circle cx="12" cy="12" r="2.6"/>169+ </svg>170+ <svg data-panel-icon="editor" viewBox="0 0 24 24" aria-hidden="true" focusable="false">171+ <path d="m9 7-5 5 5 5M15 7l5 5-5 5"/>172+ </svg>173+ </button>203174 </main>204175 205176 <dialog id="share-dialog" aria-labelledby="share-title">
@@ -286,6 +257,24 @@ document.querySelector('#app').innerHTML = `
286257 </form>287258 </dialog>288259 260+ <dialog class="command-dialog" id="command-dialog" aria-label="Command palette">261+ <div class="command-card">262+ <label class="command-search">263+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="7"/><path d="m16.5 16.5 4 4"/></svg>264+ <span class="command-scope" id="command-scope" hidden>Change format</span>265+ <span class="sr-only">Filter commands</span>266+ <input id="command-input" type="search" placeholder="Type a command" autocomplete="off" spellcheck="false" aria-controls="command-results" aria-autocomplete="list">267+ <kbd>esc</kbd>268+ </label>269+ <div class="command-results" id="command-results" role="listbox" aria-label="Commands"></div>270+ <footer class="command-footer">271+ <span><kbd>โ†‘โ†“</kbd> navigate</span>272+ <span><kbd>โ†ต</kbd> run</span>273+ <span id="command-footer-note"><kbd>โŒ˜K</kbd> anywhere</span>274+ </footer>275+ </div>276+ </dialog>277+289278 <div class="toast" id="toast" role="status" aria-live="polite" popover="manual"></div>290279 `291280
@@ -301,6 +290,7 @@ let detailsValid = true
301290 let detailsError = ''302291 let detailsState303292 let detailsEditor = null293+let sourceEditor = null304294 let previewHasBeenRevealed = false305295 for (const { id, label } of diagramTypes) {306296 typePicker.add(new Option(label, id))
@@ -312,12 +302,12 @@ detailsState = {
312302 presentation: normalizePresentation(initialState.presentation),313303 }314304 typePicker.value = initialState.type305+updateTypeCommand()315306 syncPreviewAppearanceControls()316307 updateDocumentMetadata()317308 activeType = initialState.type318-typeDrafts.set(initialState.type, initialState)319309 320-const sourceEditor = await createSourceEditor({310+sourceEditor = await createSourceEditor({321311 element: document.querySelector('#editor'),322312 source: initialState.source,323313 diagramType: initialState.type,
@@ -358,41 +348,21 @@ systemColorScheme.addEventListener('change', () => {
358348 })359349 360350 typePicker.addEventListener('change', () => {361- if (!ensureValidDetails()) {362- typePicker.value = activeType363- return364- }365- const type = typePicker.value366- history.replaceState(null, '', urlWithDiagramType(location.href, type))367- applyState(stateForTypeChange(typeDrafts, activeType, type, currentState(), exampleStateFor))351+ changeDiagramType(typePicker.value)368352 })369353 370354 document.querySelector('#lock-diagram').addEventListener('click', lockDiagram)371355 document.querySelector('#change-password').addEventListener('click', changeDiagramPassword)372356 document.querySelector('#unlock-diagram').addEventListener('click', unlockDiagram)373-document.querySelector('#open').addEventListener('click', openEditableSvgDialog)374-document.querySelector('#save').addEventListener('click', performPrimarySaveAction)375357 document.querySelector('#restore-saved').addEventListener('click', restoreSavedDiagram)358+document.querySelector('#quick-reset').addEventListener('click', quickResetDocument)376359 document.querySelector('#make-copy').addEventListener('click', makeDraftCopy)377-document.querySelector('#share').addEventListener('click', openShareDialog)360+document.querySelector('#command-trigger').addEventListener('click', () => openCommandPalette())361+document.querySelector('#command-type').addEventListener('click', () => openCommandPalette('format'))378362 titleInput.addEventListener('focus', beginTitleEdit)379363 titleInput.addEventListener('input', updateTitleFromHeader)380364 titleInput.addEventListener('blur', finishTitleEdit)381365 titleInput.addEventListener('keydown', handleTitleKeydown)382-document.querySelectorAll('[data-primary-save]').forEach(button => {383- button.addEventListener('click', () => {384- primarySaveAction = button.dataset.primarySave385- document.querySelector('#save-menu').hidePopover?.()386- updateSaveButton()387- })388-})389-document.querySelectorAll('[data-save-quick-action]').forEach(button => {390- button.addEventListener('click', async () => {391- document.querySelector('#save-menu').hidePopover?.()392- await performShareAction(button.dataset.saveQuickAction)393- updateSaveButton()394- })395-})396366 document.querySelectorAll('[data-share-action]').forEach(button => {397367 button.addEventListener('click', () => performShareAction(button.dataset.shareAction))398368 })
@@ -402,8 +372,7 @@ document.querySelector('#open-file').addEventListener('click', () => document.qu
402372 document.querySelector('#open-file-input').addEventListener('change', openSelectedEditableSvgFile)403373 document.querySelector('.brand').addEventListener('click', event => {404374 event.preventDefault()405- if (remote.dirty && !confirm('Discard these local changes and start a new diagram?')) return406- startNewDiagram()375+ requestNewDiagram()407376 })408377 409378 previewRawButton.addEventListener('click', () => {
@@ -441,9 +410,19 @@ wireTabs(document.querySelector('.editor-tabs'), button => {
441410 else sourceEditor.layout()442411 })443412 444-wireTabs(document.querySelector('.mobile-tabs'), button => {445- document.querySelector('.workspace').dataset.mobilePanel = button.dataset.panel446- if (button.dataset.panel === 'preview') {413+document.querySelector('.mobile-panel-switch').addEventListener('click', () => {414+ setMobilePanel(document.querySelector('.mobile-panel-switch').dataset.targetPanel)415+})416+417+function setMobilePanel(panel) {418+ const state = mobilePanelSwitchState(panel)419+ const switchButton = document.querySelector('.mobile-panel-switch')420+ document.querySelector('.workspace').dataset.mobilePanel = state.panel421+ switchButton.dataset.targetPanel = state.targetPanel422+ switchButton.setAttribute('aria-controls', state.controls)423+ switchButton.setAttribute('aria-label', state.label)424+ switchButton.title = state.label425+ if (state.panel === 'preview') {447426 if (!previewHasBeenRevealed) {448427 previewHasBeenRevealed = true449428 requestAnimationFrame(() => preview.fit())
@@ -451,7 +430,7 @@ wireTabs(document.querySelector('.mobile-tabs'), button => {
451430 } else {452431 activeInputEditor().layout()453432 }454-})433+}455434 456435 document.querySelectorAll('[data-copy]').forEach(button => {457436 button.addEventListener('click', async () => {
@@ -479,11 +458,62 @@ window.addEventListener('drop', event => {
479458 480459 window.addEventListener('keydown', event => {481460 if (event.defaultPrevented) return482- if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {461+ const primary = event.metaKey || event.ctrlKey462+ const key = event.key.toLowerCase()463+ if (primary && key === 'k') {464+ event.preventDefault()465+ const dialog = document.querySelector('#command-dialog')466+ if (dialog.open) dialog.close()467+ else openCommandPalette()468+ } else if (primary && event.shiftKey && key === 'f') {469+ event.preventDefault()470+ openCommandPalette('format')471+ } else if (primary && event.shiftKey && key === 'p') {472+ event.preventDefault()473+ saveDiagram()474+ } else if (primary && event.shiftKey && key === 's') {475+ event.preventDefault()476+ openShareDialog()477+ } else if (primary && key === 's') {478+ event.preventDefault()479+ saveEditableFile()480+ } else if (primary && key === 'o') {481+ event.preventDefault()482+ openEditableSvgDialog()483+ } else if (primary && key === 'n') {484+ event.preventDefault()485+ requestNewDiagram()486+ } else if (!primary && event.shiftKey && key === '0') {487+ event.preventDefault()488+ preview.fit()489+ }490+})491+492+document.querySelector('#command-input').addEventListener('input', renderCommandPalette)493+document.querySelector('#command-input').addEventListener('keydown', event => {494+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {495+ event.preventDefault()496+ if (event.key === 'ArrowDown') commandPaletteModel?.next()497+ else commandPaletteModel?.previous()498+ renderCommandPalette(false)499+ } else if (event.key === 'Enter') {483500 event.preventDefault()484- performPrimarySaveAction()501+ runSelectedCommand()502+ } else if (event.key === 'Escape') {503+ event.preventDefault()504+ document.querySelector('#command-dialog').close()485505 }486506 })507+document.querySelector('#command-dialog').addEventListener('click', event => {508+ if (event.target === event.currentTarget) event.currentTarget.close()509+})510+document.querySelector('#command-dialog').addEventListener('close', () => commandPaletteInvoker?.focus())511+document.querySelector('#command-results').addEventListener('click', event => {512+ const option = event.target.closest('[data-command-id]')513+ if (!option || option.disabled) return514+ commandPaletteModel?.selectById(option.dataset.commandId)515+ runSelectedCommand()516+})487517 488518 function wireTabs(tablist, onSelect) {489519 const tabs = [...tablist.querySelectorAll('[role="tab"]')]
@@ -522,6 +552,200 @@ function activeInputEditor() {
522552 : sourceEditor523553 }524554 555+let commandPaletteMode = 'root'556+let commandPaletteModel = null557+let commandPaletteInvoker = null558+559+function commandGroups(mode = 'root') {560+ if (mode === 'format') {561+ return launcherCatalogGroups.map(group => ({562+ id: group.id,563+ label: group.label,564+ commands: group.items.map(entry => ({565+ id: `format:${entry.id}`,566+ label: entry.label,567+ description: `${entry.description} - ${entry.extensions.join(', ')}`,568+ keywords: [entry.id, ...entry.extensions],569+ disabled: entry.id === typePicker.value,570+ run: () => changeDiagramType(entry.id),571+ })),572+ }))573+ }574+575+ const recents = localDocuments.loadRecents().slice(0, 6).map(entry => ({576+ id: entry.kind === 'local' ? `recent:local:${entry.localId}` : `recent:alias:${entry.aliasId}`,577+ label: entry.title || 'Locked diagram',578+ description: `${entry.type ? diagramTypeLabel(entry.type) : 'Encrypted'} ยท ${relativeTime(entry.updatedAt)}${entry.kind === 'alias' ? ' - published' : ''}`,579+ keywords: [entry.type, entry.kind],580+ run: () => openRecent(entry),581+ }))582+ const publishedEmbedAvailable = remote.aliasId583+ ? Boolean(remote.savedMode !== 'locked' && remote.savedState)584+ : remote.mode !== 'locked'585+ return [586+ ...(recents.length ? [{ id: 'recent', label: 'Recent', commands: recents }] : []),587+ {588+ id: 'format',589+ label: 'Format',590+ commands: [{591+ id: 'change-format',592+ label: 'Change formatโ€ฆ',593+ description: `${diagramTypeLabel(typePicker.value)} โ†’ 29 others`,594+ keywords: ['renderer', 'syntax', 'language'],595+ shortcut: 'โŒ˜โ‡งF',596+ run: () => openCommandPalette('format'),597+ }],598+ },599+ {600+ id: 'save-share',601+ label: 'Save & share',602+ commands: [603+ { id: 'save-file', label: 'Save as file', description: 'Editable .svg', keywords: ['download', 'export'], shortcut: 'โŒ˜S', disabled: saving, run: saveEditableFile },604+ { id: 'publish', label: 'Publish', description: 'Create or update a short link', shortcut: 'โŒ˜โ‡งP', disabled: saving, run: saveDiagram },605+ { id: 'encrypt-publish', label: 'Encrypt & publish', description: 'Password protected, client-side', keywords: ['lock', 'private'], disabled: saving, run: encryptAndPublish },606+ { id: 'copy-svg', label: 'Copy SVG URL', keywords: ['embed', 'link'], disabled: !publishedEmbedAvailable, run: () => performShareAction('svg-url') },607+ { id: 'copy-markdown', label: 'Copy as Markdown', keywords: ['embed', 'image'], disabled: !publishedEmbedAvailable, run: () => performShareAction('markdown') },608+ { id: 'share', label: 'Shareโ€ฆ', description: 'Links, privacy, and publishing', shortcut: 'โŒ˜โ‡งS', run: openShareDialog },609+ ],610+ },611+ {612+ id: 'view',613+ label: 'View',614+ commands: [615+ { id: 'fit', label: 'Fit to window', shortcut: 'โ‡ง0', run: () => preview.fit() },616+ { id: 'one-to-one', label: 'Actual size', description: 'Show at 1:1', keywords: ['zoom'], run: () => preview.oneToOne() },617+ { id: 'raw', label: detailsState.presentation.appearance === 'raw' ? 'Use themed renderer output' : 'Use raw renderer output', keywords: ['appearance', 'theme'], run: () => previewRawButton.click() },618+ ],619+ },620+ {621+ id: 'document',622+ label: 'Document',623+ commands: [624+ { id: 'new', label: 'Create newโ€ฆ', description: 'Choose a template in the launcher', shortcut: 'โŒ˜N', run: requestNewDiagram },625+ { id: 'open', label: 'Open editable SVGโ€ฆ', shortcut: 'โŒ˜O', run: openEditableSvgDialog },626+ { id: 'docs', label: 'Documentation', description: 'Open docs.diagram.zip', keywords: ['help', 'reference'], run: () => location.assign('https://docs.diagram.zip/') },627+ ],628+ },629+ ]630+}631+632+function openCommandPalette(mode = 'root') {633+ const dialog = document.querySelector('#command-dialog')634+ if (dialog.open) dialog.close()635+ commandPaletteInvoker = document.activeElement636+ commandPaletteMode = mode637+ commandPaletteModel = createCommandPaletteModel(commandGroups(mode))638+ const input = document.querySelector('#command-input')639+ input.value = ''640+ input.placeholder = mode === 'format' ? 'Filter formats' : 'Type a command'641+ document.querySelector('#command-scope').hidden = mode !== 'format'642+ document.querySelector('#command-footer-note').textContent = mode === 'format'643+ ? 'Source and document details stay unchanged'644+ : 'โŒ˜K anywhere'645+ if (mode === 'root') commandPaletteModel.selectById('change-format')646+ else commandPaletteModel.selectFirst()647+ renderCommandPalette(false)648+ dialog.showModal()649+ input.focus()650+}651+652+function renderCommandPalette(resetSelection = true) {653+ if (!commandPaletteModel) return654+ const input = document.querySelector('#command-input')655+ if (resetSelection) commandPaletteModel.selectFirst(input.value)656+ const results = commandPaletteModel.results()657+ const resultSet = new Set(results.map(command => command.id))658+ const container = document.querySelector('#command-results')659+ container.replaceChildren()660+ for (const group of commandPaletteModel.groups) {661+ const commands = group.commands.filter(command => resultSet.has(command.id))662+ if (!commands.length) continue663+ const heading = document.createElement('div')664+ heading.className = 'command-section'665+ heading.textContent = group.label666+ container.append(heading)667+ for (const command of commands) {668+ const option = document.createElement('button')669+ option.type = 'button'670+ option.className = 'command-option'671+ option.id = `command-option-${command.id.replace(/[^a-z0-9_-]/gi, '-')}`672+ option.dataset.commandId = command.id673+ option.disabled = command.disabled === true674+ option.setAttribute('role', 'option')675+ const selected = command.id === commandPaletteModel.selectedId676+ option.setAttribute('aria-selected', String(selected))677+ option.innerHTML = `<span class="command-option-copy"><strong></strong>${command.description ? '<small></small>' : ''}</span><span class="command-option-spacer"></span>${command.shortcut ? '<kbd></kbd>' : ''}`678+ option.querySelector('strong').textContent = command.label679+ if (command.description) option.querySelector('small').textContent = command.description680+ if (command.shortcut) option.querySelector('kbd').textContent = command.shortcut681+ container.append(option)682+ }683+ }684+ if (!results.length) {685+ const empty = document.createElement('p')686+ empty.className = 'command-empty'687+ empty.textContent = commandPaletteMode === 'format' ? 'No matching formats.' : 'No matching commands.'688+ container.append(empty)689+ }690+ const selected = container.querySelector('[aria-selected="true"]')691+ input.setAttribute('aria-activedescendant', selected?.id ?? '')692+ selected?.scrollIntoView({ block: 'nearest' })693+}694+695+function runSelectedCommand() {696+ const command = commandPaletteModel?.selected697+ if (!command || command.disabled) return698+ const dialog = document.querySelector('#command-dialog')699+ dialog.close()700+ queueMicrotask(() => command.run())701+}702+703+function changeDiagramType(type) {704+ if (!isKnownDiagramType(type)) return705+ if (!ensureValidDetails()) {706+ typePicker.value = activeType707+ updateTypeCommand()708+ return709+ }710+ history.replaceState(null, '', urlWithDiagramType(location.href, type))711+ applyState(stateForTypeChange(currentState(), type))712+}713+714+function updateTypeCommand() {715+ const label = diagramTypeLabel(typePicker.value)716+ const button = document.querySelector('#command-type')717+ document.querySelector('#command-type-label').textContent = label718+ button.setAttribute('aria-label', `Change format. Current format: ${label}`)719+ button.title = `Change format - Current: ${label}`720+}721+722+function diagramTypeLabel(type) {723+ return diagramTypes.find(item => item.id === type)?.label ?? type ?? 'Diagram'724+}725+726+function relativeTime(timestamp) {727+ const elapsed = Math.max(0, Date.now() - Number(timestamp || 0))728+ const minute = 60_000729+ const hour = 60 * minute730+ const day = 24 * hour731+ if (elapsed < minute) return 'just now'732+ if (elapsed < hour) return `${Math.floor(elapsed / minute)}m ago`733+ if (elapsed < day) return `${Math.floor(elapsed / hour)}h ago`734+ if (elapsed < 7 * day) return `${Math.floor(elapsed / day)}d ago`735+ return new Date(timestamp).toLocaleDateString()736+}737+738+function openRecent(entry) {739+ if (entry.kind === 'local') location.assign(`/?doc=${encodeURIComponent(entry.localId)}`)740+ else if (entry.mode === 'locked') location.assign(`/d/${entry.aliasId}`)741+ else location.assign(urlWithDiagramType(`/d/${entry.aliasId}`, entry.type || DEFAULT_DIAGRAM_TYPE))742+}743+744+function requestNewDiagram() {745+ if (remote.dirty && !confirm('Leave these local changes and open the launcher? They remain on this device.')) return746+ location.assign('/')747+}748+525749 function currentState() {526750 return {527751 type: typePicker.value,
@@ -620,7 +844,7 @@ function commitState(render = true) {
620844 mode: remote.mode,621845 keyEnvelopeDirty: remote.keyEnvelopeDirty,622846 savedSnapshot: remote.savedSnapshot,623- defaultStateFor: exampleStateFor,847+ defaultStateFor: templateStateFor,624848 })625849 syncStoredDraft(state)626850 updateSaveButton()
@@ -750,13 +974,13 @@ async function loadInitialState() {
750974 const unlocked = await promptToUnlock(alias)751975 if (!unlocked) {752976 initialLoadError = 'A password is required to open this diagram.'753- return exampleStateFor(DEFAULT_DIAGRAM_TYPE)977+ return templateStateFor(DEFAULT_DIAGRAM_TYPE)754978 }755979 setRemoteAlias(alias, writeCapability, unlocked.state, unlocked.bundleKey)756980 return unlocked.state757981 }758982 setRemoteAlias(alias, writeCapability, alias.state)759- const draft = loadDraft(`${ALIAS_DRAFT_PREFIX}${aliasId}`)983+ const draft = loadAliasDraft(`${ALIAS_DRAFT_PREFIX}${aliasId}`)760984 if (draft?.revision === alias.revision) {761985 restoredDetailsSource = draft.detailsSource762986 return draft.state
@@ -769,24 +993,24 @@ async function loadInitialState() {
769993 }770994 }771995 if (location.hash) history.replaceState(null, '', `${location.pathname}${location.search}`)772- const requestedType = diagramTypeFromQuery(location.search)773- const draft = loadDraft(LOCAL_DRAFT_KEY)774- if (requestedType) {775- if (draft?.state.type === requestedType) {776- lastFileSnapshot = draft.fileSnapshot777- restoredDetailsSource = draft.detailsSource778- const state = refreshMatchingExampleMetadata(draft.state, exampleStateFor(requestedType))779- if (state !== draft.state) storeDraft(state)996+ if (localDocumentId) {997+ const document = localDocuments.loadLocalDocument(localDocumentId)998+ const state = normalizeStoredState(document?.state)999+ if (state) {1000+ lastFileSnapshot = document.fileSnapshot1001+ restoredDetailsSource = document.detailsSource1002+ localDocuments.touchRecent({ localId: localDocumentId })7801003 return state7811004 }782- return exampleStateFor(requestedType)1005+ initialLoadError = 'This local diagram is no longer stored on this device.'1006+ localDocumentId = null7831007 }784- if (draft) {785- lastFileSnapshot = draft.fileSnapshot786- restoredDetailsSource = draft.detailsSource787- return draft.state1008+ const requestedType = diagramTypeFromQuery(location.search)1009+ if (requestedType) {1010+ const state = templateStateFor(requestedType)1011+ return state7881012 }789- return exampleStateFor(DEFAULT_DIAGRAM_TYPE)1013+ return templateStateFor(DEFAULT_DIAGRAM_TYPE)7901014 }7911015 7921016 function applyState(state, commit = true) {
@@ -794,8 +1018,8 @@ function applyState(state, commit = true) {
7941018 const meta = normalizeMetadata(state.meta)7951019 const presentation = normalizePresentation(state.presentation)7961020 activeType = state.type797- typeDrafts.set(state.type, { ...state, meta, presentation })7981021 typePicker.value = state.type1022+ updateTypeCommand()7991023 setDetailsState({ meta, presentation })8001024 syncPreviewAppearanceControls()8011025 sourceEditor.setDocument({ source: state.source, diagramType: state.type })
@@ -803,14 +1027,7 @@ function applyState(state, commit = true) {
8031027 else preview.render(currentState())8041028 }8051029 806-function selectedSaveAction() {807- return primarySaveAction ?? (remote.aliasId ? 'publish' : 'file')808-}809-8101030 async function performPrimarySaveAction() {811- const action = selectedSaveAction()812- if (action === 'publish') return saveDiagram()813- if (action === 'encrypt-publish') return encryptAndPublish()8141031 return saveEditableFile()8151032 }8161033
@@ -899,10 +1116,9 @@ async function openEditableSvgValue(load) {
8991116 if (remote.dirty && !confirm('Replace the current working draft with this editable SVG? Unsaved changes will be discarded.')) return9001117 clearTimeout(renderTimer)9011118 clearTimeout(detailsCommitTimer)902- primarySaveAction = null9031119 lastFileSnapshot = workingStateSnapshot(state)1120+ localDocumentId = null9041121 remote = emptyRemoteState()905- typeDrafts.clear()9061122 history.pushState(null, '', urlWithDiagramType('/', state.type))9071123 applyState(state)9081124 const dialog = document.querySelector('#open-dialog')
@@ -1069,14 +1285,19 @@ async function saveDiagram() {
10691285 10701286 async function createNewAlias(state) {10711287 const previousAliasId = remote.aliasId1072- const previousDraftKey = previousAliasId ? `${ALIAS_DRAFT_PREFIX}${previousAliasId}` : LOCAL_DRAFT_KEY1288+ const previousDraftKey = previousAliasId ? `${ALIAS_DRAFT_PREFIX}${previousAliasId}` : null1289+ const previousLocalDocumentId = localDocumentId10731290 const bundleKey = remote.bundleKey10741291 const alias = remote.mode === 'locked'10751292 ? await persistence.createLockedAlias(await lockedPayloadForState(state))10761293 : await persistence.createAlias(state)10771294 setRemoteAlias(alias, alias.writeCapability, state, remote.mode === 'locked' ? bundleKey : null)10781295 storeWriteCapability(alias.aliasId, alias.writeCapability)1079- removeDraft(previousDraftKey)1296+ if (previousDraftKey) removeDraft(previousDraftKey)1297+ if (previousLocalDocumentId) {1298+ localDocuments.deleteLocalDocument(previousLocalDocumentId)1299+ localDocumentId = null1300+ }10801301 history.pushState(null, '', urlWithDiagramType(`/d/${alias.aliasId}`, state.type))10811302 await cacheSavedRenders(state)10821303 }
@@ -1176,7 +1397,12 @@ async function lockDiagram() {
11761397 remote.encryptedContent = payload.encryptedContent11771398 remote.encryptedMetadata = payload.encryptedMetadata11781399 remote.keyEnvelopeDirty = true1179- removeDraft(remote.aliasId ? `${ALIAS_DRAFT_PREFIX}${remote.aliasId}` : LOCAL_DRAFT_KEY)1400+ if (remote.aliasId) removeDraft(`${ALIAS_DRAFT_PREFIX}${remote.aliasId}`)1401+ else if (localDocumentId) {1402+ localDocuments.deleteLocalDocument(localDocumentId)1403+ localDocumentId = null1404+ history.replaceState(null, '', urlWithDiagramType('/', typePicker.value))1405+ }11801406 commitState(false)11811407 showToast('Encrypted in this browser โ€” publish to persist')11821408 return true
@@ -1349,7 +1575,7 @@ function normalizeStoredState(value) {
13491575 }13501576 }13511577 1352-function loadDraft(key) {1578+function loadAliasDraft(key) {13531579 try {13541580 const draft = JSON.parse(localStorage.getItem(key))13551581 const state = normalizeStoredState(draft?.state)
@@ -1367,7 +1593,25 @@ function loadDraft(key) {
13671593 13681594 function storeDraft(state) {13691595 if (remote.mode === 'locked') return1370- const key = remote.aliasId ? `${ALIAS_DRAFT_PREFIX}${remote.aliasId}` : LOCAL_DRAFT_KEY1596+ if (!remote.aliasId) {1597+ const savedId = localDocuments.saveLocalDocument({1598+ state,1599+ detailsSource: !detailsValid && detailsEditor ? detailsEditor.getValue() : null,1600+ fileSnapshot: lastFileSnapshot,1601+ drafts: {},1602+ dirty: remote.dirty,1603+ }, localDocumentId ? { localId: localDocumentId, currentLocalId: localDocumentId } : {})1604+ if (!savedId) {1605+ showToast('This diagram could not be saved on this device.', 4000)1606+ return1607+ }1608+ if (!localDocumentId) {1609+ localDocumentId = savedId1610+ history.replaceState(null, '', `/?doc=${encodeURIComponent(localDocumentId)}`)1611+ }1612+ return1613+ }1614+ const key = `${ALIAS_DRAFT_PREFIX}${remote.aliasId}`13711615 try {13721616 const detailsSource = !detailsValid && detailsEditor13731617 ? detailsEditor.getValue()
@@ -1385,7 +1629,11 @@ function storeDraft(state) {
13851629 13861630 function syncStoredDraft(state) {13871631 if (remote.mode === 'locked') return1388- const key = remote.aliasId ? `${ALIAS_DRAFT_PREFIX}${remote.aliasId}` : LOCAL_DRAFT_KEY1632+ if (!remote.aliasId) {1633+ if (remote.dirty || localDocumentId) storeDraft(state)1634+ return1635+ }1636+ const key = `${ALIAS_DRAFT_PREFIX}${remote.aliasId}`13891637 if (remote.dirty) storeDraft(state)13901638 else removeDraft(key)13911639 }
@@ -1432,57 +1680,46 @@ function setRemoteAlias(alias, writeCapability, state, bundleKey = null) {
14321680 keyEnvelopeDirty: false,14331681 dirty: false,14341682 }1683+ localDocuments.upsertRecent({1684+ kind: 'alias',1685+ aliasId: alias.aliasId,1686+ title: alias.mode === 'locked' ? null : normalizeMetadata(state.meta).title,1687+ type: alias.mode === 'locked' ? '' : state.type,1688+ mode: alias.mode,1689+ })14351690 updatePrivacyControls()14361691 updateSaveButton()14371692 }14381693 14391694 function updateSaveButton() {1440- const button = document.querySelector('#save')14411695 updateDraftControls()1442- const action = selectedSaveAction()1443- const fileDirty = action === 'file'1444- && (lastFileSnapshot === null || lastFileSnapshot !== workingStateSnapshot(currentState()))1445- let state = 'save'1446- let label = action === 'file' ? 'Save as File' : action === 'encrypt-publish' ? 'Encrypt & Publish' : 'Publish'1447- let disabled = false1448- if (saving) {1449- state = 'saving'1450- label = action === 'file' ? 'Saving File' : action === 'encrypt-publish' ? 'Encrypting & Publishing' : 'Publishing'1451- disabled = true1452- } else if (action === 'file' && !fileDirty) {1453- state = 'saved'1454- label = 'Saved to File'1455- disabled = true1456- } else if (action === 'publish' && remote.aliasId && !remote.dirty) {1457- state = 'saved'1458- label = 'Published'1459- disabled = true1460- } else if (action === 'publish' && remote.aliasId && !remote.writeCapability) {1461- state = 'copy'1462- label = 'Publish a Copy'1463- } else if (action === 'encrypt-publish' && remote.aliasId && remote.mode === 'locked' && !remote.dirty) {1464- state = 'saved'1465- label = 'Published'1466- disabled = true1696+ const indicator = document.querySelector('#unsaved-indicator')1697+ const reset = document.querySelector('#quick-reset')1698+ const dirty = remote.dirty || remote.keyEnvelopeDirty1699+ indicator.dataset.dirty = String(dirty)1700+ reset.dataset.visible = String(dirty)1701+ reset.setAttribute('aria-hidden', String(!dirty))1702+ reset.tabIndex = dirty ? 0 : -11703+ reset.disabled = saving1704+ const resetTarget = remote.aliasId && remote.savedState1705+ ? 'last cloud save'1706+ : `${diagramTypeLabel(typePicker.value)} template default`1707+ reset.setAttribute('aria-label', `Reset to ${resetTarget}`)1708+ reset.title = `Reset to ${resetTarget}`1709+ if (document.querySelector('#command-dialog').open && commandPaletteMode === 'root') {1710+ const query = document.querySelector('#command-input').value1711+ commandPaletteModel = createCommandPaletteModel(commandGroups())1712+ commandPaletteModel.selectFirst(query)1713+ renderCommandPalette(false)14671714 }1468- button.dataset.saveState = state1469- button.dataset.saveAction = action1470- button.dataset.dirty = String(action === 'file' ? fileDirty : remote.dirty)1471- button.setAttribute('aria-label', label)1472- button.title = label1473- button.disabled = disabled1474- document.querySelector('#save-menu-toggle').disabled = saving1475- document.querySelectorAll('[data-primary-save]').forEach(option => {1476- const selected = option.dataset.primarySave === action1477- option.dataset.selected = String(selected)1478- option.setAttribute('aria-pressed', String(selected))1479- })1480- const publishedEmbedAvailable = remote.aliasId1481- ? Boolean(remote.savedMode !== 'locked' && remote.savedState)1482- : remote.mode !== 'locked'1483- document.querySelectorAll('[data-save-quick-action="svg-url"], [data-save-quick-action="markdown"]').forEach(option => {1484- option.disabled = !publishedEmbedAvailable1485- })1715+}1716+1717+function quickResetDocument() {1718+ if (!(remote.dirty || remote.keyEnvelopeDirty) || saving) return1719+ const cloudSaved = Boolean(remote.aliasId && remote.savedState)1720+ const target = cloudSaved ? 'the last cloud save' : `the ${diagramTypeLabel(typePicker.value)} template default`1721+ if (!confirm(`Reset to ${target}? Local changes will be discarded.`)) return1722+ restoreSavedDiagram({ confirmed: true })14861723 }14871724 14881725 function updateDraftControls() {
@@ -1490,7 +1727,7 @@ function updateDraftControls() {
14901727 const restore = document.querySelector('#restore-saved')14911728 const makeCopy = document.querySelector('#make-copy')14921729 const hasAliasOverlay = Boolean(remote.aliasId && remote.savedState && remote.dirty)1493- const hasAnonymousDraft = Boolean(!remote.aliasId && remote.dirty && lastFileSnapshot === null)1730+ const hasAnonymousDraft = Boolean(!remote.aliasId && !localDocumentId && remote.dirty && lastFileSnapshot === null)14941731 bar.hidden = !hasAliasOverlay && !hasAnonymousDraft14951732 restore.disabled = saving14961733 makeCopy.disabled = saving
@@ -1510,19 +1747,18 @@ function updateDraftControls() {
15101747 }15111748 }15121749 1513-function restoreSavedDiagram() {1750+function restoreSavedDiagram({ confirmed = false } = {}) {15141751 if (!remote.aliasId) {1515- resetAnonymousExample()1752+ resetAnonymousExample({ confirmed })15161753 return15171754 }1518- if (!remote.aliasId || !remote.savedState || !remote.dirty) return1755+ if (!remote.aliasId || !remote.savedState || !(remote.dirty || remote.keyEnvelopeDirty)) return15191756 clearTimeout(renderTimer)15201757 clearTimeout(detailsCommitTimer)15211758 remote.mode = remote.savedMode15221759 remote.keyEnvelopeDirty = false15231760 remote.dirty = false15241761 removeDraft(`${ALIAS_DRAFT_PREFIX}${remote.aliasId}`)1525- typeDrafts.clear()15261762 applyState(remote.savedState, false)15271763 history.replaceState(null, '', urlWithDiagramType(location.href, remote.savedState.type))15281764 updatePrivacyControls()
@@ -1530,14 +1766,14 @@ function restoreSavedDiagram() {
15301766 showToast('Restored published diagram')15311767 }15321768 1533-function resetAnonymousExample() {1769+function resetAnonymousExample({ confirmed = false } = {}) {15341770 if (remote.aliasId || !remote.dirty) return1535- if (!confirm('Discard local changes and reset this example?')) return1771+ if (!confirmed && !confirm('Discard local changes and reset this example?')) return15361772 clearTimeout(renderTimer)15371773 clearTimeout(detailsCommitTimer)1538- removeDraft(LOCAL_DRAFT_KEY)1539- typeDrafts.clear()1540- const state = exampleStateFor(typePicker.value)1774+ if (localDocumentId) localDocuments.deleteLocalDocument(localDocumentId)1775+ localDocumentId = null1776+ const state = templateStateFor(typePicker.value)15411777 applyState(state, false)15421778 remote.dirty = false15431779 history.replaceState(null, '', urlWithDiagramType(location.href, state.type))
@@ -1574,16 +1810,6 @@ function updatePrivacyControls() {
15741810 document.querySelector('#unlock-diagram').hidden = !locked15751811 }15761812 1577-function startNewDiagram() {1578- if (!remote.aliasId) removeDraft(LOCAL_DRAFT_KEY)1579- remote = emptyRemoteState({ dirty: false })1580- primarySaveAction = null1581- lastFileSnapshot = null1582- typeDrafts.clear()1583- history.pushState(null, '', urlWithDiagramType('/', DEFAULT_DIAGRAM_TYPE))1584- applyState(exampleStateFor(DEFAULT_DIAGRAM_TYPE))1585-}1586-15871813 function emptyRemoteState(overrides = {}) {15881814 return {15891815 aliasId: null,
A apps/editor/src/mobile-panel.js +10 -0
M apps/editor/src/preview.js +58 -12
@@ -54,6 +54,7 @@ export class PreviewController {
5454 this.x = 05555 this.y = 05656 this.objectUrl = null57+ this.imageSwapNumber = 05758 this.abortController = null5859 this.requestNumber = 05960 this.pendingRender = null
@@ -103,6 +104,7 @@ export class PreviewController {
103104 render({ type, source, options = {}, meta = {}, presentation = {} }) {104105 if (!source.trim()) {105106 this.requestNumber++107+ this.imageSwapNumber++106108 this.pendingRender = null107109 this.abortController?.abort()108110 this.setStatus('Write something to render.', 'idle')
@@ -118,6 +120,7 @@ export class PreviewController {
118120 const renderKey = JSON.stringify({ type, source, options, meta, presentation })119121 if (this.latestRenderKey === renderKey && this.latestSvgBlob) return Promise.resolve()120122 123+ this.imageSwapNumber++121124 this.pendingRender = { type, source, options, meta, presentation, renderKey, requestNumber: ++this.requestNumber }122125 this.abortController?.abort()123126 if (!this.renderLoop) this.renderLoop = this.drainRenderQueue()
@@ -200,23 +203,66 @@ export class PreviewController {
200203 ? materializePresentation(canonicalSvg, presentation)201204 : materializeSvg(canonicalSvg, displayAppearance)202205 const displayed = this.normalizedSvgBlob(displayedSvg)206+ const nextObjectUrl = URL.createObjectURL(displayed.blob)207+ const swapNumber = ++this.imageSwapNumber208+ this.setStatus('Loading imageโ€ฆ', 'loading')209+ void this.swapDecodedImage({210+ objectUrl: nextObjectUrl,211+ fallbackUrl: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(displayedSvg)}`,212+ background: previewBackgroundForAppearance(displayAppearance, displayed.background),213+ theme: previewThemeForAppearance(displayAppearance),214+ }, swapNumber)215+ }216+217+ async decodeImage(url) {218+ const image = new Image()219+ image.decoding = 'async'220+ if (typeof image.decode === 'function') {221+ image.src = url222+ await image.decode()223+ return224+ }225+ await new Promise((resolve, reject) => {226+ image.addEventListener('load', resolve, { once: true })227+ image.addEventListener('error', () => reject(new Error('The rendered image was blocked or invalid.')), { once: true })228+ image.src = url229+ })230+ }231+232+ async swapDecodedImage(next, swapNumber) {233+ let activeUrl = next.objectUrl234+ try {235+ await this.decodeImage(activeUrl)236+ } catch {237+ activeUrl = next.fallbackUrl238+ try {239+ await this.decodeImage(activeUrl)240+ } catch (error) {241+ URL.revokeObjectURL(next.objectUrl)242+ if (swapNumber === this.imageSwapNumber) this.handleRenderError(error.message)243+ return244+ }245+ }246+247+ if (swapNumber !== this.imageSwapNumber) {248+ URL.revokeObjectURL(next.objectUrl)249+ return250+ }251+203252 this.previousImageSize = !this.fitOnNextLoad && this.image.naturalWidth && this.image.naturalHeight204253 ? { width: this.image.naturalWidth, height: this.image.naturalHeight }205254 : null206- this.imageFallbackUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(displayedSvg)}`207- this.stage.dataset.previewTheme = previewThemeForAppearance(displayAppearance)208- this.stage.style.setProperty(209- '--render-background',210- previewBackgroundForAppearance(displayAppearance, displayed.background),211- )212- const nextObjectUrl = URL.createObjectURL(displayed.blob)255+ this.stage.dataset.previewTheme = next.theme256+ this.stage.style.setProperty('--render-background', next.background)257+213258 const previousObjectUrl = this.objectUrl214- this.objectUrl = nextObjectUrl215- this.activeImageUrl = nextObjectUrl216- this.image.src = nextObjectUrl217- this.minimapImage.src = nextObjectUrl259+ this.objectUrl = activeUrl === next.objectUrl ? next.objectUrl : null260+ this.imageFallbackUrl = activeUrl === next.objectUrl ? next.fallbackUrl : null261+ this.activeImageUrl = activeUrl262+ this.image.src = activeUrl263+ this.minimapImage.src = activeUrl218264 this.image.hidden = false219- this.setStatus('Loading imageโ€ฆ', 'loading')265+ if (activeUrl !== next.objectUrl) URL.revokeObjectURL(next.objectUrl)220266 if (previousObjectUrl) URL.revokeObjectURL(previousObjectUrl)221267 }222268
M apps/editor/src/style.css +459 -196
@@ -76,7 +76,6 @@ body {
7676 .brand,7777 button,7878 .preview-stage,79-.mobile-tabs,8079 .editor-tabs,8180 .toast {8281 user-select: none;
@@ -113,7 +112,7 @@ button { cursor: pointer; }
113112 114113 .app-shell {115114 display: grid;116- grid-template-rows: 64px auto minmax(0, 1fr);115+ grid-template-rows: 44px auto minmax(0, 1fr);117116 height: 100%;118117 }119118
@@ -122,8 +121,8 @@ button { cursor: pointer; }
122121 display: flex;123122 align-items: center;124123 justify-content: space-between;125- gap: 20px;126- padding: 0 14px 0 20px;124+ gap: 10px;125+ padding: 0 12px;127126 border-bottom: 1px solid var(--line);128127 background: color-mix(in srgb, var(--page), transparent 5%);129128 backdrop-filter: blur(14px);
@@ -138,31 +137,31 @@ button { cursor: pointer; }
138137 }139138 140139 .brand-mark {141- width: 40px;142- height: 40px;140+ width: 28px;141+ height: 28px;143142 display: block;144143 }145144 146145 .document-identity {147146 display: flex;148147 align-items: center;149- flex: 1 1 320px;150- gap: 12px;148+ flex: 0 1 420px;149+ gap: 0;151150 min-width: 0;152151 }153152 154153 .document-title {155154 display: block;156- width: min(38vw, 420px);155+ width: min(34vw, 420px);157156 min-width: 90px;158157 padding: 3px 6px;159158 border: 1px solid transparent;160159 border-radius: 5px;161160 background: transparent;162161 color: var(--ink);163- font-size: 22px;164- font-weight: 440;165- letter-spacing: -.025em;162+ font-size: 13.5px;163+ font-weight: 600;164+ letter-spacing: -.01em;166165 overflow: hidden;167166 text-overflow: ellipsis;168167 white-space: nowrap;
@@ -178,13 +177,6 @@ button { cursor: pointer; }
178177 179178 .document-title::placeholder { color: var(--muted); opacity: 1; }180179 181-.header-meta {182- display: flex;183- align-items: center;184- gap: 0;185- min-width: 0;186-}187-188180 .draft-bar {189181 grid-row: 2;190182 display: flex;
@@ -227,17 +219,120 @@ button { cursor: pointer; }
227219 .render-status {228220 flex: 0 0 auto;229221 color: var(--muted);230- font-size: 13px;222+ font-size: 11.5px;223+ white-space: nowrap;224+}225+226+.header-spacer { flex: 1 1 auto; }227+228+.unsaved-indicator {229+ width: 6px;230+ height: 6px;231+ flex: 0 0 auto;232+ border-radius: 50%;233+ background: var(--unsaved);234+}235+236+.document-reset {237+ display: inline-flex;238+ width: 0;239+ height: 24px;240+ flex: 0 0 auto;241+ justify-content: center;242+ align-items: center;243+ gap: 7px;244+ margin-left: 0;245+ padding: 0;246+ overflow: hidden;247+ border: 1px solid transparent;248+ border-radius: 6px;249+ background: transparent;250+ color: var(--muted);251+ opacity: 0;252+ pointer-events: none;253+ transform: translateX(-8px);254+ transition:255+ width 180ms cubic-bezier(.2, .8, .2, 1),256+ margin-left 180ms cubic-bezier(.2, .8, .2, 1),257+ border-color 140ms ease,258+ opacity 100ms ease,259+ transform 180ms cubic-bezier(.2, .8, .2, 1);260+}261+.document-reset[data-visible="true"] {262+ width: 44px;263+ margin-left: 7px;264+ border-color: var(--line);265+ opacity: 1;266+ pointer-events: auto;267+ transform: translateX(0);268+}269+.document-reset-icon {270+ width: 14px;271+ height: 14px;272+ flex: 0 0 auto;273+ fill: none;274+ stroke: currentColor;275+ stroke-width: 1.8;276+ stroke-linecap: round;277+ stroke-linejoin: round;278+ transform: translateX(-8px) rotate(-24deg);279+ opacity: 0;280+ transition: transform 180ms cubic-bezier(.2, .8, .2, 1), opacity 120ms ease 40ms;281+}282+.document-reset[data-visible="true"] .document-reset-icon {283+ transform: translateX(0) rotate(0);284+ opacity: 1;285+}286+.document-reset:hover:not(:disabled) { background: var(--soft); color: var(--ink); }287+.document-reset:disabled { cursor: default; opacity: .5; }288+289+@media (prefers-reduced-motion: reduce) {290+ .document-reset,291+ .document-reset-icon { transition: none; }292+}293+294+.type-command,295+.command-trigger {296+ display: inline-flex;297+ align-items: center;298+ gap: 7px;299+ min-height: 28px;300+ padding: 4px 9px;301+ border: 1px solid var(--line);302+ border-radius: 7px;303+ background: transparent;304+ color: var(--muted);305+ font-size: 11.5px;306+ white-space: nowrap;307+}308+309+.type-command:hover,310+.command-trigger:hover { background: var(--soft); color: var(--ink); }311+312+.type-command svg,313+.command-trigger svg {314+ width: 15px;315+ height: 15px;316+ fill: none;317+ stroke: currentColor;318+ stroke-linecap: round;319+ stroke-linejoin: round;320+ stroke-width: 1.7;321+}322+323+kbd {324+ padding: 2px 5px;325+ border: 1px solid var(--line);326+ border-radius: 4px;327+ background: var(--soft);328+ color: var(--muted);329+ font: 10.5px/1.2 var(--font-mono);231330 white-space: nowrap;232331 }233332 234-.type-picker select,235333 .primary-action,236334 .secondary-action,237-.header-icon-action,238-.save-menu-toggle,239335 .preview-toolbar button,240-.mobile-tabs button,241336 .icon-button,242337 .share-options button {243338 min-height: 36px;
@@ -246,13 +341,6 @@ button { cursor: pointer; }
246341 background: var(--surface);247342 }248343 249-.type-picker select {250- max-width: 170px;251- padding: 7px 30px 7px 11px;252-}253-254-.type-picker { margin: 0 10px; }255-256344 .primary-action {257345 padding: 7px 15px;258346 border-color: var(--accent-surface);
@@ -272,118 +360,6 @@ button { cursor: pointer; }
272360 273361 .secondary-action:hover { background: var(--soft); }274362 275-.header-actions {276- display: flex;277- align-items: center;278- gap: 6px;279-}280-281-.save-split {282- display: flex;283- align-items: stretch;284- border: 1px solid var(--line);285- border-radius: 10px;286- background: var(--surface);287- overflow: hidden;288-}289-290-.save-split #save {291- border: 0;292- border-radius: 0;293-}294-295-.save-menu-toggle {296- width: 20px;297- min-width: 20px;298- height: 40px;299- min-height: 40px;300- padding: 0;301- border: 0;302- border-left: 1px solid var(--line);303- border-radius: 0;304- font-size: 14px;305- line-height: 1;306-}307-308-.save-menu-toggle:hover { background: var(--soft); }309-.save-menu-toggle:disabled { cursor: default; opacity: .58; }310-311-.action-menu {312- position: fixed;313- inset: 58px 58px auto auto;314- min-width: 178px;315- margin: 0;316- padding: 6px;317- color: var(--ink);318- border: 1px solid var(--line);319- border-radius: 11px;320- background: var(--surface-raised);321- box-shadow: var(--shadow);322-}323-324-.action-menu::backdrop { background: transparent; }325-.action-menu button {326- display: block;327- width: 100%;328- min-height: 38px;329- padding: 7px 10px;330- border: 0;331- border-radius: 7px;332- background: transparent;333- color: var(--ink);334- text-align: left;335-}336-.action-menu button:hover,337-.action-menu button[data-selected="true"] { background: var(--soft); }338-.action-menu button[data-selected="true"]::after { content: "โœ“"; float: right; }339-340-.header-icon-action {341- display: inline-grid;342- width: 40px;343- min-width: 40px;344- height: 40px;345- padding: 0;346- place-items: center;347- color: var(--ink);348- text-decoration: none;349-}350-351-.header-icon-action:hover { background: var(--soft); }352-.header-icon-action:disabled { cursor: default; opacity: .58; }353-354-.save-split:has(#save[data-dirty="true"]) {355- border-color: var(--unsaved);356- box-shadow: inset 0 0 0 1px var(--unsaved);357-}358-359-#save[data-dirty="true"]:disabled { opacity: .72; }360-361-.save-split:has(:focus-visible) {362- outline: 2px solid var(--accent-ink);363- outline-offset: 2px;364-}365-366-.save-split :focus-visible { outline: 0; }367-368-.header-action-icon {369- width: 20px;370- height: 20px;371- display: block;372- fill: none;373- stroke: currentColor;374- stroke-width: 1.75;375- stroke-linecap: round;376- stroke-linejoin: round;377-}378-379-.save-action-icon [data-save-icon] { display: none; }380-#save[data-save-state="save"] [data-save-icon="save"],381-#save[data-save-state="saving"] [data-save-icon="saving"],382-#save[data-save-state="saved"] [data-save-icon="saved"],383-#save[data-save-state="copy"] [data-save-icon="copy"] { display: block; }384-#save[data-save-state="saving"] .save-action-icon { animation: save-spin 900ms linear infinite; }385-@keyframes save-spin { to { transform: rotate(360deg); } }386-387363 .workspace {388364 display: grid;389365 grid-template-columns: minmax(340px, .82fr) minmax(440px, 1.18fr);
@@ -650,7 +626,7 @@ button { cursor: pointer; }
650626 pointer-events: none;651627 }652628 653-.mobile-tabs { display: none; }629+.mobile-panel-switch { display: none; }654630 655631 dialog {656632 width: min(620px, calc(100% - 28px));
@@ -801,57 +777,302 @@ dialog::backdrop {
801777 802778 :focus-visible { outline: 2px solid var(--accent-ink); outline-offset: 2px; }803779 780+/* Launcher */781+.launcher-shell {782+ display: grid;783+ grid-template-rows: 44px minmax(0, 1fr);784+ height: 100%;785+ background: var(--page);786+}787+788+.launcher-header {789+ display: flex;790+ align-items: center;791+ gap: 10px;792+ padding: 0 12px;793+ border-bottom: 1px solid var(--line);794+ background: var(--surface);795+}796+797+.launcher-brand {798+ display: inline-flex;799+ align-items: center;800+ gap: 8px;801+ color: var(--muted);802+ font-size: 12.5px;803+ font-weight: 700;804+ text-decoration: none;805+}806+807+.launcher-brand img { width: 28px; height: 28px; }808+.launcher-docs { display: grid; width: 28px; height: 28px; place-items: center; border-radius: 7px; color: var(--muted); }809+.launcher-docs:hover { background: var(--soft); color: var(--ink); }810+.launcher-docs svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linejoin: round; }811+812+.launcher-layout {813+ position: relative;814+ display: grid;815+ grid-template-columns: 214px minmax(0, 1fr) 322px;816+ min-width: 0;817+ min-height: 0;818+}819+820+.launcher-rail {821+ display: flex;822+ min-width: 0;823+ min-height: 0;824+ flex-direction: column;825+ border-right: 1px solid var(--line);826+ background: color-mix(in srgb, var(--surface), var(--page) 48%);827+}828+829+.launcher-rail-scroll { min-height: 0; flex: 1; overflow: auto; padding: 12px 10px; }830+.launcher-rail h2,831+.launcher-catalog-heading h2,832+.command-section {833+ margin: 14px 8px 7px;834+ color: var(--muted);835+ font-size: 10.5px;836+ font-weight: 700;837+ letter-spacing: .1em;838+ text-transform: uppercase;839+}840+.launcher-rail h2:first-child { margin-top: 2px; }841+.launcher-rail button { font: inherit; }842+.launcher-rail-scroll > button,843+.launcher-group,844+.launcher-recents button {845+ display: flex;846+ width: 100%;847+ min-height: 34px;848+ align-items: center;849+ gap: 8px;850+ padding: 7px 8px;851+ border: 0;852+ border-radius: 7px;853+ background: transparent;854+ color: var(--ink);855+ font-size: 12.5px;856+ text-align: left;857+}858+.launcher-rail-scroll > button:hover,859+.launcher-group:hover,860+.launcher-recents button:hover { background: var(--soft); }861+.launcher-rail-scroll > button kbd { margin-left: auto; }862+.launcher-action-icon { width: 16px; color: var(--muted); font-size: 16px; text-align: center; }863+.launcher-group small { margin-left: auto; color: var(--muted); font-size: 11px; }864+.launcher-group[data-selected="true"],865+.launcher-recents button[data-selected="true"] { background: var(--accent-surface); color: var(--accent-ink); }866+.launcher-group[data-selected="true"] small,867+.launcher-recents button[data-selected="true"] small { color: inherit; opacity: .72; }868+.launcher-recents { display: grid; gap: 1px; }869+.launcher-recents button { min-height: 42px; }870+.recent-glyph {871+ display: grid;872+ width: 24px;873+ height: 20px;874+ flex: 0 0 auto;875+ place-items: center;876+ border: 1px solid currentColor;877+ border-radius: 5px;878+ color: var(--muted);879+ font: 9px/1 var(--font-mono);880+ opacity: .72;881+}882+.recent-copy { min-width: 0; }883+.recent-copy strong,884+.recent-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }885+.recent-copy strong { font-size: 12px; font-weight: 650; }886+.recent-copy small { margin-top: 2px; color: var(--muted); font-size: 10.5px; }887+.recent-dot { width: 6px; height: 6px; flex: 0 0 auto; margin-left: auto; border-radius: 50%; background: var(--unsaved); }888+.launcher-empty { margin: 0; padding: 8px; color: var(--muted); font-size: 12px; }889+.launcher-storage { display: grid; gap: 4px; padding: 10px 18px 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: 10.5px; line-height: 1.5; }890+.launcher-storage button { width: max-content; padding: 0; border: 0; background: transparent; color: inherit; font-size: inherit; text-decoration: underline; text-underline-offset: 2px; }891+892+.launcher-catalog-pane { display: flex; min-width: 0; min-height: 0; flex-direction: column; }893+.launcher-filter-bar { display: flex; align-items: center; gap: 8px; padding: 11px 14px; border-bottom: 1px solid var(--line); }894+.launcher-filter { display: flex; min-width: 0; flex: 1; align-items: center; gap: 9px; padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); color: var(--muted); }895+.launcher-filter svg { width: 15px; height: 15px; flex: none; fill: none; stroke: currentColor; stroke-width: 2; }896+.launcher-filter input { min-width: 0; flex: 1; padding: 0; border: 0; outline: 0; background: transparent; color: var(--ink); font-size: 13px; }897+.launcher-filter input::-webkit-search-cancel-button { display: none; }898+.launcher-view-toggle { display: flex; overflow: hidden; border: 1px solid var(--line); border-radius: 8px; }899+.launcher-view-toggle button { min-height: 32px; padding: 6px 10px; border: 0; border-right: 1px solid var(--line); background: var(--surface); color: var(--muted); font-size: 11.5px; }900+.launcher-view-toggle button:last-child { border-right: 0; }901+.launcher-view-toggle button[aria-pressed="true"] { background: var(--soft); color: var(--ink); font-weight: 700; }902+.launcher-catalog-scroll { min-height: 0; flex: 1; overflow: auto; padding: 14px; }903+.launcher-catalog-heading { display: flex; align-items: center; gap: 10px; margin: 14px 2px 9px; }904+.launcher-catalog-heading:first-child { margin-top: 0; }905+.launcher-catalog-heading h2 { margin: 0; }906+.launcher-catalog-heading span { height: 1px; flex: 1; background: var(--line); }907+.launcher-format-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); gap: 9px; }908+.launcher-format-grid button {909+ display: flex;910+ min-width: 0;911+ flex-direction: column;912+ gap: 7px;913+ padding: 9px;914+ border: 1px solid var(--line);915+ border-radius: 9px;916+ background: var(--surface);917+ color: var(--ink);918+ text-align: left;919+}920+.launcher-format-grid button:hover { border-color: var(--muted); }921+.launcher-format-grid button[data-selected="true"] { border-color: var(--accent-ink); box-shadow: 0 0 0 1px var(--accent-ink); }922+:root[data-theme="dark"] .launcher-format-grid button[data-selected="true"] { border-color: #80ff80; box-shadow: 0 0 0 1px #80ff80; }923+.launcher-preview-frame {924+ position: relative;925+ display: grid;926+ overflow: hidden;927+ place-items: center;928+ color: var(--muted);929+}930+.launcher-preview-frame img {931+ position: absolute;932+ inset: 0;933+ width: 100%;934+ height: 100%;935+ box-sizing: border-box;936+ object-fit: contain;937+}938+.launcher-preview-placeholder {939+ display: grid;940+ place-items: center;941+ gap: 6px;942+ color: #7e8691;943+ transition: opacity 120ms ease;944+}945+.launcher-preview-placeholder svg { width: 34px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.5; }946+.launcher-preview-placeholder small { font-size: 10px; font-weight: 600; }947+.launcher-preview-frame[data-preview-state="ready"] .launcher-preview-placeholder { opacity: 0; }948+.launcher-preview-frame[data-preview-state="error"] .launcher-preview-placeholder { color: #8b5e5e; }949+.format-icon { display: grid; width: 100%; height: 58px; place-items: center; border-radius: 6px; background: var(--soft); color: var(--muted); }950+.format-icon svg { width: 58px; height: 48px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }951+.format-copy strong,952+.format-copy small { display: block; }953+.format-copy strong { font-size: 12.5px; font-weight: 700; }954+.format-copy small { margin-top: 4px; color: var(--muted); font-size: 10.5px; line-height: 1.4; }955+.launcher-catalog-scroll[data-view="list"] .launcher-format-grid { grid-template-columns: 1fr; gap: 4px; }956+.launcher-catalog-scroll[data-view="list"] .launcher-format-grid button { min-height: 50px; flex-direction: row; align-items: center; }957+.launcher-catalog-scroll[data-view="list"] .format-icon { width: 64px; height: 42px; flex: 0 0 auto; }958+.launcher-catalog-scroll[data-view="list"] .format-icon svg { width: 45px; height: 36px; }959+.launcher-no-match { display: grid; height: 100%; place-content: center; justify-items: center; color: var(--muted); text-align: center; }960+.launcher-no-match strong { color: var(--ink); font-size: 14px; }961+.launcher-no-match p { max-width: 440px; font-size: 12.5px; line-height: 1.5; }962+.launcher-no-match button { padding: 6px 10px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface); color: var(--ink); }963+964+.launcher-detail { display: flex; min-width: 0; min-height: 0; flex-direction: column; border-left: 1px solid var(--line); background: color-mix(in srgb, var(--surface), var(--page) 48%); }965+.launcher-detail-preview {966+ width: 100%;967+ height: clamp(240px, 30vh, 330px);968+ flex: 0 0 auto;969+ border-bottom: 1px solid var(--line);970+ background-color: color-mix(in srgb, var(--surface), var(--page) 48%);971+ background-image: radial-gradient(color-mix(in srgb, var(--muted), transparent 70%) 1px, transparent 1px);972+ background-size: 15px 15px;973+}974+.launcher-detail-preview img { padding: 22px; }975+.launcher-detail-preview .launcher-preview-placeholder { padding: 12px 16px; border-radius: 7px; background: var(--surface); }976+.launcher-detail-preview .launcher-preview-placeholder svg { width: 48px; height: 32px; }977+.launcher-detail-body { min-height: 0; flex: 1; overflow: auto; padding: 14px; }978+.launcher-detail-body h1 { margin: 0; font-size: 16px; letter-spacing: -.01em; }979+.launcher-detail-body > p { margin: 6px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; }980+.launcher-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 10px; }981+.launcher-tags span { padding: 2px 6px; border: 1px solid var(--line); border-radius: 5px; color: var(--muted); font-size: 10px; }982+.launcher-detail-body dl { display: grid; grid-template-columns: auto 1fr; gap: 6px 12px; margin: 14px 0 0; font-size: 11.5px; }983+.launcher-detail-body dt { color: var(--muted); }984+.launcher-detail-body dd { margin: 0; overflow-wrap: anywhere; }985+.launcher-detail-body pre { max-height: 250px; margin: 14px 0 0; padding: 9px 10px; overflow: hidden; border: 1px solid var(--line); border-radius: 7px; background: var(--page); color: var(--ink); font: 10.5px/1.65 var(--font-mono); white-space: pre-wrap; }986+.launcher-detail-footer { display: flex; flex: 0 0 auto; gap: 7px; padding: 11px 14px; border-top: 1px solid var(--line); }987+.launcher-detail-footer .primary-action { flex: 1; justify-content: center; }988+989+/* Editor command palette */990+.command-dialog {991+ width: min(600px, calc(100% - 28px));992+ max-height: min(620px, calc(100% - 56px));993+ padding: 0;994+ overflow: hidden;995+ border-radius: 13px;996+}997+.command-dialog[open] { margin-top: 60px; }998+.command-dialog::backdrop { background: rgba(0, 0, 0, .46); backdrop-filter: none; }999+.command-card { display: flex; max-height: min(620px, calc(100vh - 100px)); flex-direction: column; }1000+.command-search { display: flex; flex: 0 0 auto; align-items: center; gap: 10px; padding: 13px 15px; border-bottom: 1px solid var(--line); color: var(--muted); }1001+.command-search svg { width: 16px; height: 16px; flex: none; fill: none; stroke: currentColor; stroke-width: 2; }1002+.command-search input { min-width: 0; flex: 1; padding: 0; border: 0; outline: 0; background: transparent; color: var(--ink); font-size: 14px; }1003+.command-search input::-webkit-search-cancel-button { display: none; }1004+.command-scope { padding: 3px 6px; border-radius: 5px; background: var(--accent-surface); color: var(--accent-ink); font-size: 10.5px; font-weight: 800; }1005+.command-results { min-height: 80px; overflow: auto; padding: 6px; }1006+.command-section { margin: 8px 10px 5px; }1007+.command-option { display: flex; width: 100%; min-height: 42px; align-items: center; gap: 10px; padding: 7px 10px; border: 0; border-radius: 7px; background: transparent; color: var(--ink); text-align: left; }1008+.command-option:hover:not(:disabled),1009+.command-option[aria-selected="true"]:not(:disabled) { background: var(--accent-surface); color: var(--accent-ink); }1010+.command-option:disabled { cursor: not-allowed; opacity: .42; }1011+.command-option-copy { min-width: 0; }1012+.command-option-copy strong,1013+.command-option-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }1014+.command-option-copy strong { font-size: 13px; font-weight: 700; }1015+.command-option-copy small { margin-top: 2px; color: var(--muted); font-size: 11px; }1016+.command-option[aria-selected="true"] small { color: currentColor; opacity: .74; }1017+.command-option-spacer { flex: 1; }1018+.command-option[aria-selected="true"] kbd { border-color: currentColor; background: transparent; color: currentColor; }1019+.command-empty { margin: 0; padding: 30px 12px; color: var(--muted); font-size: 13px; text-align: center; }1020+.command-footer { display: flex; flex: 0 0 auto; flex-wrap: wrap; gap: 14px; padding: 9px 14px; border-top: 1px solid var(--line); color: var(--muted); font-size: 10.5px; }1021+.command-footer span:last-child { margin-left: auto; }1022+1023+.paste-dialog .details-fields select { width: 100%; height: 42px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--page); }1024+.paste-dialog textarea { min-height: 220px; font-family: var(--font-mono); }1025+8041026 @media (max-width: 760px) {805- .app-shell { grid-template-rows: 64px auto 42px minmax(0, 1fr); }1027+ .app-shell { grid-template-rows: 44px auto minmax(0, 1fr); }8061028 .render-status { display: none; }8071029 .app-header { padding-left: 14px; }808- .brand-mark { width: 36px; height: 36px; }809- .document-identity { flex-basis: 140px; gap: 8px; }810- .document-title { width: 100%; font-size: 18px; }811- .header-meta { flex: 1; justify-content: flex-end; }812- .type-picker { margin: 0 8px; }813- .header-actions { gap: 4px; }814- .header-icon-action { width: 36px; min-width: 36px; height: 36px; }815- .header-action-icon { width: 18px; height: 18px; }816- .header-actions > a { display: none; }817- .save-menu-toggle { height: 36px; min-height: 36px; }818- .action-menu { inset: 58px 48px auto auto; }1030+ .brand-mark { width: 28px; height: 28px; }1031+ .document-identity { flex-basis: 140px; }1032+ .document-title { width: 100%; font-size: 13px; }8191033 .draft-bar { align-items: stretch; flex-direction: column; gap: 7px; padding: 9px 14px; }8201034 .draft-bar div { display: grid; grid-template-columns: 1fr 1fr; }8211035 .draft-bar[data-anonymous="true"] div { display: flex; justify-content: flex-end; }822- .mobile-tabs { grid-row: 3; }823- .workspace { grid-row: 4; }824- .type-picker { flex: 0 1 142px; min-width: 0; }825- .type-picker select { width: 100%; max-width: 142px; }826- .mobile-tabs {1036+ .workspace { grid-row: 3; display: block; width: 100%; overflow: hidden; }1037+ .workspace[data-mobile-panel="editor"] .preview-panel,1038+ .workspace[data-mobile-panel="preview"] .editor-panel { display: none; }1039+ .editor-panel, .preview-panel { width: 100%; height: 100%; border-right: 0; }1040+ .mobile-panel-switch {1041+ position: fixed;1042+ right: 14px;1043+ bottom: calc(44px + env(safe-area-inset-bottom, 0px));1044+ z-index: 9;8271045 display: grid;828- grid-template-columns: 1fr 1fr;829- gap: 4px;830- padding: 4px;831- border-bottom: 1px solid var(--line);832- background: var(--page);1046+ width: 52px;1047+ height: 52px;1048+ padding: 0;1049+ place-items: center;1050+ touch-action: manipulation;1051+ border: 1px solid color-mix(in srgb, var(--accent-ink), transparent 58%);1052+ border-radius: 50%;1053+ background: var(--accent-surface);1054+ color: var(--accent-ink);1055+ box-shadow: var(--shadow);8331056 }834-835- .mobile-tabs button {836- min-height: 32px;837- border-color: transparent;838- background: transparent;839- color: var(--muted);840- font-size: 13px;841- font-weight: 700;1057+ .mobile-panel-switch:active { transform: translateY(1px); }1058+ .mobile-panel-switch:focus-visible { outline: 2px solid var(--accent-ink); outline-offset: 3px; }1059+ .mobile-panel-switch svg {1060+ width: 24px;1061+ height: 24px;1062+ fill: none;1063+ stroke: currentColor;1064+ stroke-linecap: round;1065+ stroke-linejoin: round;1066+ stroke-width: 1.8;8421067 }843-844- .mobile-tabs button[aria-selected="true"] {845- background: var(--soft);846- box-shadow: inset 0 -2px 0 var(--accent-ink);847- color: var(--ink);1068+ .mobile-panel-switch[data-target-panel="preview"] [data-panel-icon="editor"],1069+ .mobile-panel-switch[data-target-panel="editor"] [data-panel-icon="preview"] { display: none; }1070+ .minimap {1071+ right: 14px;1072+ bottom: calc(110px + env(safe-area-inset-bottom, 0px));1073+ width: 132px;1074+ height: 88px;8481075 }849-850- .workspace { display: block; width: 100%; overflow: hidden; }851- .workspace[data-mobile-panel="editor"] .preview-panel,852- .workspace[data-mobile-panel="preview"] .editor-panel { display: none; }853- .editor-panel, .preview-panel { width: 100%; height: 100%; border-right: 0; }854- .minimap { width: 132px; height: 88px; }8551076 .privacy-fields { align-items: stretch; flex-direction: column; }8561077 .privacy-actions { justify-content: flex-start; }8571078 .share-action-list { grid-template-columns: 1fr; }
@@ -861,20 +1082,62 @@ dialog::backdrop {
8611082 @media (max-width: 480px) {8621083 .app-header { gap: 8px; }8631084 .document-identity { flex: 0 0 auto; }864- .document-title { display: none; }865- .header-meta { flex: 1 1 auto; min-width: 0; }866- .type-picker { flex: 1 1 auto; min-width: 0; overflow: hidden; }867- .type-picker select { display: block; width: 100%; min-width: 0; max-width: none; }868- .header-actions { flex: 0 0 auto; position: relative; }1085+ .document-title { display: block; max-width: 150px; }1086+}1087+1088+@media (max-width: 1080px) {1089+ .launcher-layout { grid-template-columns: 190px minmax(0, 1fr) 282px; }1090+ .launcher-format-grid { grid-template-columns: repeat(auto-fill, minmax(138px, 1fr)); }1091+}1092+1093+@media (max-width: 820px) {1094+ .launcher-layout { grid-template-columns: 178px minmax(0, 1fr); }1095+ .launcher-detail {1096+ position: absolute;1097+ right: 12px;1098+ bottom: 12px;1099+ width: min(310px, calc(100% - 202px));1100+ max-height: calc(100% - 24px);1101+ z-index: 5;1102+ border: 1px solid var(--line);1103+ border-radius: 11px;1104+ box-shadow: var(--shadow);1105+ overflow: hidden;1106+ }1107+ .launcher-detail-preview { height: 120px; }1108+ .launcher-detail-body pre { max-height: 130px; }1109+ .launcher-catalog-scroll { padding-bottom: 220px; }1110+ .command-dialog[open] { margin-top: 28px; }1111+}1112+1113+@media (max-width: 620px) {1114+ .launcher-layout { display: block; overflow: auto; }1115+ .launcher-rail { min-height: 310px; border-right: 0; border-bottom: 1px solid var(--line); }1116+ .launcher-rail-scroll { overflow: visible; }1117+ .launcher-catalog-pane { min-height: 620px; }1118+ .launcher-detail {1119+ position: fixed;1120+ right: 8px;1121+ bottom: 8px;1122+ left: 8px;1123+ width: auto;1124+ max-height: 42vh;1125+ }1126+ .launcher-detail-preview,1127+ .launcher-detail-body pre { display: none; }1128+ .launcher-detail-body { padding: 12px; }1129+ .launcher-detail-body dl { margin-top: 8px; }1130+ .launcher-filter-bar { position: sticky; top: 0; z-index: 3; background: var(--page); }1131+ .launcher-view-toggle { display: none; }1132+ .launcher-catalog-scroll { overflow: visible; padding-bottom: 280px; }1133+ .launcher-format-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }1134+ .type-command span { max-width: 82px; overflow: hidden; text-overflow: ellipsis; }1135+ .command-trigger svg { display: none; }1136+ .command-footer span:last-child { display: none; }8691137 }8701138 8711139 @media (max-width: 390px) {872- .type-picker { flex-basis: 108px; }873- .type-picker { margin: 0 5px; }874- .header-actions { gap: 3px; }875- .header-icon-action { width: 34px; min-width: 34px; height: 34px; }876- .save-menu-toggle { width: 18px; min-width: 18px; height: 34px; min-height: 34px; }8771140 .primary-action, .secondary-action { padding-inline: 9px; }8781141 .preview-toolbar { bottom: 10px; left: 10px; }879- .minimap { right: 10px; bottom: 62px; }1142+ .mobile-panel-switch, .minimap { right: 10px; }8801143 }
M apps/editor/src/type-drafts.js +6 -3
A apps/editor/test/command-palette.test.js +51 -0
@@ -0,0 +1,51 @@
1+import assert from 'node:assert/strict'2+import test from 'node:test'3+import { createCommandPaletteModel, flattenCommandGroups } from '../src/command-palette.js'4+5+const commands = [6+ { id: 'open', label: 'Open File', description: 'Open a diagram', keywords: ['load'], shortcut: 'โŒ˜O', run: () => 'open' },7+ { id: 'disabled', label: 'Open Recent', disabled: true },8+ { id: 'save', label: 'Save File', keywords: 'persist', shortcut: 'โŒ˜S', run: () => 'save' },9+ { id: 'export', label: 'Export SVG', description: 'Download image', run: () => 'export' },10+]11+12+test('flattens groups in stable order', () => {13+ assert.deepEqual(flattenCommandGroups([14+ { id: 'files', commands: commands.slice(0, 2) },15+ { id: 'share', commands: commands.slice(2) },16+ ]), commands)17+})18+19+test('filters case-insensitively by all query tokens across command fields', () => {20+ const model = createCommandPaletteModel([{ commands }])21+ assert.deepEqual(model.filter(' SVG download '), [commands[3]])22+ assert.deepEqual(model.filter('LOAD โŒ˜o'), [commands[0]])23+ assert.deepEqual(model.filter('file'), [commands[0], commands[2]])24+})25+26+test('selectFirst chooses the first enabled result and navigation skips disabled commands cyclically', () => {27+ const model = createCommandPaletteModel([{ commands }])28+ assert.equal(model.selectFirst(), commands[0])29+ assert.equal(model.next(), commands[2])30+ assert.equal(model.next(), commands[3])31+ assert.equal(model.next(), commands[0])32+ assert.equal(model.previous(), commands[3])33+ assert.equal(model.previous(), commands[2])34+})35+36+test('navigation with no current selection starts at the relevant end and handles no results', () => {37+ const model = createCommandPaletteModel([{ commands }])38+ assert.equal(model.previous(), commands[3])39+ assert.equal(model.filter('missing').length, 0)40+ assert.equal(model.next(), null)41+ assert.equal(model.selected, null)42+})43+44+test('lookup and selecting by id preserve command identity, while disabled commands cannot be selected', () => {45+ const model = createCommandPaletteModel([{ commands }])46+ assert.equal(model.getById('save'), commands[2])47+ assert.equal(model.selectById('save'), commands[2])48+ assert.equal(model.selectedId, 'save')49+ assert.equal(model.selectById('disabled'), null)50+ assert.equal(model.getById('unknown'), null)51+})
M apps/editor/test/example-defaults.test.js +22 -0
A apps/editor/test/format-icons.test.js +11 -0
A apps/editor/test/launcher-catalog.test.js +46 -0
@@ -0,0 +1,46 @@
1+import test from 'node:test'2+import assert from 'node:assert/strict'3+import { diagramTypes } from '../src/diagram-types.js'4+import {5+ launcherCatalog,6+ launcherCatalogGroups,7+ launcherEntryFor,8+ filterLauncherCatalog,9+} from '../src/launcher-catalog.js'10+11+test('launcher groups cover every diagram type exactly once in the source order', () => {12+ const groupedIds = launcherCatalogGroups.flatMap(group => group.items.map(item => item.id))13+ assert.equal(launcherCatalog.length, 30)14+ assert.equal(groupedIds.length, diagramTypes.length)15+ assert.equal(new Set(groupedIds).size, groupedIds.length)16+ assert.deepEqual(new Set(groupedIds), new Set(diagramTypes.map(type => type.id)))17+ assert.deepEqual(launcherCatalog.map(item => item.id), diagramTypes.map(type => type.id))18+ assert.deepEqual(launcherCatalogGroups.map(group => [group.label, group.items.length]), [19+ ['Flow & sequence', 6],20+ ['Architecture', 6],21+ ['Data & charts', 4],22+ ['Network & hardware', 7],23+ ['Drawing & ASCII', 7],24+ ])25+})26+27+test('catalog entries are normalized with group and format metadata', () => {28+ for (const entry of launcherCatalog) {29+ assert.equal(typeof entry.id, 'string')30+ assert.equal(typeof entry.label, 'string')31+ assert.equal(typeof entry.description, 'string')32+ assert.ok(entry.description.length > 0)33+ assert.equal(typeof entry.groupId, 'string')34+ assert.equal(typeof entry.groupLabel, 'string')35+ assert.ok(Array.isArray(entry.extensions))36+ }37+ assert.equal(launcherEntryFor('not-a-format'), null)38+ assert.equal(launcherEntryFor('wireviz').groupLabel, 'Network & hardware')39+})40+41+test('catalog filtering is case-insensitive across id, label, description, and extension', () => {42+ assert.deepEqual(filterLauncherCatalog('PLANTUML').map(entry => entry.id), ['plantuml', 'c4plantuml'])43+ assert.deepEqual(filterLauncherCatalog('wIrInG').map(entry => entry.id), ['wireviz'])44+ assert.deepEqual(filterLauncherCatalog('.yaml').map(entry => entry.id), ['wireviz'])45+ assert.deepEqual(filterLauncherCatalog(' '), launcherCatalog)46+})
A apps/editor/test/launcher-previews.test.js +62 -0
@@ -0,0 +1,62 @@
1+import assert from 'node:assert/strict'2+import test from 'node:test'3+import { createLauncherPreviewRenderer, previewKey } from '../src/launcher-previews.js'4+5+const state = { type: 'd2', source: 'a -> b', options: { theme: 0 }, meta: { title: 'Example' } }6+7+test('renders an HTTP preview once and caches its object URL', async () => {8+ const requests = []9+ const renderer = createLauncherPreviewRenderer({10+ endpointFor: type => `https://${type}.example/render`,11+ clientAdapterForImpl: () => null,12+ fetchImpl: async (url, init) => {13+ requests.push({ url, init })14+ return { ok: true, text: async () => '<svg viewBox="0 0 1 1"></svg>' }15+ },16+ createObjectURL: blob => `blob:${blob.type}`,17+ transformSvg: rendered => rendered.body,18+ })19+20+ const [first, second] = await Promise.all([renderer.render(state), renderer.render(state)])21+ assert.equal(first, 'blob:image/svg+xml')22+ assert.equal(second, first)23+ assert.equal(requests.length, 1)24+ assert.equal(requests[0].url, 'https://d2.example/render')25+ assert.equal(JSON.parse(requests[0].init.body).source, state.source)26+})27+28+test('uses a client renderer when one is registered', async () => {29+ let calls = 030+ const renderer = createLauncherPreviewRenderer({31+ clientAdapterForImpl: type => type === 'mermaid' ? {32+ render: async () => { calls++; return { body: '<svg></svg>' } },33+ } : null,34+ endpointFor: () => { throw new Error('HTTP renderer should not be used') },35+ createObjectURL: () => 'blob:mermaid',36+ transformSvg: rendered => rendered.body,37+ })38+39+ assert.equal(await renderer.render({ type: 'mermaid', source: 'flowchart LR' }), 'blob:mermaid')40+ assert.equal(calls, 1)41+})42+43+test('accepts renderer SVGs with an XML declaration', async () => {44+ const renderer = createLauncherPreviewRenderer({45+ clientAdapterForImpl: () => ({46+ render: async () => ({ body: '<?xml version="1.0" encoding="UTF-8"?><svg viewBox="0 0 1 1"></svg>' }),47+ }),48+ createObjectURL: () => 'blob:xml-svg',49+ transformSvg: rendered => rendered.body,50+ })51+52+ assert.equal(await renderer.render({ type: 'symbolator', source: 'module demo; endmodule' }), 'blob:xml-svg')53+})54+55+test('rejects malformed renderer output and includes source in cache identity', async () => {56+ const renderer = createLauncherPreviewRenderer({57+ clientAdapterForImpl: () => ({ render: async () => ({ body: '<html></html>' }) }),58+ transformSvg: rendered => rendered.body,59+ })60+ await assert.rejects(renderer.render({ type: 'bad', source: 'bad' }), /did not return an SVG/)61+ assert.notEqual(previewKey(state), previewKey({ ...state, source: 'b -> c' }))62+})
A apps/editor/test/local-documents.test.js +75 -0
@@ -0,0 +1,75 @@
1+import assert from 'node:assert/strict'2+import test from 'node:test'3+import { createLocalDocuments, LEGACY_DRAFT_KEY, LOCAL_DOCUMENT_PREFIX, RECENTS_KEY, WRITE_CAPABILITY_PREFIX } from '../src/local-documents.js'4+5+class MemoryStorage {6+ constructor(limit = Infinity) { this.values = new Map(); this.limit = limit }7+ get length() { return this.values.size }8+ key(index) { return [...this.values.keys()][index] ?? null }9+ getItem(key) { return this.values.get(key) ?? null }10+ removeItem(key) { this.values.delete(key) }11+ setItem(key, value) {12+ if (this.failNext) { this.failNext = false; throw new DOMException('quota', 'QuotaExceededError') }13+ const next = new Map(this.values); next.set(key, String(value))14+ if ([...next.values()].reduce((n, item) => n + item.length, 0) > this.limit) throw new DOMException('quota', 'QuotaExceededError')15+ this.values = next16+ }17+}18+const cryptoStub = { getRandomValues(bytes) { bytes.set([0xde, 0xad, 0xbe, 0xef].slice(0, bytes.length)); return bytes } }19+const doc = (title = 'Draft') => ({ state: { type: 'd2', source: 'x', meta: { title } }, detailsSource: '{}', fileSnapshot: null, drafts: { d2: 'x' }, dirty: false })20+21+test('creates, validates, upserts, touches, loads, and deletes local bodies', () => {22+ const storage = new MemoryStorage(); const docs = createLocalDocuments({ storage, cryptoImpl: cryptoStub, now: () => 10 })23+ const id = docs.saveLocalDocument(doc())24+ assert.equal(id, 'l_deadbeef')25+ assert.deepEqual(docs.loadLocalDocument(id), { state: doc().state, detailsSource: '{}', fileSnapshot: null, drafts: { d2: 'x' } })26+ assert.equal(storage.getItem(`${LOCAL_DOCUMENT_PREFIX}${id}`).includes('dirty'), false)27+ assert.equal(docs.touchRecent({ localId: id }, { title: 'Renamed' }), true)28+ assert.equal(docs.loadRecents()[0].title, 'Renamed')29+ assert.equal(docs.deleteLocalDocument(id), true)30+ assert.equal(docs.loadLocalDocument(id), null)31+ assert.deepEqual(docs.loadRecents(), [])32+})33+34+test('keeps at most 50 recent entries, ordering by updated time, and aliases have no body', () => {35+ const storage = new MemoryStorage(); let clock = 0; const docs = createLocalDocuments({ storage, cryptoImpl: cryptoStub, now: () => ++clock })36+ for (let i = 0; i < 55; i++) docs.upsertRecent({ kind: 'alias', aliasId: `a${i}`, title: `A${i}`, type: 'd2', mode: 'open' })37+ docs.upsertRecent({ kind: 'alias', aliasId: 'locked', title: 'secret', type: 'd2', mode: 'locked' })38+ assert.equal(docs.loadRecents().length, 50)39+ assert.equal(docs.loadRecents()[0].title, null)40+ assert.equal(docs.loadRecents()[0].type, '')41+ assert.equal(storage.getItem(`${LOCAL_DOCUMENT_PREFIX}locked`), null)42+})43+44+test('ignores malformed index and bodies, and clearRecents preserves write credentials', () => {45+ const storage = new MemoryStorage(); storage.setItem(RECENTS_KEY, '{bad'); storage.setItem(`${WRITE_CAPABILITY_PREFIX}a`, 'credential')46+ storage.setItem(`${LOCAL_DOCUMENT_PREFIX}l_ffffffff`, JSON.stringify(doc('orphan')))47+ const docs = createLocalDocuments({ storage, cryptoImpl: cryptoStub })48+ assert.deepEqual(docs.loadRecents(), [])49+ assert.equal(docs.loadLocalDocument('l_00000000'), null)50+ docs.upsertRecent({ kind: 'alias', aliasId: 'a', title: 'A', mode: 'open', type: 'd2' })51+ assert.equal(docs.clearRecents(), true)52+ assert.equal(storage.getItem(`${WRITE_CAPABILITY_PREFIX}a`), 'credential')53+ assert.equal(storage.getItem(`${LOCAL_DOCUMENT_PREFIX}l_ffffffff`), null)54+})55+56+test('migrates the legacy draft once into a body and index entry', () => {57+ const storage = new MemoryStorage(); storage.setItem(LEGACY_DRAFT_KEY, JSON.stringify(doc('Legacy')))58+ const docs = createLocalDocuments({ storage, cryptoImpl: cryptoStub, now: () => 3 })59+ assert.equal(docs.migrateLegacyDraft(), 'l_deadbeef')60+ assert.equal(storage.getItem(LEGACY_DRAFT_KEY), null)61+ assert.equal(docs.migrateLegacyDraft(), null)62+})63+64+test('on quota evicts the oldest clean document, excluding current, and retries once', () => {65+ const storage = new MemoryStorage(700); let clock = 0; const docs = createLocalDocuments({ storage, cryptoImpl: cryptoStub, now: () => ++clock })66+ const first = docs.saveLocalDocument({ ...doc('one'), dirty: true })67+ storage.setItem(`${WRITE_CAPABILITY_PREFIX}keep`, 'credential')68+ const second = docs.saveLocalDocument({ ...doc('two'), localId: 'l_00000002' })69+ assert.ok(first && second)70+ storage.failNext = true71+ const result = docs.saveLocalDocument({ ...doc('three'), localId: 'l_00000003' }, { currentLocalId: second })72+ assert.equal(result, null)73+ assert.ok(docs.loadLocalDocument(first))74+ assert.equal(storage.getItem(`${WRITE_CAPABILITY_PREFIX}keep`), 'credential')75+})
A apps/editor/test/mobile-panel.test.js +22 -0
M apps/editor/test/preview-routing.test.js +77 -0
@@ -244,3 +244,80 @@ test('fits once when a different diagram identity loads', () => {
244244 assert.deepEqual(calls, ['fit'])245245 assert.equal(controller.fitOnNextLoad, false)246246 })247+248+test('keeps the current image until its decoded replacement can be committed', async () => {249+ let finishDecode250+ const revoked = []251+ const originalRevokeObjectUrl = URL.revokeObjectURL252+ URL.revokeObjectURL = url => revoked.push(url)253+ const image = { src: 'blob:current', hidden: false, naturalWidth: 120, naturalHeight: 80 }254+ const minimapImage = { src: 'blob:current' }255+ const backgrounds = []256+ const controller = {257+ imageSwapNumber: 2,258+ objectUrl: 'blob:current',259+ activeImageUrl: 'blob:current',260+ imageFallbackUrl: null,261+ image,262+ minimapImage,263+ fitOnNextLoad: false,264+ stage: {265+ dataset: { previewTheme: 'light' },266+ style: { setProperty(name, value) { backgrounds.push([name, value]) } },267+ },268+ decodeImage() { return new Promise(resolve => { finishDecode = resolve }) },269+ }270+271+ try {272+ const swap = PreviewController.prototype.swapDecodedImage.call(controller, {273+ objectUrl: 'blob:next',274+ fallbackUrl: 'data:next',275+ background: 'var(--preview-dark-bg)',276+ theme: 'dark',277+ }, 2)278+279+ await Promise.resolve()280+ assert.equal(image.src, 'blob:current')281+ assert.equal(minimapImage.src, 'blob:current')282+ assert.deepEqual(revoked, [])283+284+ finishDecode()285+ await swap286+287+ assert.equal(image.src, 'blob:next')288+ assert.equal(minimapImage.src, 'blob:next')289+ assert.equal(controller.stage.dataset.previewTheme, 'dark')290+ assert.deepEqual(backgrounds, [['--render-background', 'var(--preview-dark-bg)']])291+ assert.deepEqual(revoked, ['blob:current'])292+ } finally {293+ URL.revokeObjectURL = originalRevokeObjectUrl294+ }295+})296+297+test('discards a decoded image when a newer render has superseded it', async () => {298+ const revoked = []299+ const originalRevokeObjectUrl = URL.revokeObjectURL300+ URL.revokeObjectURL = url => revoked.push(url)301+ const controller = {302+ imageSwapNumber: 4,303+ objectUrl: 'blob:current',304+ image: { src: 'blob:current' },305+ minimapImage: { src: 'blob:current' },306+ async decodeImage() {},307+ }308+309+ try {310+ await PreviewController.prototype.swapDecodedImage.call(controller, {311+ objectUrl: 'blob:stale',312+ fallbackUrl: 'data:stale',313+ background: '#000000',314+ theme: 'dark',315+ }, 3)316+317+ assert.equal(controller.image.src, 'blob:current')318+ assert.equal(controller.minimapImage.src, 'blob:current')319+ assert.deepEqual(revoked, ['blob:stale'])320+ } finally {321+ URL.revokeObjectURL = originalRevokeObjectUrl322+ }323+})
M apps/editor/test/type-drafts.test.js +14 -27
@@ -2,33 +2,20 @@ import assert from 'node:assert/strict'
22 import test from 'node:test'33 import { stateForTypeChange } from '../src/type-drafts.js'44 5-test('keeps draft source and selector type paired across engine switches', () => {6- const drafts = new Map()7- const example = type => ({ type, source: `${type} example` })5+test('changes only the renderer and its type-specific options', () => {6+ const current = {7+ type: 'ditaa',8+ source: '+---+\n| A |\n+---+',9+ options: { scale: 2 },10+ meta: { title: 'ASCII box', description: 'Keep me' },11+ presentation: { appearance: 'dark-transparent', background: '', padding: 0, frame: false },12+ }813 9- const graphviz = stateForTypeChange(10- drafts,11- 'mermaid',12- 'graphviz',13- { type: 'graphviz', source: 'edited mermaid' },14- example,15- )16- const d2 = stateForTypeChange(17- drafts,18- 'graphviz',19- 'd2',20- { ...graphviz, type: 'd2' },21- example,22- )23- const restoredGraphviz = stateForTypeChange(24- drafts,25- 'd2',26- 'graphviz',27- { ...d2, type: 'graphviz' },28- example,29- )14+ const goat = stateForTypeChange(current, 'goat')3015 31- assert.deepEqual(drafts.get('mermaid'), { type: 'mermaid', source: 'edited mermaid' })32- assert.deepEqual(drafts.get('d2'), { type: 'd2', source: 'd2 example' })33- assert.deepEqual(restoredGraphviz, { type: 'graphviz', source: 'graphviz example' })16+ assert.deepEqual(goat, { ...current, type: 'goat', options: {} })17+ assert.equal(goat.source, current.source)18+ assert.equal(goat.meta, current.meta)19+ assert.equal(goat.presentation, current.presentation)20+ assert.equal(current.type, 'ditaa')3421 })