From af7fff1598094c0bd08d264b98db83d4e10a268b Mon Sep 17 00:00:00 2001 From: Usman Khalid Date: Thu, 16 Jul 2026 09:47:32 -0400 Subject: [PATCH] feat(ew): sync layout-mode block & image selection with the doc --- blocks/canvas/editor-utils/editor-utils.js | 15 +++ blocks/canvas/ew-editor-doc/ew-editor-doc.js | 20 ++- .../canvas/ew-editor-doc/utils/selection.js | 13 ++ .../quick-edit-controller.js | 3 + .../ew-editor-wysiwyg/utils/handlers.js | 80 ++++++++++- .../editor-utils/instrument-images.test.js | 54 ++++++++ .../ew-editor-doc/utils/selection.test.js | 46 +++++++ .../ew-editor-wysiwyg/node-select.test.js | 126 ++++++++++++++++++ 8 files changed, 354 insertions(+), 3 deletions(-) create mode 100644 test/unit/blocks/canvas/editor-utils/instrument-images.test.js create mode 100644 test/unit/blocks/canvas/ew-editor-wysiwyg/node-select.test.js diff --git a/blocks/canvas/editor-utils/editor-utils.js b/blocks/canvas/editor-utils/editor-utils.js index 906d6f83..d06e161c 100644 --- a/blocks/canvas/editor-utils/editor-utils.js +++ b/blocks/canvas/editor-utils/editor-utils.js @@ -192,6 +192,21 @@ export function getInstrumentedHTML(view) { div.insertAdjacentElement('beforebegin', blockMarker); }); + const originalImages = view.dom.querySelectorAll('img'); + const clonedImages = editorClone.querySelectorAll('img'); + originalImages.forEach((originalImage, index) => { + if (originalImage.matches('.ProseMirror-separator, .ProseMirror-trailingBreak')) return; + const clonedImage = clonedImages[index]; + if (!clonedImage) return; + try { + const pos = view.posAtDOM(originalImage, 0); + clonedImage.setAttribute('data-image-index', pos); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Could not find position for image:', e); + } + }); + const remoteCursors = editorClone.querySelectorAll('.ProseMirror-yjs-cursor'); remoteCursors.forEach((remoteCursor) => { diff --git a/blocks/canvas/ew-editor-doc/ew-editor-doc.js b/blocks/canvas/ew-editor-doc/ew-editor-doc.js index 9cacd548..8e9e2ffa 100644 --- a/blocks/canvas/ew-editor-doc/ew-editor-doc.js +++ b/blocks/canvas/ew-editor-doc/ew-editor-doc.js @@ -13,7 +13,7 @@ import { editorDocRenderPhase, } from './utils/ctx.js'; import { subscribeCollabUserList } from './utils/awareness-users.js'; -import { describeDocSelection, applyHighlight, SEL_BLOCK } from './utils/selection.js'; +import { describeDocSelection, applyHighlight, SEL_BLOCK, selectedNodePayload } from './utils/selection.js'; import { prefetchWysiwygCookiesIfSignedIn, wireQuickEditControllerPort, @@ -48,6 +48,7 @@ export class EwEditorDoc extends LitElement { this._error = undefined; this._lastDocBlockIndex = undefined; this._lastDocSelKey = undefined; + this._lastBroadcastNodeKey = undefined; editorHtmlChange.emit(''); } } @@ -115,6 +116,18 @@ export class EwEditorDoc extends LitElement { view.dispatch(view.state.tr.setSelection(sel).scrollIntoView()); } + _broadcastSelectedNode(scrollIntoView = false) { + const port = this._controllerCtx?.port; + const { view } = this._proseContext ?? {}; + if (!port || !view) return; + const node = selectedNodePayload(view); + const key = node ? `${node.anchorType}:${node.proseIndex}` : 'null'; + const forceScroll = scrollIntoView && Boolean(node); + if (!forceScroll && key === this._lastBroadcastNodeKey) return; + this._lastBroadcastNodeKey = key; + port.postMessage({ type: 'set-selected-node', node, scrollIntoView: forceScroll }); + } + undo() { const { view } = this._proseContext ?? {}; if (view) yUndo(view.state, view.dispatch); @@ -226,6 +239,7 @@ export class EwEditorDoc extends LitElement { explicit: descriptor.selectionType === SEL_BLOCK, ...descriptor, }); + this._broadcastSelectedNode(true); }, ), ], @@ -266,7 +280,9 @@ export class EwEditorDoc extends LitElement { this.parentElement?.addEventListener('nx-wysiwyg-port-ready', this._onWysiwygPortReady); this._unsubscribeSelect = editorSelectChange .subscribe(({ blockIndex, source }) => { - if (source !== 'doc') this._scrollDocToBlock(blockIndex); + if (source === 'doc') return; + this._scrollDocToBlock(blockIndex); + if (source === 'outline') this._broadcastSelectedNode(true); }); this._onCanvasHighlight = (e) => this._applyHighlight(e.detail); document.addEventListener('nx-highlight-selection', this._onCanvasHighlight); diff --git a/blocks/canvas/ew-editor-doc/utils/selection.js b/blocks/canvas/ew-editor-doc/utils/selection.js index a4317584..dee1d277 100644 --- a/blocks/canvas/ew-editor-doc/utils/selection.js +++ b/blocks/canvas/ew-editor-doc/utils/selection.js @@ -55,6 +55,19 @@ export function describeDocSelection(view) { }; } +export function selectedNodePayload(view) { + const sel = view?.state?.selection; + if (!(sel instanceof NodeSelection)) return null; + const name = sel.node?.type?.name; + if (name === 'table' && sel.$from.depth === 0) { + return { anchorType: 'table', proseIndex: sel.from + 1 }; + } + if (name === 'image') { + return { anchorType: 'image', proseIndex: sel.from, src: sel.node?.attrs?.src ?? '' }; + } + return null; +} + export function applyHighlight(view, { selFrom, selTo, selectionType } = {}) { if (!view || typeof selFrom !== 'number' || typeof selTo !== 'number') return; const { doc } = view.state; diff --git a/blocks/canvas/ew-editor-wysiwyg/quick-edit-controller.js b/blocks/canvas/ew-editor-wysiwyg/quick-edit-controller.js index c0bda296..5b051a3e 100644 --- a/blocks/canvas/ew-editor-wysiwyg/quick-edit-controller.js +++ b/blocks/canvas/ew-editor-wysiwyg/quick-edit-controller.js @@ -5,6 +5,7 @@ import { handleUndoRedo, handleNewVersion, handleIframeSelectionChange, + handleNodeSelect, } from './utils/handlers.js'; export function createControllerOnMessage(ctx) { @@ -25,6 +26,8 @@ export function createControllerOnMessage(ctx) { handleNewVersion(); } else if (e.data.type === 'selection-change') { handleIframeSelectionChange(e.data, ctx); + } else if (e.data.type === 'node-select') { + handleNodeSelect(e.data, ctx); } }; } diff --git a/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js b/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js index f7a52882..d0bc9114 100644 --- a/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js +++ b/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js @@ -1,4 +1,4 @@ -import { TextSelection, yUndo, yRedo } from 'da-y-wrapper'; +import { TextSelection, NodeSelection, yUndo, yRedo } from 'da-y-wrapper'; import { getSelectionToolbar, NX_QUICK_EDIT_IFRAME_SELECTION_META, @@ -172,3 +172,81 @@ export function handleIframeSelectionChange(data, ctx) { showToolbarInIFrame(ctx); } + +function srcFileName(src) { + if (!src) return ''; + const bare = String(src).split('?')[0].split('#')[0]; + return bare.split('/').pop() || ''; +} + +function findImagePosBySrc(doc, src, blockIndex) { + const name = srcFileName(src); + if (!name) return null; + let from = 0; + let to = doc.content.size; + if (blockIndex != null) { + const tablePos = blockIndex - 1; + const table = tablePos >= 0 ? doc.nodeAt(tablePos) : null; + if (table?.type.name === 'table') { + from = tablePos; + to = tablePos + table.nodeSize; + } + } + let found = null; + doc.nodesBetween(from, to, (n, pos) => { + if (found != null) return false; + if (n.type.name === 'image' && srcFileName(n.attrs?.src) === name) { + found = pos; + return false; + } + return true; + }); + return found; +} + +export function resolveNodeSelectPos(node, doc) { + if (!node) return null; + if (node.anchorType === 'table') { + const pos = node.proseIndex - 1; + if (pos < 0 || pos > doc.content.size) return null; + return doc.nodeAt(pos)?.type.name === 'table' ? pos : null; + } + if (node.anchorType === 'image') { + const pos = node.proseIndex; + if (pos != null && pos >= 0 && pos <= doc.content.size + && doc.nodeAt(pos)?.type.name === 'image') { + return pos; + } + return node.src ? findImagePosBySrc(doc, node.src, node.blockIndex) : null; + } + return null; +} + +export function handleNodeSelect({ node }, ctx) { + const { view } = ctx; + if (!view) return; + const { state } = view; + try { + if (!node) { + const tr = state.tr + .setSelection(TextSelection.near(state.doc.resolve(state.selection.from), 1)) + .setMeta('addToHistory', false); + ctx.suppressRerender = true; + view.dispatch(tr); + ctx.suppressRerender = false; + return; + } + const pos = resolveNodeSelectPos(node, state.doc); + if (pos == null) return; + const tr = state.tr + .setSelection(NodeSelection.create(state.doc, pos)) + .scrollIntoView() + .setMeta('addToHistory', false); + ctx.suppressRerender = true; + view.dispatch(tr); + ctx.suppressRerender = false; + } catch (e) { + // eslint-disable-next-line no-console + console.error('[quick-edit-controller] handleNodeSelect failed', e?.message); + } +} diff --git a/test/unit/blocks/canvas/editor-utils/instrument-images.test.js b/test/unit/blocks/canvas/editor-utils/instrument-images.test.js new file mode 100644 index 00000000..3ec2c2c2 --- /dev/null +++ b/test/unit/blocks/canvas/editor-utils/instrument-images.test.js @@ -0,0 +1,54 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../scripts/utils.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +const { createTestEditor, destroyEditor } = await import('../../edit/prose/test-helpers.js'); +const { getInstrumentedHTML } = await import('../../../../../blocks/canvas/editor-utils/editor-utils.js'); + +function insertImages(editor) { + const { state } = editor.view; + const { schema } = state; + const standalone = schema.nodes.paragraph.create( + null, + schema.nodes.image.create({ src: '/standalone.png' }), + ); + const inline = schema.nodes.paragraph.create( + null, + [schema.text('hello '), schema.nodes.image.create({ src: '/inline.png' })], + ); + editor.view.dispatch(state.tr.replaceWith(0, state.doc.content.size, [standalone, inline])); + + const froms = []; + editor.view.state.doc.descendants((node, pos) => { + if (node.type.name === 'image') froms.push(pos); + }); + return froms; +} + +describe('getInstrumentedHTML image instrumentation', () => { + let editor; + beforeEach(async () => { editor = await createTestEditor(); }); + afterEach(() => destroyEditor(editor)); + + it('stamps every content image with a data-image-index that resolves to the image node', () => { + const imageFroms = insertImages(editor); + expect(imageFroms.length).to.equal(2); + + const html = getInstrumentedHTML(editor.view); + const parsed = new DOMParser().parseFromString(html, 'text/html'); + const stamped = [...parsed.querySelectorAll('img[data-image-index]')]; + + expect(stamped.length).to.equal(2); + + const { doc } = editor.view.state; + stamped.forEach((img) => { + const idx = Number(img.getAttribute('data-image-index')); + expect(Number.isNaN(idx)).to.equal(false); + const node = doc.nodeAt(idx); + expect(node, `nodeAt(${idx}) should be an image`).to.not.equal(null); + expect(node.type.name).to.equal('image'); + expect(imageFroms).to.include(idx); + }); + }); +}); diff --git a/test/unit/blocks/canvas/ew-editor-doc/utils/selection.test.js b/test/unit/blocks/canvas/ew-editor-doc/utils/selection.test.js index 2554e6fe..04fcb322 100644 --- a/test/unit/blocks/canvas/ew-editor-doc/utils/selection.test.js +++ b/test/unit/blocks/canvas/ew-editor-doc/utils/selection.test.js @@ -4,6 +4,7 @@ import { createTestEditor, destroyEditor } from '../../../edit/prose/test-helper import { describeDocSelection, applyHighlight, + selectedNodePayload, SEL_BLOCK, SEL_ITEM, SEL_TEXT, @@ -149,3 +150,48 @@ describe('applyHighlight', () => { expect(editor.view.state.selection).to.be.instanceOf(NodeSelection); }); }); + +describe('selectedNodePayload', () => { + let editor; + beforeEach(async () => { editor = await createTestEditor(); }); + afterEach(() => { destroyEditor(editor); }); + + it('returns null for a caret/text selection', () => { + expect(selectedNodePayload(editor.view)).to.equal(null); + editor.view.dispatch(editor.view.state.tr.insertText('hello')); + const sel = TextSelection.create(editor.view.state.doc, 1, 4); + editor.view.dispatch(editor.view.state.tr.setSelection(sel)); + expect(selectedNodePayload(editor.view)).to.equal(null); + }); + + it('returns a table payload (proseIndex = from + 1) for a block NodeSelection', () => { + const { state } = editor.view; + const { schema } = state; + const para = schema.nodes.paragraph.create(null, schema.text('cards')); + const cell = schema.nodes.table_cell.create({ colspan: 2, colwidth: null }, para); + const row = schema.nodes.table_row.create(null, cell); + const table = schema.nodes.table.create(null, row); + editor.view.dispatch(state.tr.replaceWith(0, state.doc.content.size, table)); + let tablePos = -1; + editor.view.state.doc.descendants((node, pos) => { + if (node.type.name === 'table' && tablePos < 0) tablePos = pos; + }); + const sel = NodeSelection.create(editor.view.state.doc, tablePos); + editor.view.dispatch(editor.view.state.tr.setSelection(sel)); + expect(selectedNodePayload(editor.view)).to.deep.equal({ anchorType: 'table', proseIndex: tablePos + 1 }); + }); + + it('returns an image payload (proseIndex = from) for an image NodeSelection', () => { + const { state } = editor.view; + const { schema } = state; + const para = schema.nodes.paragraph.create(null, schema.nodes.image.create({ src: '/x.png' })); + editor.view.dispatch(state.tr.replaceWith(0, state.doc.content.size, para)); + let imgPos = -1; + editor.view.state.doc.descendants((node, pos) => { + if (node.type.name === 'image') imgPos = pos; + }); + const sel = NodeSelection.create(editor.view.state.doc, imgPos); + editor.view.dispatch(editor.view.state.tr.setSelection(sel)); + expect(selectedNodePayload(editor.view)).to.deep.equal({ anchorType: 'image', proseIndex: imgPos, src: '/x.png' }); + }); +}); diff --git a/test/unit/blocks/canvas/ew-editor-wysiwyg/node-select.test.js b/test/unit/blocks/canvas/ew-editor-wysiwyg/node-select.test.js new file mode 100644 index 00000000..429f85f2 --- /dev/null +++ b/test/unit/blocks/canvas/ew-editor-wysiwyg/node-select.test.js @@ -0,0 +1,126 @@ +import { expect } from '@esm-bundle/chai'; +import { NodeSelection, TextSelection } from 'da-y-wrapper'; +import { setNx } from '../../../../../scripts/utils.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +const { createTestEditor, destroyEditor } = await import('../../edit/prose/test-helpers.js'); +const { handleNodeSelect, resolveNodeSelectPos } = await import('../../../../../blocks/canvas/ew-editor-wysiwyg/utils/handlers.js'); + +function insertTable(editor) { + const { state } = editor.view; + const { schema } = state; + const para = schema.nodes.paragraph.create(null, schema.text('cards')); + const cell = schema.nodes.table_cell.create({ colspan: 2, colwidth: null }, para); + const row = schema.nodes.table_row.create(null, cell); + const table = schema.nodes.table.create(null, row); + editor.view.dispatch(state.tr.replaceWith(0, state.doc.content.size, table)); + let tablePos = -1; + editor.view.state.doc.descendants((node, pos) => { + if (node.type.name === 'table' && tablePos < 0) tablePos = pos; + }); + return tablePos; +} + +describe('handleNodeSelect', () => { + let editor; + beforeEach(async () => { editor = await createTestEditor(); }); + afterEach(() => destroyEditor(editor)); + + it('selects a table for a block payload (proseIndex = table.from + 1)', () => { + const tablePos = insertTable(editor); + const ctx = { view: editor.view }; + handleNodeSelect({ node: { anchorType: 'table', proseIndex: tablePos + 1 } }, ctx); + const sel = editor.view.state.selection; + expect(sel instanceof NodeSelection).to.equal(true); + expect(sel.from).to.equal(tablePos); + expect(sel.node.type.name).to.equal('table'); + }); + + it('selects an image for an image payload (proseIndex = image.from)', () => { + const { state } = editor.view; + const { schema } = state; + const para = schema.nodes.paragraph.create(null, schema.nodes.image.create({ src: '/x.png' })); + editor.view.dispatch(state.tr.replaceWith(0, state.doc.content.size, para)); + let imgPos = -1; + editor.view.state.doc.descendants((node, pos) => { + if (node.type.name === 'image') imgPos = pos; + }); + const ctx = { view: editor.view }; + handleNodeSelect({ node: { anchorType: 'image', proseIndex: imgPos } }, ctx); + const sel = editor.view.state.selection; + expect(sel instanceof NodeSelection).to.equal(true); + expect(sel.node.type.name).to.equal('image'); + }); + + it('ignores a payload whose type does not match the resolved node', () => { + const tablePos = insertTable(editor); + const ctx = { view: editor.view }; + const before = editor.view.state.selection; + handleNodeSelect({ node: { anchorType: 'image', proseIndex: tablePos + 1 } }, ctx); + expect(editor.view.state.selection.eq(before)).to.equal(true); + }); + + it('collapses the selection to a caret for a null payload', () => { + const tablePos = insertTable(editor); + const ctx = { view: editor.view }; + handleNodeSelect({ node: { anchorType: 'table', proseIndex: tablePos + 1 } }, ctx); + handleNodeSelect({ node: null }, ctx); + expect(editor.view.state.selection instanceof TextSelection).to.equal(true); + expect(editor.view.state.selection.empty).to.equal(true); + const { $from } = editor.view.state.selection; + expect($from.parent.isTextblock).to.equal(true); + }); +}); + +describe('resolveNodeSelectPos', () => { + let editor; + beforeEach(async () => { editor = await createTestEditor(); }); + afterEach(() => destroyEditor(editor)); + + it('returns null for a null payload', () => { + expect(resolveNodeSelectPos(null, editor.view.state.doc)).to.equal(null); + }); + + it('returns null for an unknown anchorType', () => { + const { doc } = editor.view.state; + expect(resolveNodeSelectPos({ anchorType: 'text', proseIndex: 1 }, doc)).to.equal(null); + }); + + it('returns null for an out-of-bounds table proseIndex', () => { + insertTable(editor); + const { doc } = editor.view.state; + expect(resolveNodeSelectPos({ anchorType: 'table', proseIndex: 99999 }, doc)).to.equal(null); + }); + + it('maps a valid table proseIndex to the table position', () => { + const tablePos = insertTable(editor); + const { doc } = editor.view.state; + expect(resolveNodeSelectPos({ anchorType: 'table', proseIndex: tablePos + 1 }, doc)).to.equal(tablePos); + }); + + function insertImage(src) { + const { state } = editor.view; + const { schema } = state; + const para = schema.nodes.paragraph.create(null, schema.nodes.image.create({ src })); + editor.view.dispatch(state.tr.replaceWith(0, state.doc.content.size, para)); + let imgPos = -1; + editor.view.state.doc.descendants((node, pos) => { if (node.type.name === 'image') imgPos = pos; }); + return imgPos; + } + + it('falls back to a src lookup (by filename) when the image proseIndex is stale', () => { + const imgPos = insertImage('/media_abc.png'); + const { doc } = editor.view.state; + expect(resolveNodeSelectPos( + { anchorType: 'image', proseIndex: 99999, src: './media_abc.png?width=750&format=webply' }, + doc, + )).to.equal(imgPos); + }); + + it('returns null when no image src matches', () => { + insertImage('/media_abc.png'); + const { doc } = editor.view.state; + expect(resolveNodeSelectPos({ anchorType: 'image', proseIndex: 99999, src: '/nope.png' }, doc)).to.equal(null); + }); +});