Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/escape-label-restore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@visimer/dom": patch
---

Cancelling an in-place label edit with Escape now restores the label exactly as mermaid rendered it. Before, the canvas parsed the raw label text from the diagram source as HTML, which skipped mermaid's sanitizer. If your canvas renders diagrams that you did not write, upgrade.
44 changes: 24 additions & 20 deletions packages/dom/src/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,21 +270,16 @@ function readLabelHtml(label: HTMLElement): string {
.trim()
}

/**
* DOM-faithful read of the same label, used ONLY for the mid-flight snapshot
* that a mermaid re-render immediately reinjects into the freshly rendered
* label (`label.innerHTML = liveEdit.value`). The snapshot has to preserve
* exactly what was on screen: an in-flight render that resolves the same
* instant the user types a trailing space would otherwise lose that space
* to `readLabelHtml`'s canonicalisation + trim, and the next keystroke
* would land against the previous word. `<br/>` normalisation is still
* safe here because mermaid emits `<br/>` in the source and both browsers
* accept either serialisation.
*/
function readLabelSnapshot(label: HTMLElement): string {
return (label.innerHTML ?? '').replace(/<br\s*\/?>/gi, '<br/>')
}

interface LiveEdit {
value: string
nodes: Node[]
caret: number
}

/**
* Absolute caret offset within `root`, counted across all of its text nodes.
* `Selection.anchorOffset` alone is relative to the anchor node — a label that
Expand Down Expand Up @@ -391,6 +386,8 @@ export class MermaidCanvasView {
entityId: string
labelSelector: string
original: string
originalNodes: Node[]
openedOn: HTMLElement
lastCommitted: string
caretOffset: number
commitValue: (v: string) => void
Expand Down Expand Up @@ -809,15 +806,15 @@ export class MermaidCanvasView {
// destroys them, then reinject below so no keystroke is lost
// a session may have OPENED while the render was in flight — re-read it
// through the accessor so the early-return narrowing above doesn't apply
let liveEdit: { value: string; caret: number } | null = null
let liveEdit: LiveEdit | null = null
const midFlightSession = this.activeInPlaceSession()
const editingLabel = midFlightSession?.label
if (midFlightSession && editingLabel?.isConnected) {
// Snapshot the label's raw innerHTML (DOM-faithful, no trim, no
// `&nbsp;` canonicalisation) so `resumeInPlaceSession` below can
// reinject exactly what was on screen. Using `readLabelHtml` here
// would strip a just-typed trailing space out of the snapshot.
liveEdit = { value: readLabelSnapshot(editingLabel), caret: caretOffsetWithin(editingLabel) }
liveEdit = {
value: readLabelSnapshot(editingLabel),
nodes: [...editingLabel.childNodes],
caret: caretOffsetWithin(editingLabel),
}
// a pending live-commit timer closes over the label we are about to
// detach; the reinjection below reschedules it against the new one
if (midFlightSession.liveTimer) {
Expand Down Expand Up @@ -1418,6 +1415,8 @@ export class MermaidCanvasView {
entityId,
labelSelector,
original: current,
originalNodes: [...label.childNodes].map((node) => node.cloneNode(true)),
openedOn: label,
lastCommitted: current,
caretOffset: -1,
commitValue,
Expand Down Expand Up @@ -1481,8 +1480,13 @@ export class MermaidCanvasView {
// states. Restore the label DOM directly too — when the reverted code
// equals the last rendered code, render() short-circuits and would
// leave the typed text on screen.
label.innerHTML = session.original
// STOP: never parse session.original as HTML; it is unsanitized diagram source.
label.replaceChildren(...session.originalNodes)
if (session.lastCommitted !== session.original) session.commitValue(session.original)
if (label !== session.openedOn) {
this.lastRenderedCode = ''
this.scheduleRender()
}
}
// apply any canvas render held back while the session was typing
if (this.renderHeldByEdit) {
Expand Down Expand Up @@ -1527,7 +1531,7 @@ export class MermaidCanvasView {
}

/** re-attach a live in-place editing session after a render replaced the DOM */
private resumeInPlaceSession(liveEdit?: { value: string; caret: number } | null) {
private resumeInPlaceSession(liveEdit?: LiveEdit | null) {
const session = this.inPlaceSession
if (!session) return
if (!this.editor.entityExists(session.entityId)) {
Expand All @@ -1554,7 +1558,7 @@ export class MermaidCanvasView {
liveEdit.value !== session.lastCommitted &&
liveEdit.value !== readLabelSnapshot(label)
) {
label.innerHTML = liveEdit.value
label.replaceChildren(...liveEdit.nodes)
// run the carried-over delta through the normal live-commit debounce
label.dispatchEvent(new Event('input'))
}
Expand Down
75 changes: 73 additions & 2 deletions packages/dom/test/inplace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const CODE = 'flowchart TD\n A[Write a doc] --> B[End]\n'
* Set `gate` to hold the next render un-resolved — that models the async gap
* between a live commit and the SVG swap landing.
*/
function makeFakeMermaid() {
function makeFakeMermaid(renderLabel: (label: string) => string = (label) => label) {
let release: (() => void) | null = null
const fake: MermaidLike & { gate(): void; release(): void } = {
initialize() {},
Expand All @@ -41,7 +41,7 @@ function makeFakeMermaid() {
...nodes.map(
([, id, label], i) =>
`<g class="node" id="flowchart-${id}-${i}"><g class="label"><foreignObject width="80" height="24">` +
`<div xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"><p>${label}</p></span></div>` +
`<div xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"><p>${renderLabel(label)}</p></span></div>` +
`</foreignObject></g></g>`,
),
'</g>',
Expand Down Expand Up @@ -223,6 +223,77 @@ describe('in-place label editing survives the live-commit re-render', () => {
// rendered code, in which case no re-render will repaint it
expect(labelOf(container, 'node:A').textContent).toBe('Write a doc')
})

it('Escape after an in-flight render swapped the label repaints it from source', async () => {
mermaid.gate()
editor.dispatch({ type: 'renameNode', id: 'A', label: 'Renamed' })
await vi.advanceTimersByTimeAsync(1)

view.editEntityLabel('node:A')
const openedOn = labelOf(container, 'node:A')
mermaid.release()
for (let i = 0; i < 5; i++) await Promise.resolve()
await vi.advanceTimersByTimeAsync(0)

const swapped = labelOf(container, 'node:A')
expect(swapped).not.toBe(openedOn)
expect(swapped.getAttribute('contenteditable')).toBe('true')
swapped.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await vi.advanceTimersByTimeAsync(500)
expect(editor.code).toContain('A[Renamed]')
expect(labelOf(container, 'node:A').textContent).toBe('Renamed')
})
})

describe('Escape restores what mermaid rendered, never the raw source label', () => {
let editor: MermaidWysiwygEditor
let container: HTMLElement
let view: MermaidCanvasView

const escapeHtml = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')

beforeEach(async () => {
vi.useFakeTimers()
editor = new MermaidWysiwygEditor({ code: 'flowchart TD\n A[<i>raw</i>] --> B[End]\n' })
container = document.createElement('div')
document.body.appendChild(container)
view = new MermaidCanvasView({ editor, container, mermaid: makeFakeMermaid(escapeHtml), debounceMs: 0 })
await view.render()
})

afterEach(() => {
view.destroy()
container.remove()
vi.useRealTimers()
})

it('cancelling an untouched edit does not parse the source label as HTML', async () => {
view.editEntityLabel('node:A')
const label = labelOf(container, 'node:A')
label.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
expect(label.querySelector('i')).toBeNull()

await vi.advanceTimersByTimeAsync(500)
expect(labelOf(container, 'node:A')).toBe(label)
expect(label.querySelector('i')).toBeNull()
expect(label.textContent).toBe('<i>raw</i>')
})

it('cancelling after a live commit does not parse the source label as HTML', async () => {
view.editEntityLabel('node:A')
typeInto(labelOf(container, 'node:A'), 'Half typed')
await vi.advanceTimersByTimeAsync(500)
expect(editor.code).toContain('A[Half typed]')

const label = labelOf(container, 'node:A')
label.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
expect(label.querySelector('i')).toBeNull()
expect(label.textContent).toBe('<i>raw</i>')

await vi.advanceTimersByTimeAsync(500)
expect(editor.code).toContain('<i>raw</i>')
expect(editor.code).not.toContain('Half typed')
})
})

/**
Expand Down