Skip to content
Open
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
15 changes: 15 additions & 0 deletions blocks/canvas/editor-utils/editor-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
20 changes: 18 additions & 2 deletions blocks/canvas/ew-editor-doc/ew-editor-doc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -48,6 +48,7 @@ export class EwEditorDoc extends LitElement {
this._error = undefined;
this._lastDocBlockIndex = undefined;
this._lastDocSelKey = undefined;
this._lastBroadcastNodeKey = undefined;
editorHtmlChange.emit('');
}
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -226,6 +239,7 @@ export class EwEditorDoc extends LitElement {
explicit: descriptor.selectionType === SEL_BLOCK,
...descriptor,
});
this._broadcastSelectedNode(true);
},
),
],
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions blocks/canvas/ew-editor-doc/utils/selection.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions blocks/canvas/ew-editor-wysiwyg/quick-edit-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
handleUndoRedo,
handleNewVersion,
handleIframeSelectionChange,
handleNodeSelect,
} from './utils/handlers.js';

export function createControllerOnMessage(ctx) {
Expand All @@ -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);
}
};
}
80 changes: 79 additions & 1 deletion blocks/canvas/ew-editor-wysiwyg/utils/handlers.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
}
}
54 changes: 54 additions & 0 deletions test/unit/blocks/canvas/editor-utils/instrument-images.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
46 changes: 46 additions & 0 deletions test/unit/blocks/canvas/ew-editor-doc/utils/selection.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createTestEditor, destroyEditor } from '../../../edit/prose/test-helper
import {
describeDocSelection,
applyHighlight,
selectedNodePayload,
SEL_BLOCK,
SEL_ITEM,
SEL_TEXT,
Expand Down Expand Up @@ -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' });
});
});
Loading
Loading