clone

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

main
diagramzip / apps / editor / src / main.js
main.js 74.2K
1import { diagramTypeFromQuery, diagramTypes, isKnownDiagramType, urlWithDiagramType } from './diagram-types.js'2import { exampleStateFor } from './examples.js'3import { exampleStateForTheme } from './example-defaults.js'4import {5 DETAILS_MODEL_URI,6 detailsDocumentFor,7 detailsStateWithTitle,8 parseDetailsDocument,9 serializeDetailsDocument,10} from './details-document.js'11import { createSourceEditor } from './source-editor.js'12import { stateForTypeChange } from './type-drafts.js'13import { workingStateIsDirty, workingStateSnapshot } from './working-state.js'14import {15 DEFAULT_DIAGRAM_TYPE,16 documentTitle,17 normalizeMetadata,18 normalizePresentation,19 packedSvgUrl,20} from './state.js'21import {22 aliasIdFromPath,23 editAliasUrl,24 PersistenceClient,25 PersistenceError,26 readAliasUrl,27 stableRenderUrl,28 writeCapabilityFromHash,29} from './persistence.js'30import {31 createLockedState,32 encryptLockedRender,33 encryptLockedState,34 MIN_PASSWORD_LENGTH,35 unlockLockedAlias,36 wrapBundleKey,37} from './encryption.js'38import { PreviewController } from './preview.js'39import { exportEditableSvg } from './editable-svg.js'40import { importEditableSvgFile, importEditableSvgInput } from './editable-svg-input.js'41import { createCommandPaletteModel } from './command-palette.js'42import { launcherCatalogGroups } from './launcher-catalog.js'43import { createLocalDocuments } from './local-documents.js'44import { mobilePanelSwitchState } from './mobile-panel.js'45import './style.css'4647const ALIAS_DRAFT_PREFIX = 'diagram.zip:draft:alias:v1:'48const WRITE_CAPABILITY_PREFIX = 'diagram.zip:write:v1:'49const persistence = new PersistenceClient()50const localDocuments = createLocalDocuments()51const systemColorScheme = matchMedia('(prefers-color-scheme: dark)')52const initialTemplateTheme = systemColorScheme.matches ? 'dark' : 'light'53document.documentElement.dataset.theme = systemColorScheme.matches ? 'dark' : 'light'54let renderTimer55let detailsCommitTimer56let activeType57let saving = false58let lastFileSnapshot = null59let localDocumentId = new URLSearchParams(location.search).get('doc')60let initialLoadError = ''61let restoredDetailsSource = null62let remote = emptyRemoteState()6364function templateStateFor(type) {65 return exampleStateForTheme(exampleStateFor(type), initialTemplateTheme)66}6768document.querySelector('#app').innerHTML = `69 <main class="app-shell">70 <header class="app-header">71 <a class="brand" href="/" aria-label="Open launcher" title="Open launcher"><img class="brand-mark" src="/icon.svg?v=4" alt=""></a>72 <div class="document-identity">73 <input class="document-title" id="diagram-title" type="text" maxlength="200" placeholder="Untitled" aria-label="Diagram title" autocomplete="off" spellcheck="false">74 <button class="document-reset" id="quick-reset" type="button" data-visible="false" aria-hidden="true" tabindex="-1">75 <span class="unsaved-indicator" id="unsaved-indicator" aria-hidden="true"></span>76 <svg class="document-reset-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">77 <path d="M4 9a8 8 0 1 1-.1 6M4 9V4m0 5h5"/>78 </svg>79 </button>80 </div>81 <span class="header-spacer"></span>82 <span class="render-status" data-state="idle" role="status">Ready</span>83 <select id="diagram-type" hidden aria-hidden="true"></select>84 <button class="type-command" id="command-type" type="button" aria-haspopup="dialog" aria-controls="command-dialog">85 <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>86 <span id="command-type-label">Diagram</span>87 </button>88 <button class="command-trigger" id="command-trigger" type="button" aria-haspopup="dialog" aria-controls="command-dialog" aria-label="Open command palette">89 <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>90 <kbd>⌘K</kbd>91 </button>92 </header>9394 <aside class="draft-bar" id="draft-bar" aria-live="polite" hidden>95 <p>This device has changes that are not part of the published share link.</p>96 <div>97 <button class="secondary-action" id="restore-saved" type="button">98 <span>Restore published</span>99 <svg class="draft-reset-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">100 <path d="M4.5 9V4.5H9"/>101 <path d="M5.2 7.2A8 8 0 1 1 4.6 16"/>102 </svg>103 </button>104 <button class="primary-action" id="make-copy" type="button">Make a copy</button>105 </div>106 </aside>107108 <div class="workspace" data-mobile-panel="editor">109 <section class="editor-panel" id="editor-panel" aria-label="Diagram input">110 <div class="editor-tabs" role="tablist" aria-label="Diagram input">111 <button id="source-tab" type="button" role="tab" aria-selected="true" aria-controls="editor" tabindex="0">Source</button>112 <button id="details-tab" type="button" role="tab" aria-selected="false" aria-controls="details-editor" tabindex="-1">Details</button>113 </div>114 <div class="editor-surfaces">115 <div id="editor" role="tabpanel" aria-labelledby="source-tab"></div>116 <div id="details-editor" role="tabpanel" aria-labelledby="details-tab" hidden></div>117 </div>118 <div class="syntax-dock" id="syntax-dock" aria-label="Diagnostics" data-severity="none">119 <span class="syntax-dock-summary" id="syntax-dock-summary" aria-label="No diagnostics" title="No diagnostics">120 <svg class="diagnostic-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">121 <g data-diagnostic-icon="none"><circle cx="12" cy="12" r="8.5"/><path d="m8.5 12 2.2 2.2 4.8-5"/></g>122 <g data-diagnostic-icon="error"><circle cx="12" cy="12" r="8.5"/><path d="m9 9 6 6m0-6-6 6"/></g>123 <g data-diagnostic-icon="warning"><path d="M12 3.5 21 20H3L12 3.5Z"/><path d="M12 9v5m0 2.7v.1"/></g>124 <g data-diagnostic-icon="info"><circle cx="12" cy="12" r="8.5"/><path d="M12 10.5V16m0-8.5v.1"/></g>125 </svg>126 <span id="syntax-message-count">0</span>127 </span>128 <span class="details-validation" id="details-validation" role="status">No problems</span>129 </div>130 </section>131 <section class="preview-panel" id="preview-panel" aria-label="Diagram preview">132 <div class="preview-stage" id="preview-stage">133 <img id="preview-image" alt="Rendered diagram" draggable="false" hidden>134 <p class="preview-empty">Your diagram will appear here.</p>135 <div class="preview-toolbar" aria-label="Preview controls">136 <div class="preview-zoom-controls" role="group" aria-label="Zoom">137 <button type="button" data-preview-action="zoom-out" aria-label="Zoom out">−</button>138 <button type="button" data-preview-action="zoom-in" aria-label="Zoom in">+</button>139 <button type="button" data-preview-action="fit">Fit</button>140 <button type="button" data-preview-action="one-to-one">1:1</button>141 </div>142 <span class="preview-toolbar-divider" aria-hidden="true"></span>143 <div class="preview-appearance-controls" aria-label="Preview appearance">144 <button id="preview-raw" type="button" aria-pressed="false" title="Show the renderer's original appearance">Raw</button>145 <button id="preview-theme-toggle" type="button" data-preview-theme="light" aria-pressed="false" aria-label="Preview theme: Light" title="Preview theme: Light. Click for Dark">146 <svg class="preview-control-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">147 <circle cx="12" cy="12" r="8"/>148 <path class="preview-theme-fill" d="M12 4a8 8 0 0 1 0 16Z"/>149 </svg>150 </button>151 <button id="preview-transparent" type="button" aria-pressed="true" aria-label="Transparent background" title="Transparent background">152 <svg class="preview-control-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">153 <rect x="4" y="4" width="16" height="16" rx="1.5"/>154 <path class="preview-transparency-fill" d="M4 4h8v8H4zM12 12h8v8h-8z"/>155 </svg>156 </button>157 </div>158 </div>159 <div class="minimap" id="minimap" aria-label="Preview minimap" hidden>160 <img id="minimap-image" alt="" draggable="false">161 <span id="minimap-viewport" aria-hidden="true"></span>162 </div>163 </div>164 </section>165 </div>166 <button class="mobile-panel-switch" type="button" data-target-panel="preview" aria-controls="preview-panel" aria-label="Show preview" title="Show preview">167 <svg data-panel-icon="preview" viewBox="0 0 24 24" aria-hidden="true" focusable="false">168 <path d="M3 12s3.3-6 9-6 9 6 9 6-3.3 6-9 6-9-6-9-6Z"/>169 <circle cx="12" cy="12" r="2.6"/>170 </svg>171 <svg data-panel-icon="editor" viewBox="0 0 24 24" aria-hidden="true" focusable="false">172 <path d="m9 7-5 5 5 5M15 7l5 5-5 5"/>173 </svg>174 </button>175 </main>176177 <dialog id="share-dialog" aria-labelledby="share-title">178 <form method="dialog" class="dialog-card">179 <div class="dialog-header">180 <h1 id="share-title">Share diagram</h1>181 <button class="icon-button" value="cancel" aria-label="Close">×</button>182 </div>183 <p class="share-status" id="share-status"></p>184 <div class="share-action-list">185 <button class="primary-action" type="button" data-share-action="publish">Publish</button>186 <button class="secondary-action" type="button" data-share-action="encrypt-publish">Encrypt &amp; Publish</button>187 <button class="secondary-action" type="button" data-share-action="file">Save as File</button>188 <button class="secondary-action" type="button" data-share-action="svg-url">Copy SVG URL</button>189 <button class="secondary-action" type="button" data-share-action="markdown">Copy as Markdown</button>190 </div>191 <fieldset class="privacy-fields share-privacy-fields">192 <legend>Privacy</legend>193 <div>194 <strong id="privacy-label">Open</strong>195 <p id="privacy-description">Anyone with the read link can open and embed this diagram.</p>196 </div>197 <div class="privacy-actions">198 <button class="secondary-action" id="lock-diagram" type="button">Lock with password</button>199 <button class="secondary-action" id="change-password" type="button" hidden>Change password</button>200 <button class="secondary-action" id="unlock-diagram" type="button" hidden>Remove password</button>201 </div>202 </fieldset>203 <div class="share-options">204 <label><span>Read link</span><div><input id="viewer-link" readonly><button type="button" data-copy="viewer-link">Copy</button></div></label>205 <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>206 </div>207 </form>208 </dialog>209210 <dialog id="open-dialog" aria-labelledby="open-title">211 <form class="dialog-card" id="open-form">212 <div class="dialog-header">213 <h1 id="open-title">Open editable SVG</h1>214 <button class="icon-button" id="open-cancel" type="button" aria-label="Close">×</button>215 </div>216 <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>217 <input id="open-file-input" type="file" accept=".svg,image/svg+xml" hidden>218 <button class="secondary-action open-file-action" id="open-file" type="button">Choose SVG File</button>219 <div class="details-fields open-input-fields">220 <label><span>SVG markup or URL</span><textarea id="open-svg-input" inputmode="url" placeholder="&lt;svg …&gt; or https://example.com/diagram.svg"></textarea></label>221 </div>222 <p class="form-error" id="open-error" role="alert"></p>223 <div class="dialog-actions">224 <button class="primary-action" id="open-submit" type="submit">Open</button>225 </div>226 </form>227 </dialog>228229 <dialog id="conflict-dialog" aria-labelledby="conflict-title">230 <form method="dialog" class="dialog-card">231 <div class="dialog-header">232 <h1 id="conflict-title">Publish conflict</h1>233 <button class="icon-button" value="cancel" aria-label="Close">×</button>234 </div>235 <p class="dialog-copy">Reload the published version, or keep your work by publishing it as a new diagram.</p>236 <div class="dialog-actions conflict-actions">237 <button class="secondary-action" value="reload">Reload published</button>238 <button class="primary-action" value="copy">Publish as new</button>239 </div>240 </form>241 </dialog>242243 <dialog id="password-dialog" aria-labelledby="password-title">244 <form class="dialog-card" id="password-form">245 <div class="dialog-header">246 <h1 id="password-title">Enter password</h1>247 <button class="icon-button" id="password-cancel" type="button" aria-label="Close">×</button>248 </div>249 <p class="dialog-copy" id="password-copy"></p>250 <div class="details-fields">251 <label><span>Password</span><input id="diagram-password" type="password" autocomplete="current-password" required></label>252 <label id="password-confirm-field" hidden><span>Confirm password</span><input id="diagram-password-confirm" type="password" autocomplete="new-password"></label>253 </div>254 <p class="form-error" id="password-error" role="alert"></p>255 <div class="dialog-actions">256 <button class="primary-action" id="password-submit" type="submit">Unlock</button>257 </div>258 </form>259 </dialog>260261 <dialog class="command-dialog" id="command-dialog" aria-label="Command palette">262 <div class="command-card">263 <label class="command-search">264 <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>265 <span class="command-scope" id="command-scope" hidden>Change format</span>266 <span class="sr-only">Filter commands</span>267 <input id="command-input" type="search" placeholder="Type a command" autocomplete="off" spellcheck="false" aria-controls="command-results" aria-autocomplete="list">268 <kbd>esc</kbd>269 </label>270 <div class="command-results" id="command-results" role="listbox" aria-label="Commands"></div>271 <footer class="command-footer">272 <span><kbd>↑↓</kbd> navigate</span>273 <span><kbd>↵</kbd> run</span>274 <span id="command-footer-note"><kbd>⌘K</kbd> anywhere</span>275 </footer>276 </div>277 </dialog>278279 <div class="toast" id="toast" role="status" aria-live="polite" popover="manual"></div>280`281282const typePicker = document.querySelector('#diagram-type')283const titleInput = document.querySelector('#diagram-title')284const previewRawButton = document.querySelector('#preview-raw')285const previewTransparentButton = document.querySelector('#preview-transparent')286const previewThemeButton = document.querySelector('#preview-theme-toggle')287let supportedPreviewAppearances = new Set(['raw'])288let previewTheme = systemColorScheme.matches ? 'dark' : 'light'289let previewTransparent = true290let detailsValid = true291let detailsError = ''292let detailsState293let detailsEditor = null294let sourceEditor = null295let previewHasBeenRevealed = false296for (const { id, label } of diagramTypes) {297 typePicker.add(new Option(label, id))298}299300const initialState = await loadInitialState()301detailsState = {302 meta: normalizeMetadata(initialState.meta),303 options: initialState.options ?? {},304 presentation: normalizePresentation(initialState.presentation),305}306typePicker.value = initialState.type307updateTypeCommand()308syncPreviewAppearanceControls()309updateDocumentMetadata()310activeType = initialState.type311312sourceEditor = await createSourceEditor({313 element: document.querySelector('#editor'),314 source: initialState.source,315 diagramType: initialState.type,316 onChange: scheduleUpdate,317 onSave: performPrimarySaveAction,318})319320detailsEditor = await createSourceEditor({321 element: document.querySelector('#details-editor'),322 source: restoredDetailsSource ?? serializeDetailsDocument(initialState),323 diagramType: initialState.type,324 language: 'json',325 modelUri: DETAILS_MODEL_URI,326 ariaLabel: 'Diagram details editor',327 onChange: scheduleDetailsUpdate,328 onSave: performPrimarySaveAction,329})330validateDetailsDocument(false)331applySystemTheme()332333const preview = new PreviewController({334 stage: document.querySelector('#preview-stage'),335 image: document.querySelector('#preview-image'),336 status: document.querySelector('.render-status'),337 minimap: document.querySelector('#minimap'),338 minimapImage: document.querySelector('#minimap-image'),339 minimapViewport: document.querySelector('#minimap-viewport'),340 autoTheme: previewTheme,341 onAppearances: updateAppearanceOptions,342 onError: message => showToast(message, 4000),343})344systemColorScheme.addEventListener('change', () => {345 applySystemTheme()346 if (detailsState.presentation.appearance.startsWith('auto-')) {347 preview.setAutoTheme(systemColorScheme.matches ? 'dark' : 'light')348 syncPreviewAppearanceControls()349 }350})351352typePicker.addEventListener('change', () => {353 changeDiagramType(typePicker.value)354})355356document.querySelector('#lock-diagram').addEventListener('click', lockDiagram)357document.querySelector('#change-password').addEventListener('click', changeDiagramPassword)358document.querySelector('#unlock-diagram').addEventListener('click', unlockDiagram)359document.querySelector('#restore-saved').addEventListener('click', restoreSavedDiagram)360document.querySelector('#quick-reset').addEventListener('click', quickResetDocument)361document.querySelector('#make-copy').addEventListener('click', makeDraftCopy)362document.querySelector('#command-trigger').addEventListener('click', () => openCommandPalette())363document.querySelector('#command-type').addEventListener('click', () => openCommandPalette('format'))364titleInput.addEventListener('focus', beginTitleEdit)365titleInput.addEventListener('input', updateTitleFromHeader)366titleInput.addEventListener('blur', finishTitleEdit)367titleInput.addEventListener('keydown', handleTitleKeydown)368document.querySelectorAll('[data-share-action]').forEach(button => {369 button.addEventListener('click', () => performShareAction(button.dataset.shareAction))370})371document.querySelector('#open-form').addEventListener('submit', submitEditableSvgInput)372document.querySelector('#open-cancel').addEventListener('click', () => document.querySelector('#open-dialog').close())373document.querySelector('#open-file').addEventListener('click', () => document.querySelector('#open-file-input').click())374document.querySelector('#open-file-input').addEventListener('change', openSelectedEditableSvgFile)375document.querySelector('.brand').addEventListener('click', event => {376 event.preventDefault()377 requestNewDiagram()378})379380previewRawButton.addEventListener('click', () => {381 if (detailsState.presentation.appearance === 'raw') {382 setPreviewAppearance(`${previewTheme}-${previewTransparent ? 'transparent' : 'framed'}`)383 } else {384 setPreviewAppearance('raw')385 }386})387previewTransparentButton.addEventListener('click', () => {388 const prefix = appearancePrefix(detailsState.presentation.appearance)389 const transparent = !previewTransparent390 setPreviewAppearance(`${prefix}-${transparent ? 'transparent' : 'framed'}`)391})392previewThemeButton.addEventListener('click', () => {393 const theme = previewTheme === 'light' ? 'dark' : 'light'394 setPreviewAppearance(`${theme}-${previewTransparent ? 'transparent' : 'framed'}`)395})396397document.querySelectorAll('[data-preview-action]').forEach(button => {398 button.addEventListener('click', () => {399 const action = button.dataset.previewAction400 if (action === 'zoom-out') preview.zoom(0.8)401 if (action === 'zoom-in') preview.zoom(1.25)402 if (action === 'fit') preview.fit()403 if (action === 'one-to-one') preview.oneToOne()404 })405})406407wireTabs(document.querySelector('.editor-tabs'), button => {408 const details = button.id === 'details-tab'409 document.querySelector('#editor').hidden = details410 document.querySelector('#details-editor').hidden = !details411 if (details) detailsEditor.layout()412 else sourceEditor.layout()413})414415document.querySelector('.mobile-panel-switch').addEventListener('click', () => {416 setMobilePanel(document.querySelector('.mobile-panel-switch').dataset.targetPanel)417})418419function setMobilePanel(panel) {420 const state = mobilePanelSwitchState(panel)421 const switchButton = document.querySelector('.mobile-panel-switch')422 document.querySelector('.workspace').dataset.mobilePanel = state.panel423 switchButton.dataset.targetPanel = state.targetPanel424 switchButton.setAttribute('aria-controls', state.controls)425 switchButton.setAttribute('aria-label', state.label)426 switchButton.title = state.label427 if (state.panel === 'preview') {428 if (!previewHasBeenRevealed) {429 previewHasBeenRevealed = true430 requestAnimationFrame(() => preview.fit())431 }432 } else {433 activeInputEditor().layout()434 }435}436437document.querySelectorAll('[data-copy]').forEach(button => {438 button.addEventListener('click', async () => {439 const input = document.querySelector(`#${button.dataset.copy}`)440 if (!input.value) return441 try {442 await copy(input.value)443 showToast('Copied')444 } catch {445 showToast('Could not copy to the clipboard.', 4000)446 }447 })448})449450window.addEventListener('popstate', () => location.reload())451window.addEventListener('dragover', event => {452 if ([...event.dataTransfer?.items ?? []].some(item => item.kind === 'file')) event.preventDefault()453})454window.addEventListener('drop', event => {455 const file = event.dataTransfer?.files?.[0]456 if (!file) return457 event.preventDefault()458 openEditableSvgFile(file)459})460461window.addEventListener('keydown', event => {462 if (event.defaultPrevented) return463 const primary = event.metaKey || event.ctrlKey464 const key = event.key.toLowerCase()465 if (primary && key === 'k') {466 event.preventDefault()467 const dialog = document.querySelector('#command-dialog')468 if (dialog.open) dialog.close()469 else openCommandPalette()470 } else if (primary && event.shiftKey && key === 'f') {471 event.preventDefault()472 openCommandPalette('format')473 } else if (primary && event.shiftKey && key === 'p') {474 event.preventDefault()475 saveDiagram()476 } else if (primary && event.shiftKey && key === 's') {477 event.preventDefault()478 openShareDialog()479 } else if (primary && key === 's') {480 event.preventDefault()481 saveEditableFile()482 } else if (primary && key === 'o') {483 event.preventDefault()484 openEditableSvgDialog()485 } else if (primary && key === 'n') {486 event.preventDefault()487 requestNewDiagram()488 } else if (!primary && event.shiftKey && key === '0') {489 event.preventDefault()490 preview.fit()491 }492})493494document.querySelector('#command-input').addEventListener('input', renderCommandPalette)495document.querySelector('#command-input').addEventListener('keydown', event => {496 if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {497 event.preventDefault()498 if (event.key === 'ArrowDown') commandPaletteModel?.next()499 else commandPaletteModel?.previous()500 renderCommandPalette(false)501 } else if (event.key === 'Enter') {502 event.preventDefault()503 runSelectedCommand()504 } else if (event.key === 'Escape') {505 event.preventDefault()506 document.querySelector('#command-dialog').close()507 }508})509document.querySelector('#command-dialog').addEventListener('click', event => {510 if (event.target === event.currentTarget) event.currentTarget.close()511})512document.querySelector('#command-dialog').addEventListener('close', () => commandPaletteInvoker?.focus())513document.querySelector('#command-results').addEventListener('click', event => {514 const option = event.target.closest('[data-command-id]')515 if (!option || option.disabled) return516 commandPaletteModel?.selectById(option.dataset.commandId)517 runSelectedCommand()518})519520function wireTabs(tablist, onSelect) {521 const tabs = [...tablist.querySelectorAll('[role="tab"]')]522 const activate = button => {523 for (const tab of tabs) {524 const selected = tab === button525 tab.setAttribute('aria-selected', String(selected))526 tab.tabIndex = selected ? 0 : -1527 }528 onSelect(button)529 }530 tablist.activateTab = activate531 for (const tab of tabs) {532 tab.addEventListener('click', () => activate(tab))533 tab.addEventListener('keydown', event => {534 let index535 if (event.key === 'ArrowRight') index = (tabs.indexOf(tab) + 1) % tabs.length536 else if (event.key === 'ArrowLeft') index = (tabs.indexOf(tab) - 1 + tabs.length) % tabs.length537 else if (event.key === 'Home') index = 0538 else if (event.key === 'End') index = tabs.length - 1539 else return540 event.preventDefault()541 tabs[index].focus()542 activate(tabs[index])543 })544 }545}546547function selectTab(button) {548 button.closest('[role="tablist"]')?.activateTab?.(button)549}550551function activeInputEditor() {552 return document.querySelector('#details-tab').getAttribute('aria-selected') === 'true'553 ? detailsEditor554 : sourceEditor555}556557let commandPaletteMode = 'root'558let commandPaletteModel = null559let commandPaletteInvoker = null560561function commandGroups(mode = 'root') {562 if (mode === 'format') {563 return launcherCatalogGroups.map(group => ({564 id: group.id,565 label: group.label,566 commands: group.items.map(entry => ({567 id: `format:${entry.id}`,568 label: entry.label,569 description: `${entry.description} - ${entry.extensions.join(', ')}`,570 keywords: [entry.id, ...entry.extensions],571 disabled: entry.id === typePicker.value,572 run: () => changeDiagramType(entry.id),573 })),574 }))575 }576577 const recents = localDocuments.loadRecents().slice(0, 6).map(entry => ({578 id: entry.kind === 'local' ? `recent:local:${entry.localId}` : `recent:alias:${entry.aliasId}`,579 label: entry.title || 'Locked diagram',580 description: `${entry.type ? diagramTypeLabel(entry.type) : 'Encrypted'} · ${relativeTime(entry.updatedAt)}${entry.kind === 'alias' ? ' - published' : ''}`,581 keywords: [entry.type, entry.kind],582 run: () => openRecent(entry),583 }))584 const publishedEmbedAvailable = remote.aliasId585 ? Boolean(remote.savedMode !== 'locked' && remote.savedState)586 : remote.mode !== 'locked'587 return [588 ...(recents.length ? [{ id: 'recent', label: 'Recent', commands: recents }] : []),589 {590 id: 'format',591 label: 'Format',592 commands: [{593 id: 'change-format',594 label: 'Change format…',595 description: `${diagramTypeLabel(typePicker.value)} → 29 others`,596 keywords: ['renderer', 'syntax', 'language'],597 shortcut: '⌘⇧F',598 run: () => openCommandPalette('format'),599 }],600 },601 {602 id: 'save-share',603 label: 'Save & share',604 commands: [605 { id: 'save-file', label: 'Save as file', description: 'Editable .svg', keywords: ['download', 'export'], shortcut: '⌘S', disabled: saving, run: saveEditableFile },606 { id: 'publish', label: 'Publish', description: 'Create or update a short link', shortcut: '⌘⇧P', disabled: saving, run: saveDiagram },607 { id: 'encrypt-publish', label: 'Encrypt & publish', description: 'Password protected, client-side', keywords: ['lock', 'private'], disabled: saving, run: encryptAndPublish },608 { id: 'copy-svg', label: 'Copy SVG URL', keywords: ['embed', 'link'], disabled: !publishedEmbedAvailable, run: () => performShareAction('svg-url') },609 { id: 'copy-markdown', label: 'Copy as Markdown', keywords: ['embed', 'image'], disabled: !publishedEmbedAvailable, run: () => performShareAction('markdown') },610 { id: 'share', label: 'Share…', description: 'Links, privacy, and publishing', shortcut: '⌘⇧S', run: openShareDialog },611 ],612 },613 {614 id: 'view',615 label: 'View',616 commands: [617 { id: 'fit', label: 'Fit to window', shortcut: '⇧0', run: () => preview.fit() },618 { id: 'one-to-one', label: 'Actual size', description: 'Show at 1:1', keywords: ['zoom'], run: () => preview.oneToOne() },619 { id: 'raw', label: detailsState.presentation.appearance === 'raw' ? 'Use themed renderer output' : 'Use raw renderer output', keywords: ['appearance', 'theme'], run: () => previewRawButton.click() },620 ],621 },622 {623 id: 'document',624 label: 'Document',625 commands: [626 { id: 'new', label: 'Create new…', description: 'Choose a template in the launcher', shortcut: '⌘N', run: requestNewDiagram },627 { id: 'open', label: 'Open editable SVG…', shortcut: '⌘O', run: openEditableSvgDialog },628 { id: 'docs', label: 'Documentation', description: 'Open docs.diagram.zip', keywords: ['help', 'reference'], run: () => location.assign('https://docs.diagram.zip/') },629 ],630 },631 ]632}633634function openCommandPalette(mode = 'root') {635 const dialog = document.querySelector('#command-dialog')636 if (dialog.open) dialog.close()637 commandPaletteInvoker = document.activeElement638 commandPaletteMode = mode639 commandPaletteModel = createCommandPaletteModel(commandGroups(mode))640 const input = document.querySelector('#command-input')641 input.value = ''642 input.placeholder = mode === 'format' ? 'Filter formats' : 'Type a command'643 document.querySelector('#command-scope').hidden = mode !== 'format'644 document.querySelector('#command-footer-note').textContent = mode === 'format'645 ? 'Source and document details stay unchanged'646 : '⌘K anywhere'647 if (mode === 'root') commandPaletteModel.selectById('change-format')648 else commandPaletteModel.selectFirst()649 renderCommandPalette(false)650 dialog.showModal()651 input.focus()652}653654function renderCommandPalette(resetSelection = true) {655 if (!commandPaletteModel) return656 const input = document.querySelector('#command-input')657 if (resetSelection) commandPaletteModel.selectFirst(input.value)658 const results = commandPaletteModel.results()659 const resultSet = new Set(results.map(command => command.id))660 const container = document.querySelector('#command-results')661 container.replaceChildren()662 for (const group of commandPaletteModel.groups) {663 const commands = group.commands.filter(command => resultSet.has(command.id))664 if (!commands.length) continue665 const heading = document.createElement('div')666 heading.className = 'command-section'667 heading.textContent = group.label668 container.append(heading)669 for (const command of commands) {670 const option = document.createElement('button')671 option.type = 'button'672 option.className = 'command-option'673 option.id = `command-option-${command.id.replace(/[^a-z0-9_-]/gi, '-')}`674 option.dataset.commandId = command.id675 option.disabled = command.disabled === true676 option.setAttribute('role', 'option')677 const selected = command.id === commandPaletteModel.selectedId678 option.setAttribute('aria-selected', String(selected))679 option.innerHTML = `<span class="command-option-copy"><strong></strong>${command.description ? '<small></small>' : ''}</span><span class="command-option-spacer"></span>${command.shortcut ? '<kbd></kbd>' : ''}`680 option.querySelector('strong').textContent = command.label681 if (command.description) option.querySelector('small').textContent = command.description682 if (command.shortcut) option.querySelector('kbd').textContent = command.shortcut683 container.append(option)684 }685 }686 if (!results.length) {687 const empty = document.createElement('p')688 empty.className = 'command-empty'689 empty.textContent = commandPaletteMode === 'format' ? 'No matching formats.' : 'No matching commands.'690 container.append(empty)691 }692 const selected = container.querySelector('[aria-selected="true"]')693 input.setAttribute('aria-activedescendant', selected?.id ?? '')694 selected?.scrollIntoView({ block: 'nearest' })695}696697function runSelectedCommand() {698 const command = commandPaletteModel?.selected699 if (!command || command.disabled) return700 const dialog = document.querySelector('#command-dialog')701 dialog.close()702 queueMicrotask(() => command.run())703}704705function changeDiagramType(type) {706 if (!isKnownDiagramType(type)) return707 if (!ensureValidDetails()) {708 typePicker.value = activeType709 updateTypeCommand()710 return711 }712 history.replaceState(null, '', urlWithDiagramType(location.href, type))713 applyState(stateForTypeChange(currentState(), type))714}715716function updateTypeCommand() {717 const label = diagramTypeLabel(typePicker.value)718 const button = document.querySelector('#command-type')719 document.querySelector('#command-type-label').textContent = label720 button.setAttribute('aria-label', `Change format. Current format: ${label}`)721 button.title = `Change format - Current: ${label}`722}723724function diagramTypeLabel(type) {725 return diagramTypes.find(item => item.id === type)?.label ?? type ?? 'Diagram'726}727728function relativeTime(timestamp) {729 const elapsed = Math.max(0, Date.now() - Number(timestamp || 0))730 const minute = 60_000731 const hour = 60 * minute732 const day = 24 * hour733 if (elapsed < minute) return 'just now'734 if (elapsed < hour) return `${Math.floor(elapsed / minute)}m ago`735 if (elapsed < day) return `${Math.floor(elapsed / hour)}h ago`736 if (elapsed < 7 * day) return `${Math.floor(elapsed / day)}d ago`737 return new Date(timestamp).toLocaleDateString()738}739740function openRecent(entry) {741 if (entry.kind === 'local') location.assign(`/?doc=${encodeURIComponent(entry.localId)}`)742 else if (entry.mode === 'locked') location.assign(`/d/${entry.aliasId}`)743 else location.assign(urlWithDiagramType(`/d/${entry.aliasId}`, entry.type || DEFAULT_DIAGRAM_TYPE))744}745746function requestNewDiagram() {747 if (remote.dirty && !confirm('Leave these local changes and open the launcher? They remain on this device.')) return748 location.assign('/')749}750751function currentState() {752 return {753 type: typePicker.value,754 source: sourceEditor.getValue(),755 options: detailsState.options,756 meta: detailsState.meta,757 presentation: detailsState.presentation,758 }759}760761function applySystemTheme() {762 const resolved = systemColorScheme.matches ? 'dark' : 'light'763 document.documentElement.dataset.theme = resolved764 document.querySelector('meta[name="theme-color"]').content = resolved === 'dark' ? '#18191b' : '#ffffff'765 sourceEditor.setTheme?.()766 detailsEditor.setTheme?.()767}768769function appearancePrefix(appearance) {770 if (appearance.startsWith('auto-')) return 'auto'771 if (appearance.startsWith('dark-')) return 'dark'772 if (appearance.startsWith('light-')) return 'light'773 return previewTheme774}775776function setPreviewAppearance(appearance) {777 if (!ensureValidDetails()) return778 if (!supportedPreviewAppearances.has(appearance)) {779 showToast('This renderer does not support that appearance.')780 return781 }782 setDetailsState({783 ...detailsState,784 presentation: { ...detailsState.presentation, appearance },785 })786 syncPreviewAppearanceControls()787 commitState()788}789790function syncPreviewAppearanceControls() {791 const appearance = detailsState.presentation.appearance792 const raw = appearance === 'raw'793 if (!raw) {794 const prefix = appearancePrefix(appearance)795 previewTheme = prefix === 'auto' ? (systemColorScheme.matches ? 'dark' : 'light') : prefix796 previewTransparent = appearance.endsWith('-transparent')797 }798799 previewRawButton.setAttribute('aria-pressed', String(raw))800 previewRawButton.disabled = raw && supportedPreviewAppearances.size === 1801 previewTransparentButton.setAttribute('aria-pressed', String(!raw && previewTransparent))802 previewTransparentButton.disabled = raw || !supportedPreviewAppearances.has(803 `${appearancePrefix(appearance)}-${previewTransparent ? 'framed' : 'transparent'}`,804 )805 const oppositeTheme = previewTheme === 'light' ? 'dark' : 'light'806 previewThemeButton.dataset.previewTheme = previewTheme807 previewThemeButton.setAttribute('aria-pressed', String(!raw && previewTheme === 'dark'))808 previewThemeButton.setAttribute('aria-label', `Preview theme: ${previewTheme === 'dark' ? 'Dark' : 'Light'}`)809 previewThemeButton.title = `Preview theme: ${previewTheme === 'dark' ? 'Dark' : 'Light'}. Click for ${oppositeTheme}`810 previewThemeButton.disabled = raw || !supportedPreviewAppearances.has(811 `${oppositeTheme}-${previewTransparent ? 'transparent' : 'framed'}`,812 )813}814815function updateAppearanceOptions(appearances, appliedAppearance) {816 supportedPreviewAppearances = new Set(appearances)817 if (!detailsValid) {818 syncPreviewAppearanceControls()819 return820 }821 const requested = detailsState.presentation.appearance822 if (!supportedPreviewAppearances.has(requested)) {823 const prefix = appearancePrefix(requested)824 const alternateSurface = requested.endsWith('-transparent') ? 'framed' : 'transparent'825 const alternate = `${prefix}-${alternateSurface}`826 setDetailsState({827 ...detailsState,828 presentation: {829 ...detailsState.presentation,830 appearance: supportedPreviewAppearances.has(alternate) ? alternate : appliedAppearance,831 },832 })833 }834 syncPreviewAppearanceControls()835 if (detailsState.presentation.appearance !== requested) queueMicrotask(commitState)836}837838function scheduleUpdate(delay = 1200) {839 clearTimeout(renderTimer)840 renderTimer = setTimeout(commitState, delay)841}842843function commitState(render = true) {844 const state = currentState()845 remote.dirty = !detailsValid || workingStateIsDirty(state, {846 mode: remote.mode,847 keyEnvelopeDirty: remote.keyEnvelopeDirty,848 savedSnapshot: remote.savedSnapshot,849 defaultStateFor: templateStateFor,850 })851 syncStoredDraft(state)852 updateSaveButton()853 updatePrivacyControls()854 if (render) preview.render(state)855}856857function scheduleDetailsUpdate() {858 clearTimeout(detailsCommitTimer)859 validateDetailsDocument(false)860 detailsCommitTimer = setTimeout(() => {861 if (detailsValid) commitState()862 else commitState(false)863 }, 220)864}865866let titleEditBaseline = ''867868function beginTitleEdit() {869 if (!detailsValid) {870 titleInput.blur()871 ensureValidDetails()872 return873 }874 titleEditBaseline = detailsState.meta.title875}876877function updateTitleFromHeader() {878 if (!detailsValid) return879 clearTimeout(detailsCommitTimer)880 setDetailsState(detailsStateWithTitle(detailsState, titleInput.value))881 detailsCommitTimer = setTimeout(commitState, 220)882}883884function finishTitleEdit() {885 if (!detailsValid || titleInput.value === titleEditBaseline) return886 clearTimeout(detailsCommitTimer)887 commitState()888}889890function handleTitleKeydown(event) {891 if (event.key === 'Enter') {892 event.preventDefault()893 titleInput.blur()894 } else if (event.key === 'Escape') {895 event.preventDefault()896 clearTimeout(detailsCommitTimer)897 setDetailsState(detailsStateWithTitle(detailsState, titleEditBaseline))898 titleInput.blur()899 }900}901902function validateDetailsDocument(showError = false) {903 try {904 detailsState = parseDetailsDocument(detailsEditor.getValue())905 detailsValid = true906 detailsError = ''907 updateDetailsValidation()908 updateDocumentMetadata()909 syncPreviewAppearanceControls()910 return true911 } catch (error) {912 detailsValid = false913 detailsError = error instanceof Error ? error.message : 'Details are invalid.'914 updateDetailsValidation()915 if (showError) showToast(detailsError, 4000)916 return false917 }918}919920function ensureValidDetails() {921 if (validateDetailsDocument(true)) return true922 selectTab(document.querySelector('#details-tab'))923 detailsEditor.focus()924 return false925}926927function setDetailsState(nextDetails) {928 const normalized = detailsDocumentFor(nextDetails)929 detailsState = {930 meta: normalizeMetadata({ title: normalized.title, description: normalized.description }),931 options: normalized.options,932 presentation: normalizePresentation(normalized.presentation),933 }934 detailsValid = true935 detailsError = ''936 detailsEditor.setDocument({937 source: serializeDetailsDocument(detailsState),938 diagramType: typePicker.value,939 language: 'json',940 })941 updateDetailsValidation()942 updateDocumentMetadata()943}944945function updateDetailsValidation() {946 const status = document.querySelector('#details-validation')947 const tab = document.querySelector('#details-tab')948 const dock = document.querySelector('#syntax-dock')949 const summary = document.querySelector('#syntax-dock-summary')950 const count = document.querySelector('#syntax-message-count')951 status.textContent = detailsValid ? 'No problems' : detailsError952 status.title = detailsError953 count.textContent = detailsValid ? '0' : '1'954 dock.dataset.severity = detailsValid ? 'none' : 'error'955 summary.setAttribute('aria-label', detailsValid ? 'No diagnostics' : '1 error')956 summary.title = detailsValid ? 'No diagnostics' : '1 error'957 tab.dataset.invalid = String(!detailsValid)958 tab.setAttribute('aria-invalid', String(!detailsValid))959 if (detailsValid) {960 tab.removeAttribute('aria-describedby')961 } else {962 tab.setAttribute('aria-describedby', 'details-validation')963 }964}965966async function loadInitialState() {967 const aliasId = aliasIdFromPath(location.pathname)968 if (aliasId) {969 const importedCapability = writeCapabilityFromHash(location.hash)970 if (importedCapability) {971 storeWriteCapability(aliasId, importedCapability)972 }973 if (location.hash) history.replaceState(null, '', `${location.pathname}${location.search}`)974 try {975 const alias = await persistence.getAlias(aliasId)976 const writeCapability = importedCapability ?? loadWriteCapability(aliasId)977 if (alias.mode === 'locked') {978 const unlocked = await promptToUnlock(alias)979 if (!unlocked) {980 initialLoadError = 'A password is required to open this diagram.'981 return templateStateFor(DEFAULT_DIAGRAM_TYPE)982 }983 setRemoteAlias(alias, writeCapability, unlocked.state, unlocked.bundleKey)984 return unlocked.state985 }986 setRemoteAlias(alias, writeCapability, alias.state)987 const draft = loadAliasDraft(`${ALIAS_DRAFT_PREFIX}${aliasId}`)988 if (draft?.revision === alias.revision) {989 restoredDetailsSource = draft.detailsSource990 return draft.state991 }992 return alias.state993 } catch (error) {994 initialLoadError = error instanceof PersistenceError995 ? error.message996 : 'This published diagram could not be loaded.'997 }998 }999 if (location.hash) history.replaceState(null, '', `${location.pathname}${location.search}`)1000 if (localDocumentId) {1001 const document = localDocuments.loadLocalDocument(localDocumentId)1002 const state = normalizeStoredState(document?.state)1003 if (state) {1004 lastFileSnapshot = document.fileSnapshot1005 restoredDetailsSource = document.detailsSource1006 localDocuments.touchRecent({ localId: localDocumentId })1007 return state1008 }1009 initialLoadError = 'This local diagram is no longer stored on this device.'1010 localDocumentId = null1011 }1012 const requestedType = diagramTypeFromQuery(location.search)1013 if (requestedType) {1014 const state = templateStateFor(requestedType)1015 return state1016 }1017 return templateStateFor(DEFAULT_DIAGRAM_TYPE)1018}10191020function applyState(state, commit = true) {1021 if (!isKnownDiagramType(state.type)) throw new Error('Unsupported diagram type.')1022 const meta = normalizeMetadata(state.meta)1023 const presentation = normalizePresentation(state.presentation)1024 activeType = state.type1025 typePicker.value = state.type1026 updateTypeCommand()1027 setDetailsState({ meta, options: state.options ?? {}, presentation })1028 syncPreviewAppearanceControls()1029 sourceEditor.setDocument({ source: state.source, diagramType: state.type })1030 if (commit) commitState()1031 else preview.render(currentState())1032}10331034async function performPrimarySaveAction() {1035 return saveEditableFile()1036}10371038async function saveEditableFile() {1039 if (saving || !ensureValidDetails()) return false1040 if (remote.mode === 'locked' && !confirm('Save a decrypted editable SVG? The file will contain the diagram source without password protection.')) {1041 return false1042 }1043 commitState(false)1044 const state = currentState()1045 saving = true1046 updateSaveButton()1047 try {1048 await preview.render(state)1049 const canonical = preview.canonicalSvgFor(state)1050 if (!canonical) throw new Error('The current diagram has not finished rendering.')1051 const source = exportEditableSvg(canonical, state)1052 downloadEditableSvg(source, editableSvgFilename(state))1053 lastFileSnapshot = workingStateSnapshot(state)1054 if (!remote.aliasId) storeDraft(state)1055 showToast('Saved editable SVG')1056 return true1057 } catch (error) {1058 showToast(error instanceof Error ? error.message : 'Could not save this editable SVG.', 4000)1059 return false1060 } finally {1061 saving = false1062 updateSaveButton()1063 }1064}10651066function editableSvgFilename(state) {1067 const requested = state.meta.title.trim() || `${state.type}-diagram`1068 const basename = requested1069 .replace(/[\u0000-\u001f<>:"/\\|?*]+/g, ' ')1070 .replace(/\s+/g, ' ')1071 .trim()1072 .slice(0, 120) || 'diagram'1073 return `${basename}.diagram.svg`1074}10751076function downloadEditableSvg(source, filename) {1077 const url = URL.createObjectURL(new Blob([source], { type: 'image/svg+xml' }))1078 const link = document.createElement('a')1079 link.href = url1080 link.download = filename1081 link.hidden = true1082 document.body.append(link)1083 link.click()1084 link.remove()1085 setTimeout(() => URL.revokeObjectURL(url), 0)1086}10871088function openEditableSvgDialog() {1089 const dialog = document.querySelector('#open-dialog')1090 document.querySelector('#open-svg-input').value = ''1091 document.querySelector('#open-file-input').value = ''1092 document.querySelector('#open-error').textContent = ''1093 dialog.showModal()1094}10951096async function submitEditableSvgInput(event) {1097 event.preventDefault()1098 const input = document.querySelector('#open-svg-input').value.trim()1099 if (!input) {1100 document.querySelector('#open-error').textContent = 'Paste SVG markup or a URL, or choose a file.'1101 return1102 }1103 await openEditableSvgValue(() => importEditableSvgInput(input))1104}11051106async function openSelectedEditableSvgFile(event) {1107 const file = event.target.files?.[0]1108 if (file) await openEditableSvgFile(file)1109}11101111async function openEditableSvgFile(file) {1112 await openEditableSvgValue(() => importEditableSvgFile(file))1113}11141115async function openEditableSvgValue(load) {1116 const error = document.querySelector('#open-error')1117 error.textContent = ''1118 try {1119 const state = await load()1120 if (remote.dirty && !confirm('Replace the current working draft with this editable SVG? Unsaved changes will be discarded.')) return1121 clearTimeout(renderTimer)1122 clearTimeout(detailsCommitTimer)1123 lastFileSnapshot = workingStateSnapshot(state)1124 localDocumentId = null1125 remote = emptyRemoteState()1126 history.pushState(null, '', urlWithDiagramType('/', state.type))1127 applyState(state)1128 const dialog = document.querySelector('#open-dialog')1129 if (dialog.open) dialog.close()1130 showToast('Opened editable SVG')1131 } catch (caught) {1132 const message = caught instanceof Error ? caught.message : 'Could not open this editable SVG.'1133 error.textContent = message1134 if (!document.querySelector('#open-dialog').open) showToast(message, 4000)1135 }1136}11371138function openShareDialog() {1139 if (!ensureValidDetails()) return1140 commitState()1141 populateShareDialog()1142 document.querySelector('#share-dialog').showModal()1143}11441145function populateShareDialog() {1146 const hasAlias = Boolean(remote.aliasId)1147 const savedLocked = hasAlias && remote.savedMode === 'locked'1148 const viewerLink = hasAlias ? readAliasUrl(location.origin, remote.aliasId) : ''1149 const editorLink = hasAlias && remote.writeCapability1150 ? editAliasUrl(location.origin, remote.aliasId, remote.writeCapability)1151 : ''1152 const savedState = savedLocked ? null : remote.savedState1153 const savedAppearance = savedState ? normalizePresentation(savedState.presentation).appearance : 'raw'1154 const svgLink = savedState ? stableRenderUrl(location.origin, remote.aliasId, 'svg', savedAppearance) : ''11551156 setShareField('viewer-link', viewerLink, 'Publish to create a read link')1157 setShareField('editor-link', editorLink, hasAlias ? 'No write capability on this device' : 'Publish to create an edit link')11581159 document.querySelector('#editor-link-note').textContent = editorLink1160 ? 'Anyone with this link can update the diagram.'1161 : hasAlias1162 ? 'This device has read-only access. Publish a copy to create a new edit link.'1163 : 'The edit link is a bearer credential and appears after publishing.'1164 const embedAvailable = hasAlias ? Boolean(svgLink) : remote.mode !== 'locked'1165 document.querySelector('[data-share-action="svg-url"]').disabled = !embedAvailable1166 document.querySelector('[data-share-action="markdown"]').disabled = !embedAvailable11671168 const shareStatus = document.querySelector('#share-status')1169 if (!hasAlias) {1170 shareStatus.textContent = 'Keep this draft in a file, or publish it when you want stable share links.'1171 } else if (remote.dirty && remote.writeCapability) {1172 shareStatus.textContent = 'These links show the last published version. Publish your changes before sharing.'1173 } else if (remote.dirty) {1174 shareStatus.textContent = 'You are editing a read-only diagram. Publish to create a new alias for your changes.'1175 } else if (savedLocked) {1176 shareStatus.textContent = remote.writeCapability1177 ? 'The read link asks for the password. The edit link also grants permission to save changes.'1178 : 'The read link asks for the password. This device has read-only access.'1179 } else {1180 shareStatus.textContent = remote.writeCapability1181 ? 'Copy a read link, or share the edit capability carefully.'1182 : 'This diagram is read-only on this device.'1183 }1184}11851186async function performShareAction(action) {1187 let completed = false1188 if (action === 'publish') completed = await saveDiagram()1189 if (action === 'encrypt-publish') completed = await encryptAndPublish()1190 if (action === 'file') completed = await saveEditableFile()1191 if (action === 'svg-url' || action === 'markdown') {1192 try {1193 const value = await embedValue(action)1194 if (!value) return1195 await copy(value)1196 showToast(action === 'svg-url' ? 'SVG URL copied' : 'Markdown copied')1197 completed = true1198 } catch (error) {1199 showToast(error instanceof Error ? error.message : 'Could not copy to the clipboard.', 4000)1200 }1201 }1202 if (completed) populateShareDialog()1203}12041205async function embedValue(action) {1206 let state1207 let svgLink1208 if (remote.aliasId) {1209 if (remote.savedMode === 'locked' || !remote.savedState) return ''1210 state = remote.savedState1211 const appearance = normalizePresentation(state.presentation).appearance1212 svgLink = stableRenderUrl(location.origin, remote.aliasId, 'svg', appearance)1213 } else {1214 if (remote.mode === 'locked') throw new Error('Encrypted diagrams cannot use public SVG embeds.')1215 if (!ensureValidDetails()) return ''1216 commitState(false)1217 state = currentState()1218 await preview.render(state)1219 const canonical = preview.canonicalSvgFor(state)1220 if (!canonical) throw new Error('The current diagram has not finished rendering.')1221 svgLink = packedSvgUrl(location.origin, exportEditableSvg(canonical, state))1222 }1223 if (action === 'svg-url') return svgLink1224 const title = state.meta.title.trim().replace(/[\[\]\r\n]/g, ' ') || 'Diagram'1225 return `![${title}](${svgLink})`1226}12271228async function encryptAndPublish() {1229 if (remote.mode !== 'locked' && !(await lockDiagram())) return false1230 return saveDiagram()1231}12321233function setShareField(id, value, placeholder) {1234 const input = document.querySelector(`#${id}`)1235 const button = document.querySelector(`[data-copy="${id}"]`)1236 input.value = value1237 input.placeholder = value ? '' : placeholder1238 button.disabled = !value1239}12401241async function saveDiagram() {1242 if (saving) return false1243 if (!ensureValidDetails()) return false1244 commitState(false)1245 const state = currentState()1246 if (remote.aliasId && !remote.dirty) {1247 showToast('Already published')1248 return true1249 }12501251 saving = true1252 updateSaveButton()1253 try {1254 if (remote.aliasId && remote.writeCapability) {1255 const alias = remote.mode === 'locked'1256 ? await persistence.updateLockedAlias(1257 remote.aliasId,1258 await lockedPayloadForState(state),1259 remote.revision,1260 remote.writeCapability,1261 )1262 : await persistence.updateAlias(1263 remote.aliasId,1264 state,1265 remote.revision,1266 remote.writeCapability,1267 )1268 setRemoteAlias(alias, remote.writeCapability, state, remote.mode === 'locked' ? remote.bundleKey : null)1269 removeDraft(`${ALIAS_DRAFT_PREFIX}${remote.aliasId}`)1270 await cacheSavedRenders(state)1271 showToast('Published changes')1272 return true1273 }1274 const savedAsCopy = Boolean(remote.aliasId)1275 await createNewAlias(state)1276 showToast(savedAsCopy ? 'Published as a new diagram' : 'Published')1277 return true1278 } catch (error) {1279 if (error instanceof PersistenceError && error.status === 412 && remote.aliasId) {1280 return await resolveConflict(state)1281 }1282 showToast(error instanceof Error ? error.message : 'Could not publish this diagram.')1283 return false1284 } finally {1285 saving = false1286 updateSaveButton()1287 }1288}12891290async function createNewAlias(state) {1291 const previousAliasId = remote.aliasId1292 const previousDraftKey = previousAliasId ? `${ALIAS_DRAFT_PREFIX}${previousAliasId}` : null1293 const previousLocalDocumentId = localDocumentId1294 const bundleKey = remote.bundleKey1295 const alias = remote.mode === 'locked'1296 ? await persistence.createLockedAlias(await lockedPayloadForState(state))1297 : await persistence.createAlias(state)1298 setRemoteAlias(alias, alias.writeCapability, state, remote.mode === 'locked' ? bundleKey : null)1299 storeWriteCapability(alias.aliasId, alias.writeCapability)1300 if (previousDraftKey) removeDraft(previousDraftKey)1301 if (previousLocalDocumentId) {1302 localDocuments.deleteLocalDocument(previousLocalDocumentId)1303 localDocumentId = null1304 }1305 history.pushState(null, '', urlWithDiagramType(`/d/${alias.aliasId}`, state.type))1306 await cacheSavedRenders(state)1307}13081309async function lockedPayloadForState(state) {1310 if (!(remote.bundleKey instanceof Uint8Array) || !remote.keyEnvelope) {1311 throw new Error('This diagram is missing its browser encryption key.')1312 }1313 const contentIsUnchanged = remote.savedMode === 'locked'1314 && remote.savedSnapshot === workingStateSnapshot(state, 'locked')1315 && remote.encryptedContent1316 && remote.encryptedMetadata1317 if (contentIsUnchanged) {1318 return {1319 mode: 'locked',1320 encryptedContent: remote.encryptedContent,1321 encryptedMetadata: remote.encryptedMetadata,1322 keyEnvelope: remote.keyEnvelope,1323 }1324 }1325 return encryptLockedState(state, remote.bundleKey, remote.keyEnvelope)1326}13271328function rendererIdentitiesMatch(left, right) {1329 return Boolean(1330 left1331 && right1332 && left.unit === right.unit1333 && left.build === right.build1334 && left.pipeline.join(',') === right.pipeline.join(',')1335 )1336}13371338function savedRenderCanRefresh(state) {1339 return Boolean(1340 remote.aliasId1341 && remote.writeCapability1342 && remote.savedState === state1343 && !remote.dirty1344 && !remote.keyEnvelopeDirty1345 && detailsValid1346 && workingStateSnapshot(currentState(), remote.mode) === remote.savedSnapshot1347 )1348}13491350async function refreshSavedRenderIfStale() {1351 const state = remote.savedState1352 if (!savedRenderCanRefresh(state)) return1353 const target = `${remote.aliasId}:${remote.revision}:${remote.renderId}`1354 try {1355 await preview.render(state)1356 if (!savedRenderCanRefresh(state)1357 || target !== `${remote.aliasId}:${remote.revision}:${remote.renderId}`) return1358 const current = preview.rendererIdentityFor(state)1359 if (!current) return1360 const published = await persistence.getRenderIdentity(remote.aliasId, 'svg', remote.mode)1361 if (!savedRenderCanRefresh(state)1362 || target !== `${remote.aliasId}:${remote.revision}:${remote.renderId}`1363 || rendererIdentitiesMatch(current, published)) return1364 await cacheSavedRenders(state, { silent: true })1365 } catch {1366 // Render freshness is best effort and must not interrupt opening a diagram.1367 }1368}13691370async function cacheSavedRenders(state, { silent = false } = {}) {1371 if (!remote.aliasId || !remote.writeCapability) return false1372 await preview.render(state)1373 const svg = preview.svgBlobFor(state)1374 const renderer = preview.rendererIdentityFor(state)1375 if (!svg || !renderer) return false13761377 const renders = [['svg', svg]]1378 try {1379 renders.push(['png', await svgToPngBlob(svg)])1380 } catch {1381 // SVG remains the canonical cached render when this browser cannot rasterize it.1382 }13831384 const uploads = renders.map(async ([format, blob]) => {1385 const render = remote.mode === 'locked'1386 ? await encryptLockedRender(blob, remote.bundleKey, format)1387 : blob1388 return persistence.uploadRender({1389 aliasId: remote.aliasId,1390 renderId: remote.renderId,1391 revision: remote.revision,1392 writeCapability: remote.writeCapability,1393 format,1394 mode: remote.mode,1395 render,1396 renderer,1397 })1398 })1399 const results = await Promise.allSettled(uploads)1400 if (results.every(result => result.status === 'rejected')) {1401 if (!silent) showToast('Published, but the render cache is unavailable.', 4000)1402 return false1403 }1404 return results[0]?.status === 'fulfilled'1405}14061407async function svgToPngBlob(svg) {1408 const objectUrl = URL.createObjectURL(svg)1409 try {1410 const image = new Image()1411 image.src = objectUrl1412 await image.decode()1413 const maxDimension = 40961414 const scale = Math.min(1, maxDimension / Math.max(image.naturalWidth, image.naturalHeight))1415 const width = Math.max(1, Math.round(image.naturalWidth * scale))1416 const height = Math.max(1, Math.round(image.naturalHeight * scale))1417 const canvas = document.createElement('canvas')1418 canvas.width = width1419 canvas.height = height1420 const context = canvas.getContext('2d')1421 if (!context) throw new Error('Canvas is unavailable.')1422 context.drawImage(image, 0, 0, width, height)1423 const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'))1424 if (!blob) throw new Error('Could not create a PNG render.')1425 return blob1426 } finally {1427 URL.revokeObjectURL(objectUrl)1428 }1429}14301431async function lockDiagram() {1432 if (!ensureValidDetails()) return false1433 const password = await askPassword({1434 title: 'Lock diagram',1435 copy: 'The source, metadata, and published render will be encrypted in this browser. The password cannot be recovered.',1436 submitLabel: 'Lock diagram',1437 confirm: true,1438 })1439 if (password === null) return false1440 try {1441 const { bundleKey, payload } = await createLockedState(currentState(), password)1442 remote.mode = 'locked'1443 remote.bundleKey = bundleKey1444 remote.keyEnvelope = payload.keyEnvelope1445 remote.encryptedContent = payload.encryptedContent1446 remote.encryptedMetadata = payload.encryptedMetadata1447 remote.keyEnvelopeDirty = true1448 if (remote.aliasId) removeDraft(`${ALIAS_DRAFT_PREFIX}${remote.aliasId}`)1449 else if (localDocumentId) {1450 localDocuments.deleteLocalDocument(localDocumentId)1451 localDocumentId = null1452 history.replaceState(null, '', urlWithDiagramType('/', typePicker.value))1453 }1454 commitState(false)1455 showToast('Encrypted in this browser — publish to persist')1456 return true1457 } catch (error) {1458 showToast(error instanceof Error ? error.message : 'Could not lock this diagram.')1459 return false1460 }1461}14621463async function changeDiagramPassword() {1464 if (remote.mode !== 'locked' || !(remote.bundleKey instanceof Uint8Array)) return1465 const password = await askPassword({1466 title: 'Change password',1467 copy: 'Only the encrypted bundle key will be rewrapped. The diagram content does not need to be re-encrypted.',1468 submitLabel: 'Change password',1469 confirm: true,1470 })1471 if (password === null) return1472 try {1473 remote.keyEnvelope = await wrapBundleKey(remote.bundleKey, password)1474 remote.keyEnvelopeDirty = true1475 commitState(false)1476 showToast('Password changed — publish to persist')1477 } catch (error) {1478 showToast(error instanceof Error ? error.message : 'Could not change the password.')1479 }1480}14811482function unlockDiagram() {1483 if (remote.mode !== 'locked') return1484 if (!confirm('Remove password protection? The next publish will store the diagram in plaintext.')) return1485 remote.mode = 'open'1486 remote.keyEnvelopeDirty = false1487 commitState(false)1488 showToast('Password removed — publish to persist')1489}14901491async function promptToUnlock(alias) {1492 while (true) {1493 const password = await askPassword({1494 title: 'Unlock diagram',1495 copy: 'Decryption happens only in this browser. The password is never sent to diagram.zip.',1496 submitLabel: 'Unlock',1497 confirm: false,1498 })1499 if (password === null) return null1500 try {1501 return await unlockLockedAlias(alias, password)1502 } catch (error) {1503 showToast(error instanceof Error ? error.message : 'Could not unlock this diagram.')1504 }1505 }1506}15071508function askPassword({ title, copy: copyText, submitLabel, confirm: shouldConfirm }) {1509 const dialog = document.querySelector('#password-dialog')1510 const form = document.querySelector('#password-form')1511 const passwordInput = document.querySelector('#diagram-password')1512 const confirmInput = document.querySelector('#diagram-password-confirm')1513 const confirmField = document.querySelector('#password-confirm-field')1514 const error = document.querySelector('#password-error')1515 document.querySelector('#password-title').textContent = title1516 document.querySelector('#password-copy').textContent = copyText1517 document.querySelector('#password-submit').textContent = submitLabel1518 confirmField.hidden = !shouldConfirm1519 passwordInput.autocomplete = shouldConfirm ? 'new-password' : 'current-password'1520 passwordInput.value = ''1521 confirmInput.value = ''1522 error.textContent = ''15231524 return new Promise(resolve => {1525 let settled = false1526 const finish = value => {1527 if (settled) return1528 settled = true1529 form.removeEventListener('submit', submit)1530 document.querySelector('#password-cancel').removeEventListener('click', cancel)1531 dialog.removeEventListener('cancel', cancel)1532 dialog.removeEventListener('close', closed)1533 resolve(value)1534 }1535 const submit = event => {1536 event.preventDefault()1537 const password = passwordInput.value1538 if (shouldConfirm && password.length < MIN_PASSWORD_LENGTH) {1539 error.textContent = `Use at least ${MIN_PASSWORD_LENGTH} characters.`1540 passwordInput.focus()1541 return1542 }1543 if (shouldConfirm && password !== confirmInput.value) {1544 error.textContent = 'The passwords do not match.'1545 confirmInput.focus()1546 return1547 }1548 dialog.close()1549 finish(password)1550 }1551 const cancel = event => {1552 event.preventDefault()1553 dialog.close()1554 finish(null)1555 }1556 const closed = () => finish(null)1557 form.addEventListener('submit', submit)1558 document.querySelector('#password-cancel').addEventListener('click', cancel)1559 dialog.addEventListener('cancel', cancel)1560 dialog.addEventListener('close', closed)1561 dialog.showModal()1562 passwordInput.focus()1563 })1564}15651566async function resolveConflict(localState) {1567 const choice = await askConflict()1568 if (choice === 'reload') {1569 const aliasId = remote.aliasId1570 const writeCapability = remote.writeCapability1571 try {1572 const alias = await persistence.getAlias(aliasId)1573 let state = alias.state1574 let bundleKey = null1575 if (alias.mode === 'locked') {1576 const unlocked = await promptToUnlock(alias)1577 if (!unlocked) return false1578 state = unlocked.state1579 bundleKey = unlocked.bundleKey1580 }1581 setRemoteAlias(alias, writeCapability, state, bundleKey)1582 removeDraft(`${ALIAS_DRAFT_PREFIX}${aliasId}`)1583 applyState(state, false)1584 updateSaveButton()1585 showToast('Reloaded published diagram')1586 return true1587 } catch (error) {1588 showToast(error instanceof Error ? error.message : 'Could not reload this diagram.')1589 return false1590 }1591 }1592 if (choice === 'copy') {1593 await createNewAlias(localState)1594 showToast('Saved as a new diagram')1595 return true1596 }1597 return false1598}15991600function askConflict() {1601 const dialog = document.querySelector('#conflict-dialog')1602 dialog.returnValue = ''1603 dialog.showModal()1604 return new Promise(resolve => {1605 dialog.addEventListener('close', () => resolve(dialog.returnValue), { once: true })1606 })1607}16081609function normalizeStoredState(value) {1610 if (!value || typeof value !== 'object' || !isKnownDiagramType(value.type) || typeof value.source !== 'string') {1611 return null1612 }1613 try {1614 return {1615 type: value.type,1616 source: value.source,1617 options: value.options ?? {},1618 meta: normalizeMetadata(value.meta),1619 presentation: normalizePresentation(value.presentation),1620 }1621 } catch {1622 return null1623 }1624}16251626function loadAliasDraft(key) {1627 try {1628 const draft = JSON.parse(localStorage.getItem(key))1629 const state = normalizeStoredState(draft?.state)1630 if (!state) return null1631 return {1632 state,1633 revision: draft.revision ?? null,1634 detailsSource: typeof draft.detailsSource === 'string' ? draft.detailsSource : null,1635 fileSnapshot: typeof draft.fileSnapshot === 'string' ? draft.fileSnapshot : null,1636 }1637 } catch {1638 return null1639 }1640}16411642function storeDraft(state) {1643 if (remote.mode === 'locked') return1644 if (!remote.aliasId) {1645 const savedId = localDocuments.saveLocalDocument({1646 state,1647 detailsSource: !detailsValid && detailsEditor ? detailsEditor.getValue() : null,1648 fileSnapshot: lastFileSnapshot,1649 drafts: {},1650 dirty: remote.dirty,1651 }, localDocumentId ? { localId: localDocumentId, currentLocalId: localDocumentId } : {})1652 if (!savedId) {1653 showToast('This diagram could not be saved on this device.', 4000)1654 return1655 }1656 if (!localDocumentId) {1657 localDocumentId = savedId1658 history.replaceState(null, '', `/?doc=${encodeURIComponent(localDocumentId)}`)1659 }1660 return1661 }1662 const key = `${ALIAS_DRAFT_PREFIX}${remote.aliasId}`1663 try {1664 const detailsSource = !detailsValid && detailsEditor1665 ? detailsEditor.getValue()1666 : null1667 localStorage.setItem(key, JSON.stringify({1668 state,1669 revision: remote.revision,1670 detailsSource,1671 fileSnapshot: remote.aliasId ? null : lastFileSnapshot,1672 }))1673 } catch {1674 // The editor remains usable when storage is unavailable.1675 }1676}16771678function syncStoredDraft(state) {1679 if (remote.mode === 'locked') return1680 if (!remote.aliasId) {1681 if (remote.dirty || localDocumentId) storeDraft(state)1682 return1683 }1684 const key = `${ALIAS_DRAFT_PREFIX}${remote.aliasId}`1685 if (remote.dirty) storeDraft(state)1686 else removeDraft(key)1687}16881689function removeDraft(key) {1690 try {1691 localStorage.removeItem(key)1692 } catch {1693 // Ignore unavailable storage.1694 }1695}16961697function storeWriteCapability(aliasId, writeCapability) {1698 try {1699 localStorage.setItem(`${WRITE_CAPABILITY_PREFIX}${aliasId}`, writeCapability)1700 } catch {1701 // The edit link can be copied even when persistent storage is unavailable.1702 }1703}17041705function loadWriteCapability(aliasId) {1706 try {1707 return writeCapabilityFromHash(`#w=${localStorage.getItem(`${WRITE_CAPABILITY_PREFIX}${aliasId}`) ?? ''}`)1708 } catch {1709 return null1710 }1711}17121713function setRemoteAlias(alias, writeCapability, state, bundleKey = null) {1714 remote = {1715 aliasId: alias.aliasId,1716 contentId: alias.contentId,1717 renderId: alias.renderId,1718 revision: alias.revision,1719 mode: alias.mode,1720 savedMode: alias.mode,1721 writeCapability,1722 savedState: state,1723 savedSnapshot: workingStateSnapshot(state, alias.mode),1724 bundleKey,1725 keyEnvelope: alias.mode === 'locked' ? alias.keyEnvelope : null,1726 encryptedContent: alias.mode === 'locked' ? alias.encryptedContent : null,1727 encryptedMetadata: alias.mode === 'locked' ? alias.encryptedMetadata : null,1728 keyEnvelopeDirty: false,1729 dirty: false,1730 }1731 localDocuments.upsertRecent({1732 kind: 'alias',1733 aliasId: alias.aliasId,1734 title: alias.mode === 'locked' ? null : normalizeMetadata(state.meta).title,1735 type: alias.mode === 'locked' ? '' : state.type,1736 mode: alias.mode,1737 })1738 updatePrivacyControls()1739 updateSaveButton()1740}17411742function updateSaveButton() {1743 updateDraftControls()1744 const indicator = document.querySelector('#unsaved-indicator')1745 const reset = document.querySelector('#quick-reset')1746 const dirty = remote.dirty || remote.keyEnvelopeDirty1747 indicator.dataset.dirty = String(dirty)1748 reset.dataset.visible = String(dirty)1749 reset.setAttribute('aria-hidden', String(!dirty))1750 reset.tabIndex = dirty ? 0 : -11751 reset.disabled = saving1752 const resetTarget = remote.aliasId && remote.savedState1753 ? 'last cloud save'1754 : `${diagramTypeLabel(typePicker.value)} template default`1755 reset.setAttribute('aria-label', `Reset to ${resetTarget}`)1756 reset.title = `Reset to ${resetTarget}`1757 if (document.querySelector('#command-dialog').open && commandPaletteMode === 'root') {1758 const query = document.querySelector('#command-input').value1759 commandPaletteModel = createCommandPaletteModel(commandGroups())1760 commandPaletteModel.selectFirst(query)1761 renderCommandPalette(false)1762 }1763}17641765function quickResetDocument() {1766 if (!(remote.dirty || remote.keyEnvelopeDirty) || saving) return1767 const cloudSaved = Boolean(remote.aliasId && remote.savedState)1768 const target = cloudSaved ? 'the last cloud save' : `the ${diagramTypeLabel(typePicker.value)} template default`1769 if (!confirm(`Reset to ${target}? Local changes will be discarded.`)) return1770 restoreSavedDiagram({ confirmed: true })1771}17721773function updateDraftControls() {1774 const bar = document.querySelector('#draft-bar')1775 const restore = document.querySelector('#restore-saved')1776 const makeCopy = document.querySelector('#make-copy')1777 const hasAliasOverlay = Boolean(remote.aliasId && remote.savedState && remote.dirty)1778 const hasAnonymousDraft = Boolean(!remote.aliasId && !localDocumentId && remote.dirty && lastFileSnapshot === null)1779 bar.hidden = !hasAliasOverlay && !hasAnonymousDraft1780 restore.disabled = saving1781 makeCopy.disabled = saving1782 makeCopy.hidden = hasAnonymousDraft1783 bar.dataset.anonymous = String(hasAnonymousDraft)1784 if (hasAnonymousDraft) {1785 const type = typePicker.selectedOptions[0]?.textContent ?? 'diagram'1786 bar.querySelector('p').textContent = `This device has changes to the ${type} example.`1787 restore.querySelector('span').textContent = ''1788 restore.setAttribute('aria-label', 'Reset')1789 restore.title = 'Reset'1790 } else {1791 bar.querySelector('p').textContent = 'This device has changes that are not part of the published share link.'1792 restore.querySelector('span').textContent = 'Restore published'1793 restore.removeAttribute('aria-label')1794 restore.removeAttribute('title')1795 }1796}17971798function restoreSavedDiagram({ confirmed = false } = {}) {1799 if (!remote.aliasId) {1800 resetAnonymousExample({ confirmed })1801 return1802 }1803 if (!remote.aliasId || !remote.savedState || !(remote.dirty || remote.keyEnvelopeDirty)) return1804 clearTimeout(renderTimer)1805 clearTimeout(detailsCommitTimer)1806 remote.mode = remote.savedMode1807 remote.keyEnvelopeDirty = false1808 remote.dirty = false1809 removeDraft(`${ALIAS_DRAFT_PREFIX}${remote.aliasId}`)1810 applyState(remote.savedState, false)1811 history.replaceState(null, '', urlWithDiagramType(location.href, remote.savedState.type))1812 updatePrivacyControls()1813 updateSaveButton()1814 showToast('Restored published diagram')1815}18161817function resetAnonymousExample({ confirmed = false } = {}) {1818 if (remote.aliasId || !remote.dirty) return1819 if (!confirmed && !confirm('Discard local changes and reset this example?')) return1820 clearTimeout(renderTimer)1821 clearTimeout(detailsCommitTimer)1822 if (localDocumentId) localDocuments.deleteLocalDocument(localDocumentId)1823 localDocumentId = null1824 const state = templateStateFor(typePicker.value)1825 applyState(state, false)1826 remote.dirty = false1827 history.replaceState(null, '', urlWithDiagramType(location.href, state.type))1828 updateSaveButton()1829 showToast('Example reset')1830}18311832async function makeDraftCopy() {1833 if (saving || !remote.aliasId || !remote.dirty) return1834 if (!ensureValidDetails()) return1835 commitState(false)1836 const state = currentState()1837 saving = true1838 updateSaveButton()1839 try {1840 await createNewAlias(state)1841 showToast('Published as a new diagram')1842 } catch (error) {1843 showToast(error instanceof Error ? error.message : 'Could not make a copy.')1844 } finally {1845 saving = false1846 updateSaveButton()1847 }1848}18491850function updatePrivacyControls() {1851 const locked = remote.mode === 'locked'1852 document.querySelector('#privacy-label').textContent = locked ? 'Password locked' : 'Open'1853 document.querySelector('#privacy-description').textContent = locked1854 ? 'Source, metadata, and published renders are encrypted in your browser. Password-locked diagrams cannot be embedded.'1855 : 'Anyone with the read link can open and embed this diagram.'1856 document.querySelector('#lock-diagram').hidden = locked1857 document.querySelector('#change-password').hidden = !locked1858 document.querySelector('#unlock-diagram').hidden = !locked1859}18601861function emptyRemoteState(overrides = {}) {1862 return {1863 aliasId: null,1864 contentId: null,1865 renderId: null,1866 revision: null,1867 mode: 'open',1868 savedMode: null,1869 writeCapability: null,1870 savedState: null,1871 savedSnapshot: null,1872 bundleKey: null,1873 keyEnvelope: null,1874 encryptedContent: null,1875 encryptedMetadata: null,1876 keyEnvelopeDirty: false,1877 dirty: true,1878 ...overrides,1879 }1880}18811882function updateDocumentMetadata() {1883 const title = detailsState.meta.title.trim()1884 const description = detailsState.meta.description.trim()1885 if (titleInput.value !== detailsState.meta.title) titleInput.value = detailsState.meta.title1886 document.title = documentTitle(title)1887 document.querySelector('meta[name="description"]').content = description || 'Create diagrams from text and share them as a link.'1888}18891890async function copy(value) {1891 if (navigator.clipboard) {1892 try {1893 await navigator.clipboard.writeText(value)1894 return1895 } catch {1896 // Fall back for browsers that expose Clipboard without granting write access.1897 }1898 }1899 const textarea = document.createElement('textarea')1900 textarea.value = value1901 textarea.style.position = 'fixed'1902 textarea.style.opacity = '0'1903 document.body.append(textarea)1904 textarea.select()1905 const copied = document.execCommand('copy')1906 textarea.remove()1907 if (!copied) throw new Error('Clipboard access is unavailable.')1908}19091910function showToast(message, duration = 1800) {1911 const toast = document.querySelector('#toast')1912 toast.textContent = message1913 clearTimeout(showToast.hideTimeout)1914 if (typeof toast.showPopover === 'function' && !toast.matches(':popover-open')) toast.showPopover()1915 toast.dataset.visible = 'true'1916 clearTimeout(showToast.timeout)1917 showToast.timeout = setTimeout(() => {1918 delete toast.dataset.visible1919 showToast.hideTimeout = setTimeout(() => {1920 if (typeof toast.hidePopover === 'function' && toast.matches(':popover-open')) toast.hidePopover()1921 }, 160)1922 }, duration)1923}19241925commitState()1926void refreshSavedRenderIfStale()1927if (initialLoadError) showToast(initialLoadError)