Skip to content

Commit 25a2705

Browse files
authored
fix: disable sdt hover in view mode for web layout (#2661)
* fix: disable sdt hover in view mode for web layout * fix: prevent cursor issue for block sdt * fix: update tests
1 parent efa2961 commit 25a2705

10 files changed

Lines changed: 292 additions & 2 deletions

packages/super-editor/src/editors/v1/assets/styles/extensions/structured-content.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,19 @@
7272
border: none;
7373
}
7474

75+
.super-editor .ProseMirror.view-mode .sd-structured-content:hover,
76+
.super-editor .ProseMirror.view-mode .sd-structured-content-block:hover {
77+
background: none;
78+
}
79+
7580
.super-editor .ProseMirror.view-mode .sd-structured-content-draggable {
7681
display: none;
7782
}
7883

84+
.super-editor .ProseMirror.view-mode .sd-structured-content-block.ProseMirror-selectednode {
85+
outline: none;
86+
}
87+
7988
.presentation-editor--viewing .sd-structured-content,
8089
.presentation-editor--viewing .sd-structured-content-block {
8190
padding: 0;

packages/super-editor/src/editors/v1/core/Editor.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ import { initPartsRuntime } from './parts/init-parts-runtime.js';
8383
import { syncPackageMetadata } from './opc/sync-package-metadata.js';
8484
import { readSettingsRoot, parseProtectionState } from '../document-api-adapters/document-settings.js';
8585
import { applyEffectiveEditability, getProtectionStorage } from '../extensions/protection/editability.js';
86+
import { getViewModeSelectionWithoutStructuredContent } from './helpers/getViewModeSelectionWithoutStructuredContent.js';
8687

8788
declare const __APP_VERSION__: string | undefined;
8889
declare const version: string | undefined;
@@ -1612,6 +1613,10 @@ export class Editor extends EventEmitter<EditorEventMap> {
16121613
// Viewing mode: Not editable, no tracked changes, no comments
16131614
if (cleanedMode === 'viewing') {
16141615
this.commands.toggleTrackChangesShowOriginal?.();
1616+
const normalizedSelection = getViewModeSelectionWithoutStructuredContent(this.state);
1617+
if (normalizedSelection) {
1618+
this.view?.dispatch(this.state.tr.setSelection(normalizedSelection));
1619+
}
16151620
this.setEditable(false, false);
16161621
this.setOptions({ documentMode: 'viewing' });
16171622
if (pm) pm.classList.add('view-mode');
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { NodeSelection, Selection } from 'prosemirror-state';
2+
3+
const STRUCTURED_CONTENT_NODE_TYPES = new Set(['structuredContent', 'structuredContentBlock']);
4+
5+
function findEnclosingStructuredContentPosition($pos) {
6+
for (let depth = $pos.depth; depth > 0; depth--) {
7+
const node = $pos.node(depth);
8+
if (STRUCTURED_CONTENT_NODE_TYPES.has(node.type.name)) {
9+
return $pos.before(depth);
10+
}
11+
}
12+
13+
return null;
14+
}
15+
16+
export function getViewModeSelectionWithoutStructuredContent(state) {
17+
const { selection, doc } = state;
18+
19+
if (selection instanceof NodeSelection && STRUCTURED_CONTENT_NODE_TYPES.has(selection.node.type.name)) {
20+
const candidate = Selection.near(doc.resolve(selection.from), -1);
21+
const candidatePos = findEnclosingStructuredContentPosition(candidate.$from);
22+
if (candidatePos !== null) return null;
23+
return candidate;
24+
}
25+
26+
if (selection.empty) return null;
27+
28+
const startPos = findEnclosingStructuredContentPosition(selection.$from);
29+
const endPos = findEnclosingStructuredContentPosition(selection.$to);
30+
31+
if (startPos === null || endPos === null || startPos !== endPos) return null;
32+
33+
return Selection.near(doc.resolve(startPos), -1);
34+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import { EditorState, NodeSelection, Selection, TextSelection } from 'prosemirror-state';
3+
import { initTestEditor } from '@tests/helpers/helpers.js';
4+
import { getViewModeSelectionWithoutStructuredContent } from './getViewModeSelectionWithoutStructuredContent.js';
5+
6+
function findNode(doc, nodeType) {
7+
let result = null;
8+
9+
doc.descendants((node, pos) => {
10+
if (node.type.name === nodeType) {
11+
result = { node, pos };
12+
return false;
13+
}
14+
});
15+
16+
return result;
17+
}
18+
19+
describe('getViewModeSelectionWithoutStructuredContent', () => {
20+
let editor;
21+
let schema;
22+
23+
beforeEach(() => {
24+
({ editor } = initTestEditor());
25+
({ schema } = editor);
26+
});
27+
28+
afterEach(() => {
29+
editor?.destroy();
30+
editor = null;
31+
schema = null;
32+
});
33+
34+
function createState(doc, selection) {
35+
return EditorState.create({
36+
schema,
37+
doc,
38+
selection,
39+
plugins: editor.state.plugins,
40+
});
41+
}
42+
43+
it('normalizes structured content node selections', () => {
44+
const blockSdt = schema.nodes.structuredContentBlock.create({ id: 'block-1' }, [
45+
schema.nodes.paragraph.create(null, schema.text('Block field')),
46+
]);
47+
const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, schema.text('Before')), blockSdt]);
48+
const sdt = findNode(doc, 'structuredContentBlock');
49+
const state = createState(doc, NodeSelection.create(doc, sdt.pos));
50+
51+
const result = getViewModeSelectionWithoutStructuredContent(state);
52+
const expected = Selection.near(doc.resolve(sdt.pos), -1);
53+
54+
expect(result?.eq(expected)).toBe(true);
55+
});
56+
57+
it('returns null for block structured content node selections when no outside selection exists', () => {
58+
const blockSdt = schema.nodes.structuredContentBlock.create({ id: 'block-1' }, [
59+
schema.nodes.paragraph.create(null, schema.text('Block field')),
60+
]);
61+
const doc = schema.nodes.doc.create(null, [blockSdt]);
62+
const sdt = findNode(doc, 'structuredContentBlock');
63+
const state = createState(doc, NodeSelection.create(doc, sdt.pos));
64+
65+
expect(getViewModeSelectionWithoutStructuredContent(state)).toBeNull();
66+
});
67+
68+
it('normalizes non-empty text selections fully inside the same inline structured content', () => {
69+
const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }, schema.text('Field'));
70+
const paragraph = schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt, schema.text(' Z')]);
71+
const doc = schema.nodes.doc.create(null, [paragraph]);
72+
const sdt = findNode(doc, 'structuredContent');
73+
const selection = TextSelection.create(doc, sdt.pos + 2, sdt.pos + inlineSdt.nodeSize - 1);
74+
const state = createState(doc, selection);
75+
76+
const result = getViewModeSelectionWithoutStructuredContent(state);
77+
const expected = Selection.near(doc.resolve(sdt.pos), -1);
78+
79+
expect(result?.eq(expected)).toBe(true);
80+
});
81+
82+
it('returns null for collapsed cursor selections inside structured content', () => {
83+
const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }, schema.text('Field'));
84+
const paragraph = schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt, schema.text(' Z')]);
85+
const doc = schema.nodes.doc.create(null, [paragraph]);
86+
const sdt = findNode(doc, 'structuredContent');
87+
const state = createState(doc, TextSelection.create(doc, sdt.pos + 2));
88+
89+
expect(getViewModeSelectionWithoutStructuredContent(state)).toBeNull();
90+
});
91+
92+
it('returns null for selections outside structured content', () => {
93+
const paragraph = schema.nodes.paragraph.create(null, schema.text('Plain text'));
94+
const doc = schema.nodes.doc.create(null, [paragraph]);
95+
const state = createState(doc, TextSelection.create(doc, 1, 5));
96+
97+
expect(getViewModeSelectionWithoutStructuredContent(state)).toBeNull();
98+
});
99+
100+
it('returns null when a selection crosses structured content boundaries', () => {
101+
const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }, schema.text('Field'));
102+
const paragraph = schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt, schema.text(' Z')]);
103+
const doc = schema.nodes.doc.create(null, [paragraph]);
104+
const sdt = findNode(doc, 'structuredContent');
105+
const state = createState(doc, TextSelection.create(doc, sdt.pos - 1, sdt.pos + 3));
106+
107+
expect(getViewModeSelectionWithoutStructuredContent(state)).toBeNull();
108+
});
109+
});

