- synchronize the header title with the Details JSON in both directions\n- preserve validation, draft persistence, and keyboard editing behavior\n- highlight the complete save split button when changes are pending
- commit
- 711c0b669fd94d904540b0d2a1a8e4e5307ef666
- parent
- 5f4adc6ba8fb340cf3fb0a0bf513a42241350e7e
- author
- Dami <dami.etoile@gmail.com>
| M | apps/ | 2 | + |
| M | apps/ | 2 | + |
| M | apps/ | 10 | ++++++ |
| M | apps/ | 47 | ++++++++++++++++++++++++++++-- |
| M | apps/ | 36 | ++++++++++++++++++++--- |
| M | apps/ | 9 | ++++++ |
@@ -45,6 +45,8 @@ Saved read and edit links remain alias-based and do not depend on this query.
4545 4646 The left pane has **Source** and **Details** tabs. Source contains the selected diagram language. Details contains validated JSON for metadata and presentation.4747 48+Edit the title directly in the top bar for a faster naming workflow. The value stays synchronized with `title` in Details.49+4850 The diagnostics dock below the editors reports Details validation. Invalid Details JSON keeps the last valid preview and blocks actions that require valid state.4951 5052 The preview updates as you edit. A source syntax error can stop rendering and appears in a short-lived toast at the lower right.@@ -47,6 +47,8 @@ Saved read and edit links remain alias-based and do not depend on this query.
4747 4848 The left pane has **Source** and **Details** tabs. Source contains the selected diagram language. Details contains validated JSON for metadata and presentation.4949 50+Edit the title directly in the top bar for a faster naming workflow. The value stays synchronized with `title` in Details.51+5052 The diagnostics dock below the editors reports Details validation. Invalid Details JSON keeps the last valid preview and blocks actions that require valid state.5153 5254 The preview updates as you edit. A source syntax error can stop rendering and appears in a short-lived toast at the lower right.@@ -67,6 +67,16 @@ export function serializeDetailsDocument(state) {
6767 return `${JSON.stringify(detailsDocumentFor(state), null, 2)}\n`6868 }6969 70+export function detailsStateWithTitle(state, title) {71+ if (typeof title !== 'string' || title.length > 200) {72+ throw new Error('title must be a string with at most 200 characters.')73+ }74+ return {75+ meta: normalizeMetadata({ ...state.meta, title }),76+ presentation: normalizePresentation(state.presentation),77+ }78+}79+7080 export function parseDetailsDocument(source) {7181 let value7282 try {@@ -3,6 +3,7 @@ import { exampleStateFor } from './examples.js'
33 import { refreshMatchingExampleMetadata } from './example-variants.js'44 import {55 DETAILS_MODEL_URI,6+ detailsStateWithTitle,67 parseDetailsDocument,78 serializeDetailsDocument,89 } from './details-document.js'@@ -60,7 +61,7 @@ document.querySelector('#app').innerHTML = `
6061 <header class="app-header">6162 <div class="document-identity">6263 <a class="brand" href="/" aria-label="New diagram" title="New diagram"><img class="brand-mark" src="/icon.svg?v=4" alt=""></a>63- <span class="document-title" id="diagram-title">Untitled</span>64+ <input class="document-title" id="diagram-title" type="text" maxlength="200" placeholder="Untitled" aria-label="Diagram title" autocomplete="off" spellcheck="false">6465 </div>6566 <div class="header-meta">6667 <span class="render-status" data-state="idle" role="status">Ready</span>@@ -289,7 +290,7 @@ document.querySelector('#app').innerHTML = `
289290 `290291 291292 const typePicker = document.querySelector('#diagram-type')292-const titleLabel = document.querySelector('#diagram-title')293+const titleInput = document.querySelector('#diagram-title')293294 const previewRawButton = document.querySelector('#preview-raw')294295 const previewTransparentButton = document.querySelector('#preview-transparent')295296 const previewThemeButton = document.querySelector('#preview-theme-toggle')@@ -374,6 +375,10 @@ document.querySelector('#save').addEventListener('click', performPrimarySaveActi
374375 document.querySelector('#restore-saved').addEventListener('click', restoreSavedDiagram)375376 document.querySelector('#make-copy').addEventListener('click', makeDraftCopy)376377 document.querySelector('#share').addEventListener('click', openShareDialog)378+titleInput.addEventListener('focus', beginTitleEdit)379+titleInput.addEventListener('input', updateTitleFromHeader)380+titleInput.addEventListener('blur', finishTitleEdit)381+titleInput.addEventListener('keydown', handleTitleKeydown)377382 document.querySelectorAll('[data-primary-save]').forEach(button => {378383 button.addEventListener('click', () => {379384 primarySaveAction = button.dataset.primarySave@@ -632,6 +637,42 @@ function scheduleDetailsUpdate() {
632637 }, 220)633638 }634639 640+let titleEditBaseline = ''641+642+function beginTitleEdit() {643+ if (!detailsValid) {644+ titleInput.blur()645+ ensureValidDetails()646+ return647+ }648+ titleEditBaseline = detailsState.meta.title649+}650+651+function updateTitleFromHeader() {652+ if (!detailsValid) return653+ clearTimeout(detailsCommitTimer)654+ setDetailsState(detailsStateWithTitle(detailsState, titleInput.value))655+ detailsCommitTimer = setTimeout(commitState, 220)656+}657+658+function finishTitleEdit() {659+ if (!detailsValid || titleInput.value === titleEditBaseline) return660+ clearTimeout(detailsCommitTimer)661+ commitState()662+}663+664+function handleTitleKeydown(event) {665+ if (event.key === 'Enter') {666+ event.preventDefault()667+ titleInput.blur()668+ } else if (event.key === 'Escape') {669+ event.preventDefault()670+ clearTimeout(detailsCommitTimer)671+ setDetailsState(detailsStateWithTitle(detailsState, titleEditBaseline))672+ titleInput.blur()673+ }674+}675+635676 function validateDetailsDocument(showError = false) {636677 try {637678 detailsState = parseDetailsDocument(detailsEditor.getValue())@@ -1567,7 +1608,7 @@ function emptyRemoteState(overrides = {}) {
15671608 function updateDocumentMetadata() {15681609 const title = detailsState.meta.title.trim()15691610 const description = detailsState.meta.description.trim()1570- titleLabel.textContent = title || 'Untitled'1611+ if (titleInput.value !== detailsState.meta.title) titleInput.value = detailsState.meta.title15711612 document.title = documentTitle(title)15721613 document.querySelector('meta[name="description"]').content = description || 'Create diagrams from text and share them as a link.'15731614 }@@ -155,7 +155,10 @@ button { cursor: pointer; }
155155 display: block;156156 width: min(38vw, 420px);157157 min-width: 90px;158- padding: 4px 7px;158+ padding: 3px 6px;159+ border: 1px solid transparent;160+ border-radius: 5px;161+ background: transparent;159162 color: var(--ink);160163 font-size: 22px;161164 font-weight: 440;@@ -165,6 +168,16 @@ button { cursor: pointer; }
165168 white-space: nowrap;166169 }167170 171+.document-title:hover { border-color: var(--line); }172+173+.document-title:focus {174+ border-color: var(--muted);175+ background: var(--page);176+ outline: 0;177+}178+179+.document-title::placeholder { color: var(--muted); opacity: 1; }180+168181 .header-meta {169182 display: flex;170183 align-items: center;@@ -268,10 +281,15 @@ button { cursor: pointer; }
268281 .save-split {269282 display: flex;270283 align-items: stretch;284+ border: 1px solid var(--line);285+ border-radius: 10px;286+ background: var(--surface);287+ overflow: hidden;271288 }272289 273290 .save-split #save {274- border-radius: 10px 0 0 10px;291+ border: 0;292+ border-radius: 0;275293 }276294 277295 .save-menu-toggle {@@ -280,8 +298,9 @@ button { cursor: pointer; }
280298 height: 40px;281299 min-height: 40px;282300 padding: 0;283- border-left: 0;284- border-radius: 0 10px 10px 0;301+ border: 0;302+ border-left: 1px solid var(--line);303+ border-radius: 0;285304 font-size: 14px;286305 line-height: 1;287306 }@@ -332,13 +351,20 @@ button { cursor: pointer; }
332351 .header-icon-action:hover { background: var(--soft); }333352 .header-icon-action:disabled { cursor: default; opacity: .58; }334353 335-#save[data-dirty="true"] {354+.save-split:has(#save[data-dirty="true"]) {336355 border-color: var(--unsaved);337356 box-shadow: inset 0 0 0 1px var(--unsaved);338357 }339358 340359 #save[data-dirty="true"]:disabled { opacity: .72; }341360 361+.save-split:has(:focus-visible) {362+ outline: 2px solid var(--accent-ink);363+ outline-offset: 2px;364+}365+366+.save-split :focus-visible { outline: 0; }367+342368 .header-action-icon {343369 width: 20px;344370 height: 20px;@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
22 import test from 'node:test'33 import {44 detailsDocumentFor,5+ detailsStateWithTitle,56 parseDetailsDocument,67 serializeDetailsDocument,78 } from '../src/details-document.js'@@ -42,3 +43,11 @@ test('communicates metadata and presentation restrictions', () => {
4243 presentation: { ...state.presentation, padding: 257 },4344 })), /invalid appearance/)4445 })46+47+test('updates the title without changing other details', () => {48+ assert.deepEqual(detailsStateWithTitle(state, 'Renamed'), {49+ meta: { title: 'Renamed', description: state.meta.description },50+ presentation: state.presentation,51+ })52+ assert.throws(() => detailsStateWithTitle(state, 'x'.repeat(201)), /at most 200/)53+})