Add a split primary action that defaults anonymous work to a local editable SVG while preserving explicit Publish for server aliases. Consolidate publishing, encryption, file export, and embed-copy actions in Share. Accept enriched SVGs from files, drops, pasted markup, HTTP(S), and data URLs with bounded reads and fail-closed validation. Preserve file-backed state across reloads and export the exact canonical preview for deterministic round trips. Test URL, Blob, size-limit, and exact-preview routing behavior; verify desktop and mobile flows in the local editor.
- commit
- 3ffc37a5dc85a4fa54c4debaf44c4ddcdacb285d
- parent
- 45de8f98e7cc4b1d3d824a8f2d0b3a85eae1973b
- author
- Dami <dami.etoile@gmail.com>
| M | apps/ | 2 | +- |
| M | apps/ | 1 | - |
| A | apps/ | 109 | ++++++++ |
| M | apps/ | 435 | ++++++++++++++++++++++-------- |
| M | apps/ | 6 | + |
| M | apps/ | 85 | ++++++- |
| A | apps/ | 81 | ++++++ |
| M | apps/ | 9 | + |
@@ -5,7 +5,7 @@
55 <meta name="viewport" content="width=device-width, initial-scale=1.0">66 <meta name="theme-color" content="#ffffff">77 <meta name="description" content="Create diagrams from text and share them as a link.">8- <link rel="icon" href="/icon.svg?v=3" type="image/svg+xml">8+ <link rel="icon" href="/icon.svg?v=4" type="image/svg+xml">99 <title>untitled.zip</title>1010 </head>1111 <body>@@ -0,0 +1,109 @@
1+import { importEditableSvg } from './editable-svg.js'2+3+export const MAX_EDITABLE_SVG_INPUT_BYTES = 5 * 1024 * 10244+5+const encoder = new TextEncoder()6+const SVG_CONTENT_TYPE = /^(?:image\/svg\+xml|application\/(?:xml|svg\+xml)|text\/xml)(?:\s*;|\s*$)/i7+8+export class EditableSvgInputError extends Error {9+ constructor(code, message) {10+ super(message)11+ this.name = 'EditableSvgInputError'12+ this.code = code13+ }14+}15+16+function fail(code, message) {17+ throw new EditableSvgInputError(code, message)18+}19+20+function assertWithinLimit(bytes, label = 'Editable SVG') {21+ if (bytes.byteLength > MAX_EDITABLE_SVG_INPUT_BYTES) {22+ fail('input_too_large', `${label} is larger than 5 MiB.`)23+ }24+ return bytes25+}26+27+function decode(bytes) {28+ return new TextDecoder().decode(assertWithinLimit(bytes))29+}30+31+async function bytesFromResponse(response) {32+ const contentType = response.headers?.get?.('content-type') ?? ''33+ if (contentType && !SVG_CONTENT_TYPE.test(contentType)) {34+ fail('invalid_content_type', 'The URL did not return an SVG or XML document.')35+ }36+37+ if (response.body?.getReader) {38+ const reader = response.body.getReader()39+ const chunks = []40+ let length = 041+ try {42+ while (true) {43+ const { done, value } = await reader.read()44+ if (done) break45+ const chunk = value instanceof Uint8Array ? value : new Uint8Array(value)46+ length += chunk.byteLength47+ if (length > MAX_EDITABLE_SVG_INPUT_BYTES) {48+ await reader.cancel().catch(() => {})49+ fail('input_too_large', 'The downloaded SVG is larger than 5 MiB.')50+ }51+ chunks.push(chunk)52+ }53+ } finally {54+ reader.releaseLock?.()55+ }56+ const bytes = new Uint8Array(length)57+ let offset = 058+ for (const chunk of chunks) {59+ bytes.set(chunk, offset)60+ offset += chunk.byteLength61+ }62+ return bytes63+ }64+65+ if (typeof response.arrayBuffer === 'function') {66+ return assertWithinLimit(new Uint8Array(await response.arrayBuffer()), 'The downloaded SVG')67+ }68+ if (typeof response.text === 'function') return encoder.encode(await response.text())69+ fail('invalid_response', 'The URL response did not contain readable SVG data.')70+}71+72+export function importEditableSvgText(source) {73+ if (typeof source !== 'string') fail('invalid_input', 'Editable SVG input must be text.')74+ assertWithinLimit(encoder.encode(source))75+ return importEditableSvg(source)76+}77+78+export async function importEditableSvgFile(file) {79+ if (!file || typeof file.arrayBuffer !== 'function') {80+ fail('invalid_input', 'Editable SVG input must be a File or Blob.')81+ }82+ if (Number.isFinite(file.size) && file.size > MAX_EDITABLE_SVG_INPUT_BYTES) {83+ fail('input_too_large', 'The selected SVG is larger than 5 MiB.')84+ }85+ return importEditableSvg(decode(new Uint8Array(await file.arrayBuffer())))86+}87+88+export async function importEditableSvgInput(input, { fetchImpl = globalThis.fetch } = {}) {89+ if (typeof input !== 'string') return importEditableSvgFile(input)90+ let url91+ try {92+ url = new URL(input)93+ } catch {94+ return importEditableSvgText(input)95+ }96+ if (!['http:', 'https:', 'data:'].includes(url.protocol)) {97+ fail('invalid_url', 'Editable SVG URLs must use HTTP(S) or data: URLs.')98+ }99+ if (typeof fetchImpl !== 'function') fail('fetch_unavailable', 'URL import is unavailable in this browser.')100+ let response101+ try {102+ response = await fetchImpl(url.toString())103+ } catch (error) {104+ fail('fetch_failed', error instanceof Error ? error.message : 'Could not download the SVG.')105+ }106+ if (!response?.ok) fail('fetch_failed', `The SVG URL returned HTTP ${response?.status ?? 'an error'}.`)107+ return importEditableSvg(decode(await bytesFromResponse(response)))108+}109+@@ -33,6 +33,8 @@ import {
3333 wrapBundleKey,3434 } from './encryption.js'3535 import { PreviewController } from './preview.js'36+import { exportEditableSvg } from './editable-svg.js'37+import { importEditableSvgFile, importEditableSvgInput } from './editable-svg-input.js'3638 import './style.css'3739 3840 const LOCAL_DRAFT_KEY = 'diagram.zip:draft:local:v2'@@ -45,32 +47,18 @@ let renderTimer
4547 let detailsCommitTimer4648 let activeType4749 let saving = false50+let primarySaveAction = null51+let lastFileSnapshot = null4852 let initialLoadError = ''4953 let restoredDetailsSource = null50-let remote = {51- aliasId: null,52- contentId: null,53- renderId: null,54- revision: null,55- mode: 'open',56- savedMode: null,57- writeCapability: null,58- savedState: null,59- savedSnapshot: null,60- bundleKey: null,61- keyEnvelope: null,62- encryptedContent: null,63- encryptedMetadata: null,64- keyEnvelopeDirty: false,65- dirty: true,66-}54+let remote = emptyRemoteState()6755 const typeDrafts = new Map()6856 6957 document.querySelector('#app').innerHTML = `7058 <main class="app-shell">7159 <header class="app-header">7260 <div class="document-identity">73- <a class="brand" href="/" aria-label="New diagram" title="New diagram"><img class="brand-mark" src="/icon.svg?v=3" alt=""></a>61+ <a class="brand" href="/" aria-label="New diagram" title="New diagram"><img class="brand-mark" src="/icon.svg?v=4" alt=""></a>7462 <span class="document-title" id="diagram-title">Untitled</span>7563 </div>7664 <div class="header-meta">@@ -86,25 +74,41 @@ document.querySelector('#app').innerHTML = `
8674 <path d="M12 7.5v12"/>8775 </svg>8876 </a>89- <button class="header-icon-action" id="save" type="button" data-save-state="save" data-dirty="true" aria-label="Save" title="Save">90- <svg class="header-action-icon save-action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">91- <g data-save-icon="save">92- <path d="M5 3.5h11.5L19 6v14.5H5v-17Z"/>93- <path d="M8 3.5V9h8V3.5M8 20.5v-7h8v7"/>94- </g>95- <g data-save-icon="saving">96- <path d="M19.5 8.5A8 8 0 1 0 20 14"/>97- <path d="M19.5 4.5v4h-4"/>98- </g>99- <g data-save-icon="saved">100- <path d="m5.5 12 4 4 9-9"/>101- </g>102- <g data-save-icon="copy">103- <path d="M8 7.5h11v13H8v-13Z"/>104- <path d="M5 16.5H4v-13h11v1M13.5 11v6M10.5 14h6"/>105- </g>77+ <button class="header-icon-action" id="open" type="button" aria-label="Open editable SVG" title="Open editable SVG">78+ <svg class="header-action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">79+ <path d="M3.5 7.5h6l2-2h9v13h-17v-11Z"/>80+ <path d="M8 13h8m-4-4v8"/>10681 </svg>10782 </button>83+ <div class="save-split">84+ <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">85+ <svg class="header-action-icon save-action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">86+ <g data-save-icon="save">87+ <path d="M5 3.5h11.5L19 6v14.5H5v-17Z"/>88+ <path d="M8 3.5V9h8V3.5M8 20.5v-7h8v7"/>89+ </g>90+ <g data-save-icon="saving">91+ <path d="M19.5 8.5A8 8 0 1 0 20 14"/>92+ <path d="M19.5 4.5v4h-4"/>93+ </g>94+ <g data-save-icon="saved">95+ <path d="m5.5 12 4 4 9-9"/>96+ </g>97+ <g data-save-icon="copy">98+ <path d="M8 7.5h11v13H8v-13Z"/>99+ <path d="M5 16.5H4v-13h11v1M13.5 11v6M10.5 14h6"/>100+ </g>101+ </svg>102+ </button>103+ <button class="save-menu-toggle" id="save-menu-toggle" type="button" popovertarget="save-menu" aria-label="Choose save action" title="Choose save action">⌄</button>104+ <div class="action-menu save-action-menu" id="save-menu" popover>105+ <button type="button" data-primary-save="publish">Publish</button>106+ <button type="button" data-save-quick-action="encrypt-publish">Encrypt & Publish</button>107+ <button type="button" data-primary-save="file">Save as File</button>108+ <button type="button" data-save-quick-action="svg-url">Copy SVG URL</button>109+ <button type="button" data-save-quick-action="markdown">Copy as Markdown</button>110+ </div>111+ </div>108112 <button class="header-icon-action" id="share" type="button" aria-label="Share" title="Share">109113 <svg class="header-action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">110114 <circle cx="18" cy="5" r="2.5"/>@@ -118,10 +122,10 @@ document.querySelector('#app').innerHTML = `
118122 </header>119123 120124 <aside class="draft-bar" id="draft-bar" aria-live="polite" hidden>121- <p>This device has changes that are not part of the saved share link.</p>125+ <p>This device has changes that are not part of the published share link.</p>122126 <div>123127 <button class="secondary-action" id="restore-saved" type="button">124- <span>Restore saved</span>128+ <span>Restore published</span>125129 <svg class="draft-reset-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">126130 <path d="M4.5 9V4.5H9"/>127131 <path d="M5.2 7.2A8 8 0 1 1 4.6 16"/>@@ -203,6 +207,13 @@ document.querySelector('#app').innerHTML = `
203207 <button class="icon-button" value="cancel" aria-label="Close">×</button>204208 </div>205209 <p class="share-status" id="share-status"></p>210+ <div class="share-action-list">211+ <button class="primary-action" type="button" data-share-action="publish">Publish</button>212+ <button class="secondary-action" type="button" data-share-action="encrypt-publish">Encrypt & Publish</button>213+ <button class="secondary-action" type="button" data-share-action="file">Save as File</button>214+ <button class="secondary-action" type="button" data-share-action="svg-url">Copy SVG URL</button>215+ <button class="secondary-action" type="button" data-share-action="markdown">Copy as Markdown</button>216+ </div>206217 <fieldset class="privacy-fields share-privacy-fields">207218 <legend>Privacy</legend>208219 <div>@@ -218,11 +229,25 @@ document.querySelector('#app').innerHTML = `
218229 <div class="share-options">219230 <label><span>Read link</span><div><input id="viewer-link" readonly><button type="button" data-copy="viewer-link">Copy</button></div></label>220231 <label><span>Edit link</span><div><input id="editor-link" readonly><button type="button" data-copy="editor-link">Copy</button></div><small id="editor-link-note">Anyone with this link can update the diagram.</small></label>221- <label><span>SVG image</span><div><input id="image-link" readonly><button type="button" data-copy="image-link">Copy</button></div><small id="image-link-note"></small></label>222- <label><span>Markdown</span><div><input id="markdown-link" readonly><button type="button" data-copy="markdown-link">Copy</button></div></label>223232 </div>224- <div class="dialog-actions share-actions">225- <button class="primary-action" id="share-save" type="button">Save</button>233+ </form>234+ </dialog>235+236+ <dialog id="open-dialog" aria-labelledby="open-title">237+ <form class="dialog-card" id="open-form">238+ <div class="dialog-header">239+ <h1 id="open-title">Open editable SVG</h1>240+ <button class="icon-button" id="open-cancel" type="button" aria-label="Close">×</button>241+ </div>242+ <p class="dialog-copy">Choose a Diagram.zip SVG file, or paste its SVG markup or URL. Other SVG images are not treated as source.</p>243+ <input id="open-file-input" type="file" accept=".svg,image/svg+xml" hidden>244+ <button class="secondary-action open-file-action" id="open-file" type="button">Choose SVG File</button>245+ <div class="details-fields open-input-fields">246+ <label><span>SVG markup or URL</span><textarea id="open-svg-input" inputmode="url" placeholder="<svg …> or https://example.com/diagram.svg"></textarea></label>247+ </div>248+ <p class="form-error" id="open-error" role="alert"></p>249+ <div class="dialog-actions">250+ <button class="primary-action" id="open-submit" type="submit">Open</button>226251 </div>227252 </form>228253 </dialog>@@ -230,13 +255,13 @@ document.querySelector('#app').innerHTML = `
230255 <dialog id="conflict-dialog" aria-labelledby="conflict-title">231256 <form method="dialog" class="dialog-card">232257 <div class="dialog-header">233- <h1 id="conflict-title">Save conflict</h1>258+ <h1 id="conflict-title">Publish conflict</h1>234259 <button class="icon-button" value="cancel" aria-label="Close">×</button>235260 </div>236- <p class="dialog-copy">Reload the saved version, or keep your work by saving it as a new diagram.</p>261+ <p class="dialog-copy">Reload the published version, or keep your work by publishing it as a new diagram.</p>237262 <div class="dialog-actions conflict-actions">238- <button class="secondary-action" value="reload">Reload saved</button>239- <button class="primary-action" value="copy">Save as new</button>263+ <button class="secondary-action" value="reload">Reload published</button>264+ <button class="primary-action" value="copy">Publish as new</button>240265 </div>241266 </form>242267 </dialog>@@ -295,7 +320,7 @@ const sourceEditor = await createSourceEditor({
295320 source: initialState.source,296321 diagramType: initialState.type,297322 onChange: scheduleUpdate,298- onSave: saveDiagram,323+ onSave: performPrimarySaveAction,299324 })300325 301326 detailsEditor = await createSourceEditor({@@ -306,7 +331,7 @@ detailsEditor = await createSourceEditor({
306331 modelUri: DETAILS_MODEL_URI,307332 ariaLabel: 'Diagram details editor',308333 onChange: scheduleDetailsUpdate,309- onSave: saveDiagram,334+ onSave: performPrimarySaveAction,310335 })311336 validateDetailsDocument(false)312337 applySystemTheme()@@ -343,14 +368,32 @@ typePicker.addEventListener('change', () => {
343368 document.querySelector('#lock-diagram').addEventListener('click', lockDiagram)344369 document.querySelector('#change-password').addEventListener('click', changeDiagramPassword)345370 document.querySelector('#unlock-diagram').addEventListener('click', unlockDiagram)346-document.querySelector('#save').addEventListener('click', () => saveDiagram())371+document.querySelector('#open').addEventListener('click', openEditableSvgDialog)372+document.querySelector('#save').addEventListener('click', performPrimarySaveAction)347373 document.querySelector('#restore-saved').addEventListener('click', restoreSavedDiagram)348374 document.querySelector('#make-copy').addEventListener('click', makeDraftCopy)349375 document.querySelector('#share').addEventListener('click', openShareDialog)350-document.querySelector('#share-save').addEventListener('click', async () => {351- await saveDiagram()352- populateShareDialog()376+document.querySelectorAll('[data-primary-save]').forEach(button => {377+ button.addEventListener('click', () => {378+ primarySaveAction = button.dataset.primarySave379+ document.querySelector('#save-menu').hidePopover?.()380+ updateSaveButton()381+ })382+})383+document.querySelectorAll('[data-save-quick-action]').forEach(button => {384+ button.addEventListener('click', async () => {385+ document.querySelector('#save-menu').hidePopover?.()386+ await performShareAction(button.dataset.saveQuickAction)387+ updateSaveButton()388+ })353389 })390+document.querySelectorAll('[data-share-action]').forEach(button => {391+ button.addEventListener('click', () => performShareAction(button.dataset.shareAction))392+})393+document.querySelector('#open-form').addEventListener('submit', submitEditableSvgInput)394+document.querySelector('#open-cancel').addEventListener('click', () => document.querySelector('#open-dialog').close())395+document.querySelector('#open-file').addEventListener('click', () => document.querySelector('#open-file-input').click())396+document.querySelector('#open-file-input').addEventListener('change', openSelectedEditableSvgFile)354397 document.querySelector('.brand').addEventListener('click', event => {355398 event.preventDefault()356399 if (remote.dirty && !confirm('Discard these local changes and start a new diagram?')) return@@ -418,12 +461,21 @@ document.querySelectorAll('[data-copy]').forEach(button => {
418461 })419462 420463 window.addEventListener('popstate', () => location.reload())464+window.addEventListener('dragover', event => {465+ if ([...event.dataTransfer?.items ?? []].some(item => item.kind === 'file')) event.preventDefault()466+})467+window.addEventListener('drop', event => {468+ const file = event.dataTransfer?.files?.[0]469+ if (!file) return470+ event.preventDefault()471+ openEditableSvgFile(file)472+})421473 422474 window.addEventListener('keydown', event => {423475 if (event.defaultPrevented) return424476 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {425477 event.preventDefault()426- saveDiagram()478+ performPrimarySaveAction()427479 }428480 })429481 @@ -671,7 +723,7 @@ async function loadInitialState() {
671723 } catch (error) {672724 initialLoadError = error instanceof PersistenceError673725 ? error.message674- : 'This saved diagram could not be loaded.'726+ : 'This published diagram could not be loaded.'675727 }676728 }677729 if (location.hash) history.replaceState(null, '', `${location.pathname}${location.search}`)@@ -679,6 +731,7 @@ async function loadInitialState() {
679731 const draft = loadDraft(LOCAL_DRAFT_KEY)680732 if (requestedType) {681733 if (draft?.state.type === requestedType) {734+ lastFileSnapshot = draft.fileSnapshot682735 restoredDetailsSource = draft.detailsSource683736 const state = refreshMatchingExampleMetadata(draft.state, exampleStateFor(requestedType))684737 if (state !== draft.state) storeDraft(state)@@ -687,6 +740,7 @@ async function loadInitialState() {
687740 return exampleStateFor(requestedType)688741 }689742 if (draft) {743+ lastFileSnapshot = draft.fileSnapshot690744 restoredDetailsSource = draft.detailsSource691745 return draft.state692746 }@@ -707,6 +761,115 @@ function applyState(state, commit = true) {
707761 else preview.render(currentState())708762 }709763 764+function selectedSaveAction() {765+ return primarySaveAction ?? (remote.aliasId ? 'publish' : 'file')766+}767+768+async function performPrimarySaveAction() {769+ return selectedSaveAction() === 'publish' ? saveDiagram() : saveEditableFile()770+}771+772+async function saveEditableFile() {773+ if (saving || !ensureValidDetails()) return false774+ if (remote.mode === 'locked' && !confirm('Save a decrypted editable SVG? The file will contain the diagram source without password protection.')) {775+ return false776+ }777+ commitState(false)778+ const state = currentState()779+ saving = true780+ updateSaveButton()781+ try {782+ await preview.render(state)783+ const canonical = preview.canonicalSvgFor(state)784+ if (!canonical) throw new Error('The current diagram has not finished rendering.')785+ const source = exportEditableSvg(canonical, state)786+ downloadEditableSvg(source, editableSvgFilename(state))787+ lastFileSnapshot = workingStateSnapshot(state)788+ if (!remote.aliasId) storeDraft(state)789+ showToast('Saved editable SVG')790+ return true791+ } catch (error) {792+ showToast(error instanceof Error ? error.message : 'Could not save this editable SVG.', 4000)793+ return false794+ } finally {795+ saving = false796+ updateSaveButton()797+ }798+}799+800+function editableSvgFilename(state) {801+ const requested = state.meta.title.trim() || `${state.type}-diagram`802+ const basename = requested803+ .replace(/[\u0000-\u001f<>:"/\\|?*]+/g, ' ')804+ .replace(/\s+/g, ' ')805+ .trim()806+ .slice(0, 120) || 'diagram'807+ return `${basename}.diagram.svg`808+}809+810+function downloadEditableSvg(source, filename) {811+ const url = URL.createObjectURL(new Blob([source], { type: 'image/svg+xml' }))812+ const link = document.createElement('a')813+ link.href = url814+ link.download = filename815+ link.hidden = true816+ document.body.append(link)817+ link.click()818+ link.remove()819+ setTimeout(() => URL.revokeObjectURL(url), 0)820+}821+822+function openEditableSvgDialog() {823+ const dialog = document.querySelector('#open-dialog')824+ document.querySelector('#open-svg-input').value = ''825+ document.querySelector('#open-file-input').value = ''826+ document.querySelector('#open-error').textContent = ''827+ dialog.showModal()828+}829+830+async function submitEditableSvgInput(event) {831+ event.preventDefault()832+ const input = document.querySelector('#open-svg-input').value.trim()833+ if (!input) {834+ document.querySelector('#open-error').textContent = 'Paste SVG markup or a URL, or choose a file.'835+ return836+ }837+ await openEditableSvgValue(() => importEditableSvgInput(input))838+}839+840+async function openSelectedEditableSvgFile(event) {841+ const file = event.target.files?.[0]842+ if (file) await openEditableSvgFile(file)843+}844+845+async function openEditableSvgFile(file) {846+ await openEditableSvgValue(() => importEditableSvgFile(file))847+}848+849+async function openEditableSvgValue(load) {850+ const error = document.querySelector('#open-error')851+ error.textContent = ''852+ try {853+ const state = await load()854+ if (remote.dirty && !confirm('Replace the current working draft with this editable SVG? Unsaved changes will be discarded.')) return855+ clearTimeout(renderTimer)856+ clearTimeout(detailsCommitTimer)857+ primarySaveAction = null858+ lastFileSnapshot = workingStateSnapshot(state)859+ remote = emptyRemoteState()860+ typeDrafts.clear()861+ history.pushState(null, '', urlWithDiagramType('/', state.type))862+ applyState(state)863+ const dialog = document.querySelector('#open-dialog')864+ if (dialog.open) dialog.close()865+ showToast('Opened editable SVG')866+ } catch (caught) {867+ const message = caught instanceof Error ? caught.message : 'Could not open this editable SVG.'868+ error.textContent = message869+ if (!document.querySelector('#open-dialog').open) showToast(message, 4000)870+ }871+}872+710873 function openShareDialog() {711874 if (!ensureValidDetails()) return712875 commitState()@@ -724,41 +887,25 @@ function populateShareDialog() {
724887 const savedState = savedLocked ? null : remote.savedState725888 const savedAppearance = savedState ? normalizePresentation(savedState.presentation).appearance : 'raw'726889 const svgLink = savedState ? stableRenderUrl(location.origin, remote.aliasId, 'svg', savedAppearance) : ''727- const imageLink = document.querySelector('#image-link')728- const markdownLink = document.querySelector('#markdown-link')729- const markdownTitle = savedState?.meta.title.trim().replace(/[\[\]\r\n]/g, ' ') || 'Diagram'730-731- setShareField('viewer-link', viewerLink, 'Save to create a read link')732- setShareField('editor-link', editorLink, hasAlias ? 'No write capability on this device' : 'Save to create an edit link')733- const embedPlaceholder = savedLocked734- ? 'Encrypted diagrams cannot be embedded'735- : hasAlias ? 'Unavailable for this diagram' : 'Save to create an image link'736- setShareField('image-link', svgLink, embedPlaceholder)737- setShareField('markdown-link', !svgLink ? '' : ``, savedLocked ? 'Encrypted diagrams cannot be embedded' : hasAlias ? 'Unavailable for this diagram' : 'Save to create Markdown')890+891+ setShareField('viewer-link', viewerLink, 'Publish to create a read link')892+ setShareField('editor-link', editorLink, hasAlias ? 'No write capability on this device' : 'Publish to create an edit link')738893 739894 document.querySelector('#editor-link-note').textContent = editorLink740895 ? 'Anyone with this link can update the diagram.'741896 : hasAlias742- ? 'This device has read-only access. Save a copy to create a new edit link.'743- : 'The edit link is a bearer credential and appears after saving.'744- document.querySelector('#image-link-note').textContent = savedLocked745- ? 'This diagram is encrypted in your browser. Image embeds are disabled because they cannot ask for its password.'746- : ''747- imageLink.dataset.state = savedLocked ? 'unavailable' : 'ready'748- markdownLink.dataset.state = savedLocked ? 'unavailable' : 'ready'897+ ? 'This device has read-only access. Publish a copy to create a new edit link.'898+ : 'The edit link is a bearer credential and appears after publishing.'899+ document.querySelector('[data-share-action="svg-url"]').disabled = !svgLink900+ document.querySelector('[data-share-action="markdown"]').disabled = !svgLink749901 750902 const shareStatus = document.querySelector('#share-status')751- const shareSave = document.querySelector('#share-save')752- shareSave.hidden = hasAlias && !remote.dirty753903 if (!hasAlias) {754- shareStatus.textContent = 'Save this diagram to create stable share links.'755- shareSave.textContent = 'Save diagram'904+ shareStatus.textContent = 'Keep this draft in a file, or publish it when you want stable share links.'756905 } else if (remote.dirty && remote.writeCapability) {757- shareStatus.textContent = 'These links show the last saved version. Save your changes before sharing.'758- shareSave.textContent = 'Save changes'906+ shareStatus.textContent = 'These links show the last published version. Publish your changes before sharing.'759907 } else if (remote.dirty) {760- shareStatus.textContent = 'You are editing a read-only diagram. Save a copy to share your changes.'761- shareSave.textContent = 'Save as new'908+ shareStatus.textContent = 'You are editing a read-only diagram. Publish to create a new alias for your changes.'762909 } else if (savedLocked) {763910 shareStatus.textContent = remote.writeCapability764911 ? 'The read link asks for the password. The edit link also grants permission to save changes.'@@ -770,6 +917,39 @@ function populateShareDialog() {
770917 }771918 }772919 920+async function performShareAction(action) {921+ let completed = false922+ if (action === 'publish') completed = await saveDiagram()923+ if (action === 'encrypt-publish') completed = await encryptAndPublish()924+ if (action === 'file') completed = await saveEditableFile()925+ if (action === 'svg-url' || action === 'markdown') {926+ const value = publishedEmbedValue(action)927+ if (!value) return928+ try {929+ await copy(value)930+ showToast(action === 'svg-url' ? 'SVG URL copied' : 'Markdown copied')931+ completed = true932+ } catch {933+ showToast('Could not copy to the clipboard.', 4000)934+ }935+ }936+ if (completed) populateShareDialog()937+}938+939+function publishedEmbedValue(action) {940+ if (!remote.aliasId || remote.savedMode === 'locked' || !remote.savedState) return ''941+ const appearance = normalizePresentation(remote.savedState.presentation).appearance942+ const svgLink = stableRenderUrl(location.origin, remote.aliasId, 'svg', appearance)943+ if (action === 'svg-url') return svgLink944+ const title = remote.savedState.meta.title.trim().replace(/[\[\]\r\n]/g, ' ') || 'Diagram'945+ return ``946+}947+948+async function encryptAndPublish() {949+ if (remote.mode !== 'locked' && !(await lockDiagram())) return false950+ return saveDiagram()951+}952+773953 function setShareField(id, value, placeholder) {774954 const input = document.querySelector(`#${id}`)775955 const button = document.querySelector(`[data-copy="${id}"]`)@@ -784,7 +964,7 @@ async function saveDiagram() {
784964 commitState(false)785965 const state = currentState()786966 if (remote.aliasId && !remote.dirty) {787- showToast('Already saved')967+ showToast('Already published')788968 return true789969 }790970 @@ -808,18 +988,18 @@ async function saveDiagram() {
808988 setRemoteAlias(alias, remote.writeCapability, state, remote.mode === 'locked' ? remote.bundleKey : null)809989 removeDraft(`${ALIAS_DRAFT_PREFIX}${remote.aliasId}`)810990 await cacheSavedRenders(state)811- showToast('Saved')991+ showToast('Published changes')812992 return true813993 }814994 const savedAsCopy = Boolean(remote.aliasId)815995 await createNewAlias(state)816- showToast(savedAsCopy ? 'Saved as a new diagram' : 'Saved')996+ showToast(savedAsCopy ? 'Published as a new diagram' : 'Published')817997 return true818998 } catch (error) {819999 if (error instanceof PersistenceError && error.status === 412 && remote.aliasId) {8201000 return await resolveConflict(state)8211001 }822- showToast(error instanceof Error ? error.message : 'Could not save this diagram.')1002+ showToast(error instanceof Error ? error.message : 'Could not publish this diagram.')8231003 return false8241004 } finally {8251005 saving = false@@ -891,7 +1071,7 @@ async function cacheSavedRenders(state) {
8911071 })8921072 const results = await Promise.allSettled(uploads)8931073 if (results.every(result => result.status === 'rejected')) {894- showToast('Saved, but the render cache is unavailable.', 4000)1074+ showToast('Published, but the render cache is unavailable.', 4000)8951075 }8961076 }8971077 @@ -920,14 +1100,14 @@ async function svgToPngBlob(svg) {
9201100 }9211101 9221102 async function lockDiagram() {923- if (!ensureValidDetails()) return1103+ if (!ensureValidDetails()) return false9241104 const password = await askPassword({9251105 title: 'Lock diagram',926- copy: 'The source, metadata, and saved render will be encrypted in this browser. The password cannot be recovered.',1106+ copy: 'The source, metadata, and published render will be encrypted in this browser. The password cannot be recovered.',9271107 submitLabel: 'Lock diagram',9281108 confirm: true,9291109 })930- if (password === null) return1110+ if (password === null) return false9311111 try {9321112 const { bundleKey, payload } = await createLockedState(currentState(), password)9331113 remote.mode = 'locked'@@ -938,9 +1118,11 @@ async function lockDiagram() {
9381118 remote.keyEnvelopeDirty = true9391119 removeDraft(remote.aliasId ? `${ALIAS_DRAFT_PREFIX}${remote.aliasId}` : LOCAL_DRAFT_KEY)9401120 commitState(false)941- showToast('Locked in this browser — save to persist')1121+ showToast('Encrypted in this browser — publish to persist')1122+ return true9421123 } catch (error) {9431124 showToast(error instanceof Error ? error.message : 'Could not lock this diagram.')1125+ return false9441126 }9451127 }9461128 @@ -957,7 +1139,7 @@ async function changeDiagramPassword() {
9571139 remote.keyEnvelope = await wrapBundleKey(remote.bundleKey, password)9581140 remote.keyEnvelopeDirty = true9591141 commitState(false)960- showToast('Password changed — save to persist')1142+ showToast('Password changed — publish to persist')9611143 } catch (error) {9621144 showToast(error instanceof Error ? error.message : 'Could not change the password.')9631145 }@@ -965,11 +1147,11 @@ async function changeDiagramPassword() {
9651147 9661148 function unlockDiagram() {9671149 if (remote.mode !== 'locked') return968- if (!confirm('Remove password protection? The next save will store the diagram in plaintext.')) return1150+ if (!confirm('Remove password protection? The next publish will store the diagram in plaintext.')) return9691151 remote.mode = 'open'9701152 remote.keyEnvelopeDirty = false9711153 commitState(false)972- showToast('Password removed — save to persist')1154+ showToast('Password removed — publish to persist')9731155 }9741156 9751157 async function promptToUnlock(alias) {@@ -1066,7 +1248,7 @@ async function resolveConflict(localState) {
10661248 removeDraft(`${ALIAS_DRAFT_PREFIX}${aliasId}`)10671249 applyState(state, false)10681250 updateSaveButton()1069- showToast('Reloaded saved diagram')1251+ showToast('Reloaded published diagram')10701252 return true10711253 } catch (error) {10721254 showToast(error instanceof Error ? error.message : 'Could not reload this diagram.')@@ -1116,6 +1298,7 @@ function loadDraft(key) {
11161298 state,11171299 revision: draft.revision ?? null,11181300 detailsSource: typeof draft.detailsSource === 'string' ? draft.detailsSource : null,1301+ fileSnapshot: typeof draft.fileSnapshot === 'string' ? draft.fileSnapshot : null,11191302 }11201303 } catch {11211304 return null@@ -1129,7 +1312,12 @@ function storeDraft(state) {
11291312 const detailsSource = !detailsValid && detailsEditor11301313 ? detailsEditor.getValue()11311314 : null1132- localStorage.setItem(key, JSON.stringify({ state, revision: remote.revision, detailsSource }))1315+ localStorage.setItem(key, JSON.stringify({1316+ state,1317+ revision: remote.revision,1318+ detailsSource,1319+ fileSnapshot: remote.aliasId ? null : lastFileSnapshot,1320+ }))11331321 } catch {11341322 // The editor remains usable when storage is unavailable.11351323 }@@ -1191,26 +1379,44 @@ function setRemoteAlias(alias, writeCapability, state, bundleKey = null) {
11911379 function updateSaveButton() {11921380 const button = document.querySelector('#save')11931381 updateDraftControls()1382+ const action = selectedSaveAction()1383+ const fileDirty = action === 'file'1384+ && (lastFileSnapshot === null || lastFileSnapshot !== workingStateSnapshot(currentState()))11941385 let state = 'save'1195- let label = 'Save'1386+ let label = action === 'file' ? 'Save as File' : 'Publish'11961387 let disabled = false11971388 if (saving) {11981389 state = 'saving'1199- label = 'Saving'1390+ label = action === 'file' ? 'Saving File' : 'Publishing'1391+ disabled = true1392+ } else if (action === 'file' && !fileDirty) {1393+ state = 'saved'1394+ label = 'Saved to File'12001395 disabled = true1201- } else if (remote.aliasId && !remote.dirty) {1396+ } else if (action === 'publish' && remote.aliasId && !remote.dirty) {12021397 state = 'saved'1203- label = 'Saved'1398+ label = 'Published'12041399 disabled = true1205- } else if (remote.aliasId && !remote.writeCapability) {1400+ } else if (action === 'publish' && remote.aliasId && !remote.writeCapability) {12061401 state = 'copy'1207- label = 'Save a copy'1402+ label = 'Publish a Copy'12081403 }12091404 button.dataset.saveState = state1210- button.dataset.dirty = String(remote.dirty)1405+ button.dataset.saveAction = action1406+ button.dataset.dirty = String(action === 'file' ? fileDirty : remote.dirty)12111407 button.setAttribute('aria-label', label)12121408 button.title = label12131409 button.disabled = disabled1410+ document.querySelector('#save-menu-toggle').disabled = saving1411+ document.querySelectorAll('[data-primary-save]').forEach(option => {1412+ const selected = option.dataset.primarySave === action1413+ option.dataset.selected = String(selected)1414+ option.setAttribute('aria-pressed', String(selected))1415+ })1416+ const publishedEmbedAvailable = Boolean(remote.aliasId && remote.savedMode !== 'locked' && remote.savedState)1417+ document.querySelectorAll('[data-save-quick-action="svg-url"], [data-save-quick-action="markdown"]').forEach(option => {1418+ option.disabled = !publishedEmbedAvailable1419+ })12141420 }12151421 12161422 function updateDraftControls() {@@ -1218,7 +1424,7 @@ function updateDraftControls() {
12181424 const restore = document.querySelector('#restore-saved')12191425 const makeCopy = document.querySelector('#make-copy')12201426 const hasAliasOverlay = Boolean(remote.aliasId && remote.savedState && remote.dirty)1221- const hasAnonymousDraft = Boolean(!remote.aliasId && remote.dirty)1427+ const hasAnonymousDraft = Boolean(!remote.aliasId && remote.dirty && lastFileSnapshot === null)12221428 bar.hidden = !hasAliasOverlay && !hasAnonymousDraft12231429 restore.disabled = saving12241430 makeCopy.disabled = saving@@ -1231,8 +1437,8 @@ function updateDraftControls() {
12311437 restore.setAttribute('aria-label', 'Reset')12321438 restore.title = 'Reset'12331439 } else {1234- bar.querySelector('p').textContent = 'This device has changes that are not part of the saved share link.'1235- restore.querySelector('span').textContent = 'Restore saved'1440+ bar.querySelector('p').textContent = 'This device has changes that are not part of the published share link.'1441+ restore.querySelector('span').textContent = 'Restore published'12361442 restore.removeAttribute('aria-label')12371443 restore.removeAttribute('title')12381444 }@@ -1255,7 +1461,7 @@ function restoreSavedDiagram() {
12551461 history.replaceState(null, '', urlWithDiagramType(location.href, remote.savedState.type))12561462 updatePrivacyControls()12571463 updateSaveButton()1258- showToast('Restored saved diagram')1464+ showToast('Restored published diagram')12591465 }12601466 12611467 function resetAnonymousExample() {@@ -1282,7 +1488,7 @@ async function makeDraftCopy() {
12821488 updateSaveButton()12831489 try {12841490 await createNewAlias(state)1285- showToast('Saved as a new diagram')1491+ showToast('Published as a new diagram')12861492 } catch (error) {12871493 showToast(error instanceof Error ? error.message : 'Could not make a copy.')12881494 } finally {@@ -1295,7 +1501,7 @@ function updatePrivacyControls() {
12951501 const locked = remote.mode === 'locked'12961502 document.querySelector('#privacy-label').textContent = locked ? 'Password locked' : 'Open'12971503 document.querySelector('#privacy-description').textContent = locked1298- ? 'Source, metadata, and saved renders are encrypted in your browser. Password-locked diagrams cannot be embedded.'1504+ ? 'Source, metadata, and published renders are encrypted in your browser. Password-locked diagrams cannot be embedded.'12991505 : 'Anyone with the read link can open and embed this diagram.'13001506 document.querySelector('#lock-diagram').hidden = locked13011507 document.querySelector('#change-password').hidden = !locked@@ -1304,7 +1510,16 @@ function updatePrivacyControls() {
13041510 13051511 function startNewDiagram() {13061512 if (!remote.aliasId) removeDraft(LOCAL_DRAFT_KEY)1307- remote = {1513+ remote = emptyRemoteState({ dirty: false })1514+ primarySaveAction = null1515+ lastFileSnapshot = null1516+ typeDrafts.clear()1517+ history.pushState(null, '', urlWithDiagramType('/', DEFAULT_DIAGRAM_TYPE))1518+ applyState(exampleStateFor(DEFAULT_DIAGRAM_TYPE))1519+}1520+1521+function emptyRemoteState(overrides = {}) {1522+ return {13081523 aliasId: null,13091524 contentId: null,13101525 renderId: null,@@ -1319,11 +1534,9 @@ function startNewDiagram() {
13191534 encryptedContent: null,13201535 encryptedMetadata: null,13211536 keyEnvelopeDirty: false,1322- dirty: false,1537+ dirty: true,1538+ ...overrides,13231539 }1324- typeDrafts.clear()1325- history.pushState(null, '', urlWithDiagramType('/', DEFAULT_DIAGRAM_TYPE))1326- applyState(exampleStateFor(DEFAULT_DIAGRAM_TYPE))13271540 }13281541 13291542 function updateDocumentMetadata() {@@ -287,6 +287,12 @@ export class PreviewController {
287287 return this.latestRenderKey === renderKey ? this.latestSvgBlob : null288288 }289289 290+ canonicalSvgFor(state) {291+ const { type, source, options = {}, meta = {}, presentation = {} } = state292+ const renderKey = JSON.stringify({ type, source, options, meta, presentation })293+ return this.latestRenderKey === renderKey ? this.latestCanonicalSvg : null294+ }295+290296 rendererIdentityFor(state) {291297 const { type, source, options = {}, meta = {}, presentation = {} } = state292298 const renderKey = JSON.stringify({ type, source, options, meta, presentation })@@ -222,6 +222,7 @@ button { cursor: pointer; }
222222 .primary-action,223223 .secondary-action,224224 .header-icon-action,225+.save-menu-toggle,225226 .preview-toolbar button,226227 .mobile-tabs button,227228 .icon-button,@@ -264,6 +265,59 @@ button { cursor: pointer; }
264265 gap: 6px;265266 }266267 268+.save-split {269+ display: flex;270+ align-items: stretch;271+}272+273+.save-split #save {274+ border-radius: 10px 0 0 10px;275+}276+277+.save-menu-toggle {278+ width: 20px;279+ min-width: 20px;280+ height: 40px;281+ min-height: 40px;282+ padding: 0;283+ border-left: 0;284+ border-radius: 0 10px 10px 0;285+ font-size: 14px;286+ line-height: 1;287+}288+289+.save-menu-toggle:hover { background: var(--soft); }290+.save-menu-toggle:disabled { cursor: default; opacity: .58; }291+292+.action-menu {293+ position: fixed;294+ inset: 58px 58px auto auto;295+ min-width: 178px;296+ margin: 0;297+ padding: 6px;298+ color: var(--ink);299+ border: 1px solid var(--line);300+ border-radius: 11px;301+ background: var(--surface-raised);302+ box-shadow: var(--shadow);303+}304+305+.action-menu::backdrop { background: transparent; }306+.action-menu button {307+ display: block;308+ width: 100%;309+ min-height: 38px;310+ padding: 7px 10px;311+ border: 0;312+ border-radius: 7px;313+ background: transparent;314+ color: var(--ink);315+ text-align: left;316+}317+.action-menu button:hover,318+.action-menu button[data-selected="true"] { background: var(--soft); }319+.action-menu button[data-selected="true"]::after { content: "✓"; float: right; }320+267321 .header-icon-action {268322 display: inline-grid;269323 width: 40px;@@ -619,6 +673,15 @@ dialog::backdrop {
619673 }620674 621675 .share-options { display: grid; gap: 18px; }676+.share-action-list {677+ display: grid;678+ grid-template-columns: repeat(2, minmax(0, 1fr));679+ gap: 8px;680+ margin-bottom: 20px;681+}682+.share-action-list button { min-width: 0; }683+.share-action-list button:disabled { cursor: not-allowed; opacity: .42; }684+.share-action-list button:last-child { grid-column: 1 / -1; }622685 .share-status,623686 .dialog-copy {624687 margin: -8px 0 20px;@@ -655,6 +718,8 @@ dialog::backdrop {
655718 }656719 .details-fields input { height: 42px; }657720 .details-fields textarea { min-height: 108px; line-height: 1.5; }721+.open-file-action { width: 100%; margin-bottom: 18px; }722+.open-input-fields textarea { min-height: 150px; font: 12px/1.5 var(--font-mono); }658723 659724 .privacy-fields {660725 display: flex;@@ -722,6 +787,9 @@ dialog::backdrop {
722787 .header-actions { gap: 4px; }723788 .header-icon-action { width: 36px; min-width: 36px; height: 36px; }724789 .header-action-icon { width: 18px; height: 18px; }790+ .header-actions > a { display: none; }791+ .save-menu-toggle { height: 36px; min-height: 36px; }792+ .action-menu { inset: 58px 48px auto auto; }725793 .draft-bar { align-items: stretch; flex-direction: column; gap: 7px; padding: 9px 14px; }726794 .draft-bar div { display: grid; grid-template-columns: 1fr 1fr; }727795 .draft-bar[data-anonymous="true"] div { display: flex; justify-content: flex-end; }@@ -760,17 +828,26 @@ dialog::backdrop {
760828 .minimap { width: 132px; height: 88px; }761829 .privacy-fields { align-items: stretch; flex-direction: column; }762830 .privacy-actions { justify-content: flex-start; }831+ .share-action-list { grid-template-columns: 1fr; }832+ .share-action-list button:last-child { grid-column: auto; }763833 }764834 765-@media (max-width: 390px) {835+@media (max-width: 480px) {766836 .app-header { gap: 8px; }837+ .document-identity { flex: 0 0 auto; }838+ .document-title { display: none; }839+ .header-meta { flex: 1 1 auto; min-width: 0; }840+ .type-picker { flex: 1 1 auto; min-width: 0; overflow: hidden; }841+ .type-picker select { display: block; width: 100%; min-width: 0; max-width: none; }842+ .header-actions { flex: 0 0 auto; position: relative; }843+}844+845+@media (max-width: 390px) {767846 .type-picker { flex-basis: 108px; }768- .type-picker select { max-width: 108px; }769847 .type-picker { margin: 0 5px; }770- .document-identity { flex-basis: 76px; }771- .document-title { min-width: 40px; padding-inline: 4px; font-size: 16px; }772848 .header-actions { gap: 3px; }773849 .header-icon-action { width: 34px; min-width: 34px; height: 34px; }850+ .save-menu-toggle { width: 18px; min-width: 18px; height: 34px; min-height: 34px; }774851 .primary-action, .secondary-action { padding-inline: 9px; }775852 .preview-toolbar { bottom: 10px; left: 10px; }776853 .minimap { right: 10px; bottom: 62px; }@@ -0,0 +1,81 @@
1+import assert from 'node:assert/strict'2+import test from 'node:test'3+import { canonicalizeSvg } from '../../../shared/svg/index.js'4+import { exportEditableSvg } from '../src/editable-svg.js'5+import {6+ EditableSvgInputError,7+ MAX_EDITABLE_SVG_INPUT_BYTES,8+ importEditableSvgFile,9+ importEditableSvgInput,10+ importEditableSvgText,11+} from '../src/editable-svg-input.js'12+13+const state = {14+ type: 'pikchr',15+ source: 'box "input"',16+ options: {},17+ meta: { title: 'Input', description: '' },18+ presentation: { background: '', padding: 0, frame: false, appearance: 'raw' },19+}20+const canonical = canonicalizeSvg(21+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><rect x="1" y="1" width="18" height="18" fill="white"/></svg>',22+ state.meta,23+ 'pikchr',24+ 'pikchr@85e65b9686/edge-wasm-1',25+)26+const editable = exportEditableSvg(canonical, state)27+28+test('imports pasted SVG text through the editable-document importer', () => {29+ assert.deepEqual(importEditableSvgText(editable), state)30+})31+32+test('imports HTTP, HTTPS, and data URLs with an injected fetch', async () => {33+ const calls = []34+ const fetchImpl = async url => {35+ calls.push(url)36+ return new Response(editable, { headers: { 'Content-Type': 'image/svg+xml; charset=utf-8' } })37+ }38+ assert.deepEqual(await importEditableSvgInput('https://example.test/diagram.svg', { fetchImpl }), state)39+ assert.deepEqual(await importEditableSvgInput('data:image/svg+xml,fixture', { fetchImpl }), state)40+ assert.deepEqual(calls, ['https://example.test/diagram.svg', 'data:image/svg+xml,fixture'])41+})42+43+test('imports File and Blob inputs and enforces the shared 5 MiB cap', async () => {44+ assert.deepEqual(await importEditableSvgFile(new Blob([editable], { type: 'image/svg+xml' })), state)45+ await assert.rejects(46+ () => importEditableSvgFile({ size: MAX_EDITABLE_SVG_INPUT_BYTES + 1, arrayBuffer: async () => new ArrayBuffer(0) }),47+ error => error instanceof EditableSvgInputError && error.code === 'input_too_large',48+ )49+})50+51+test('rejects unsuccessful responses and non-SVG content types', async () => {52+ await assert.rejects(53+ () => importEditableSvgInput('https://example.test/missing.svg', {54+ fetchImpl: async () => new Response('', { status: 404 }),55+ }),56+ error => error.code === 'fetch_failed',57+ )58+ await assert.rejects(59+ () => importEditableSvgInput('https://example.test/page', {60+ fetchImpl: async () => new Response(editable, { headers: { 'Content-Type': 'text/html' } }),61+ }),62+ error => error.code === 'invalid_content_type',63+ )64+})65+66+test('rejects streamed responses that exceed 5 MiB before importing', async () => {67+ const stream = new ReadableStream({68+ start(controller) {69+ controller.enqueue(new Uint8Array(MAX_EDITABLE_SVG_INPUT_BYTES))70+ controller.enqueue(new Uint8Array(1))71+ controller.close()72+ },73+ })74+ await assert.rejects(75+ () => importEditableSvgInput('https://example.test/large.svg', {76+ fetchImpl: async () => new Response(stream, { headers: { 'Content-Type': 'image/svg+xml' } }),77+ }),78+ error => error.code === 'input_too_large',79+ )80+})81+@@ -192,6 +192,15 @@ test('keeps one viewport identity across presentation-only changes', () => {
192192 )193193 })194194 195+test('returns canonical SVG only for the exact current editable state', () => {196+ const controller = {197+ latestRenderKey: JSON.stringify(request),198+ latestCanonicalSvg: '<svg data-dz-schema="1"></svg>',199+ }200+ assert.equal(PreviewController.prototype.canonicalSvgFor.call(controller, request), controller.latestCanonicalSvg)201+ assert.equal(PreviewController.prototype.canonicalSvgFor.call(controller, { ...request, source: 'changed' }), null)202+})203+195204 test('preserves the current transform when a presentation-only image loads', () => {196205 const calls = []197206 const controller = {