packages/super-editor/src/editors/v1/core/helpers/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,4 @@ export * from './importMarkdown.js';
2929
export * from './contentProcessor.js';
3030
export * from './updateDOMAttributes.js';
3131
export * from './applyPatch.js';
32+
export * from './getViewModeSelectionWithoutStructuredContent.js';

packages/super-editor/src/editors/v1/extensions/structured-content/StructuredContentViewBase.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export class StructuredContentViewBase {
8484
const isPasteEvent = event.type === 'paste';
8585
const isCutEvent = event.type === 'cut';
8686
const isClickEvent = event.type === 'mousedown';
87+
const isViewingMode = this.editor?.options?.documentMode === 'viewing';
8788

8889
// ProseMirror tries to drag selectable nodes
8990
// even if `draggable` is set to `false`
@@ -97,6 +98,12 @@ export class StructuredContentViewBase {
9798
return false;
9899
}
99100

101+
// In viewing mode, suppress node-wrapper clicks so SDTs don't become
102+
// selected when the PM fallback is used.
103+
if (isViewingMode && isClickEvent && isSelectable) {
104+
return true;
105+
}
106+
100107
// we have to store that dragging started
101108
if (isDraggable && isEditable && !isDragging && isClickEvent) {
102109
const dragHandle = target.closest('[data-drag-handle]');

packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ import { Plugin, TextSelection } from 'prosemirror-state';
1111
* Uses appendTransaction so it works in both editing mode (PM DOM clicks) and
1212
* presentation mode (PresentationEditor dispatched selections).
1313
*/
14-
export function createStructuredContentSelectPlugin() {
14+
export function createStructuredContentSelectPlugin(editor) {
1515
return new Plugin({
1616
appendTransaction(transactions, oldState, newState) {
17+
if (editor?.options?.documentMode === 'viewing') return null;
18+
1719
const { selection } = newState;
1820

1921
// Only for collapsed selections (cursor placement, not range selections)
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import { EditorState, NodeSelection, TextSelection } from 'prosemirror-state';
3+
import { initTestEditor } from '@tests/helpers/helpers.js';
4+
5+
function findNode(doc, nodeType) {
6+
let result = null;
7+
8+
doc.descendants((node, pos) => {
9+
if (node.type.name === nodeType) {
10+
result = { node, pos };
11+
return false;
12+
}
13+
});
14+
15+
return result;
16+
}
17+
18+
describe('StructuredContentSelectPlugin', () => {
19+
let editor;
20+
let schema;
21+
22+
beforeEach(() => {
23+
({ editor } = initTestEditor());
24+
({ schema } = editor);
25+
});
26+
27+
afterEach(() => {
28+
editor?.destroy();
29+
editor = null;
30+
schema = null;
31+
});
32+
33+
function applyDoc(doc) {
34+
editor.setState(
35+
EditorState.create({
36+
schema,
37+
doc,
38+
plugins: editor.state.plugins,
39+
}),
40+
);
41+
}
42+
43+
it('selects inline SDT content on first click in editing mode', () => {
44+
const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }, schema.text('Field'));
45+
const paragraph = schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt, schema.text(' Z')]);
46+
applyDoc(schema.nodes.doc.create(null, [paragraph]));
47+
48+
const sdt = findNode(editor.state.doc, 'structuredContent');
49+
expect(sdt).not.toBeNull();
50+
51+
const contentFrom = sdt.pos + 1;
52+
const contentTo = sdt.pos + sdt.node.nodeSize - 1;
53+
54+
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, contentFrom + 1)));
55+
56+
expect(editor.state.selection.empty).toBe(false);
57+
expect(editor.state.selection.from).toBe(contentFrom);
58+
expect(editor.state.selection.to).toBe(contentTo);
59+
});
60+
61+
it('does not auto-select inline SDT content in viewing mode', () => {
62+
const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }, schema.text('Field'));
63+
const paragraph = schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt, schema.text(' Z')]);
64+
applyDoc(schema.nodes.doc.create(null, [paragraph]));
65+
66+
editor.setDocumentMode('viewing');
67+
68+
const sdt = findNode(editor.state.doc, 'structuredContent');
69+
expect(sdt).not.toBeNull();
70+
71+
const contentFrom = sdt.pos + 1;
72+
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, contentFrom + 1)));
73+
74+
expect(editor.state.selection.empty).toBe(true);
75+
expect(editor.state.selection.from).toBe(contentFrom + 1);
76+
expect(editor.state.selection.to).toBe(contentFrom + 1);
77+
});
78+
79+
it('clears an existing SDT node selection when switching to viewing mode if an outside selection exists', () => {
80+
const innerParagraph = schema.nodes.paragraph.create(null, schema.text('Block field'));
81+
const blockSdt = schema.nodes.structuredContentBlock.create({ id: 'block-1' }, [innerParagraph]);
82+
const beforeParagraph = schema.nodes.paragraph.create(null, schema.text('Before'));
83+
applyDoc(schema.nodes.doc.create(null, [beforeParagraph, blockSdt]));
84+
85+
const sdt = findNode(editor.state.doc, 'structuredContentBlock');
86+
expect(sdt).not.toBeNull();
87+
88+
editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, sdt.pos)));
89+
expect(editor.state.selection).toBeInstanceOf(NodeSelection);
90+
91+
editor.setDocumentMode('viewing');
92+
93+
expect(editor.state.selection).not.toBeInstanceOf(NodeSelection);
94+
expect(editor.state.selection.empty).toBe(true);
95+
expect(editor.options.documentMode).toBe('viewing');
96+
});
97+
98+
it('keeps an SDT node selection when switching to viewing mode if the block SDT is the whole document', () => {
99+
const innerParagraph = schema.nodes.paragraph.create(null, schema.text('Block field'));
100+
const blockSdt = schema.nodes.structuredContentBlock.create({ id: 'block-1' }, [innerParagraph]);
101+
applyDoc(schema.nodes.doc.create(null, [blockSdt]));
102+
103+
const sdt = findNode(editor.state.doc, 'structuredContentBlock');
104+
expect(sdt).not.toBeNull();
105+
106+
editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, sdt.pos)));
107+
expect(editor.state.selection).toBeInstanceOf(NodeSelection);
108+
109+
editor.setDocumentMode('viewing');
110+
111+
expect(editor.state.selection).toBeInstanceOf(NodeSelection);
112+
expect(editor.options.documentMode).toBe('viewing');
113+
});
114+
});

packages/super-editor/src/editors/v1/extensions/structured-content/structured-content.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ export const StructuredContent = Node.create({
145145
},
146146

147147
addPmPlugins() {
148-
return [createStructuredContentLockPlugin(), createStructuredContentSelectPlugin()];
148+
return [createStructuredContentLockPlugin(), createStructuredContentSelectPlugin(this.editor)];
149149
},
150150

151151
addNodeView() {

packages/super-editor/src/editors/v1/tests/css-scoping-regressions.test.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const __dirname = dirname(__filename);
99

1010
const NODE_RESIZER_CSS_PATH = resolve(__dirname, '../assets/styles/extensions/noderesizer.css');
1111
const PROSEMIRROR_CSS_PATH = resolve(__dirname, '../assets/styles/elements/prosemirror.css');
12+
const STRUCTURED_CONTENT_CSS_PATH = resolve(__dirname, '../assets/styles/extensions/structured-content.css');
1213

1314
function extractTopLevelSelectors(cssText) {
1415
const selectors = [];
@@ -216,4 +217,12 @@ describe('CSS Scoping Regressions', () => {
216217
expect(proseMirrorListSelectors.some((selector) => !selector.includes('.super-editor'))).toBe(true);
217218
expect(proseMirrorSelectedNodeSelectors.some((selector) => !selector.includes('.super-editor'))).toBe(true);
218219
});
220+
221+
it('structured content stylesheet should disable hover affordances in ProseMirror view mode', () => {
222+
const cssText = readFileSync(STRUCTURED_CONTENT_CSS_PATH, 'utf8');
223+
const selectors = extractTopLevelSelectors(cssText);
224+
225+
expect(selectors).toContain('.super-editor .ProseMirror.view-mode .sd-structured-content:hover');
226+
expect(selectors).toContain('.super-editor .ProseMirror.view-mode .sd-structured-content-block:hover');
227+
});
219228
});

0 commit comments

Comments
 (0)