Skip to content

Commit 382ffa6

Browse files
mattConnHarboursemantic-release-botharbournickcaio-pizzol
authored
SD-1695 - add flag for text selection in viewing mode (#1891)
* chore(release): 1.9.0-next.7 [skip ci] * **export:** prefix Relationship IDs with rId for valid xsd:ID ([#1855](#1855)) ([11e67e1](11e67e1)) * feat(editor): add flag for text selection in viewing mode * fix(super-editor): expand keyboard allowlist and block IME in selection-only view mode (SD-1695) - Allow navigation keys (arrows, Home/End, PageUp/PageDown), Cmd+A, and Shift+Arrow in addition to Cmd+C for keyboard accessibility - Block composition events (compositionstart/update/end) to prevent IME input from mutating the document when view.editable is true due to allowSelectionInViewMode - Add unit tests for keyboard allowlist and composition blocking * fix(superdoc): fix RAF callback missing selection guard and clean up editable extension (SD-1695) - Fix missed allowSelectionInViewMode guard in RAF callback that was clearing selection on the next animation frame, defeating the feature - Extract isFullyBlocked() and blockWhenNotEditable() helpers to deduplicate guard logic in editable.js - Hoist navigation keys to module-level NAVIGATION_KEYS Set - Collapse composition event tests into it.each * test(behavior): add behavior tests for selection in viewing mode (SD-1695) - Add allowSelectionInViewMode and documentMode params to behavior test harness and fixture config - Add behavior tests verifying: mouse click + keyboard navigation, Shift+Arrow selection extending, Select All, typing/paste/delete blocked, and selection cleared without the flag * feat(editor): add flag for text selection in viewing mode - addressed review * fix(test): adjust behavior tests for PresentationEditor keyboard limitations (SD-1695) Keyboard selection extending (Shift+Arrow, Cmd+A) doesn't work in PresentationEditor mode because view.editable is false and PM's editHandlers.keydown doesn't run. Replace those tests with triple-click selection (mouse-based) which works correctly. * fix(super-editor): expand keyboard allowlist and block IME in selection-only view mode (SD-1695) - Allow navigation keys (arrows, Home/End, PageUp/PageDown), Cmd+A, and Shift+Arrow in addition to Cmd+C for keyboard accessibility - Block composition events (compositionstart/update/end) to prevent IME input from mutating the document when view.editable is true due to allowSelectionInViewMode - Add unit tests for keyboard allowlist and composition blocking * fix(superdoc): fix RAF callback missing selection guard and clean up editable extension (SD-1695) - Fix missed allowSelectionInViewMode guard in RAF callback that was clearing selection on the next animation frame, defeating the feature - Extract isFullyBlocked() and blockWhenNotEditable() helpers to deduplicate guard logic in editable.js - Hoist navigation keys to module-level NAVIGATION_KEYS Set - Collapse composition event tests into it.each * test(behavior): add behavior tests for selection in viewing mode (SD-1695) - Add allowSelectionInViewMode and documentMode params to behavior test harness and fixture config - Add behavior tests verifying: mouse click + keyboard navigation, Shift+Arrow selection extending, Select All, typing/paste/delete blocked, and selection cleared without the flag * fix(test): adjust behavior tests for PresentationEditor keyboard limitations (SD-1695) Keyboard selection extending (Shift+Arrow, Cmd+A) doesn't work in PresentationEditor mode because view.editable is false and PM's editHandlers.keydown doesn't run. Replace those tests with triple-click selection (mouse-based) which works correctly. * fix(test): use someProp to test handleKeyDown directly --------- Co-authored-by: semantic-release-bot <semantic-release-bot@martynus.net> Co-authored-by: Nick Bernal <nick@superdoc.dev> Co-authored-by: Caio Pizzol <caio@harbourshare.com>
1 parent 9714ffb commit 382ffa6

15 files changed

Lines changed: 618 additions & 18 deletions

File tree

packages/super-editor/src/assets/styles/elements/prosemirror.css

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,20 @@
8282
caret-color: transparent;
8383
}
8484

85+
/* Allow selection visibility in viewing mode when allowSelectionInViewMode is enabled */
86+
.presentation-editor--allow-selection .ProseMirror-hideselection *::selection {
87+
background: Highlight;
88+
background: -moz-Highlight;
89+
}
90+
91+
.presentation-editor--allow-selection .ProseMirror-hideselection *::-moz-selection {
92+
background: Highlight;
93+
}
94+
95+
.presentation-editor--allow-selection .ProseMirror-hideselection * {
96+
caret-color: auto;
97+
}
98+
8599
/* See https://github.com/ProseMirror/prosemirror/issues/1421#issuecomment-1759320191 */
86100
.sd-editor-scoped .ProseMirror [draggable][contenteditable='false'] {
87101
user-select: text;
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
/**
4+
* Test the allowSelectionInViewMode behavior in the Editable extension.
5+
*
6+
* These tests verify the event handling logic without initializing
7+
* a full ProseMirror editor, ensuring the allowlist logic is correct.
8+
*/
9+
10+
/**
11+
* Creates handler functions that mimic the editable plugin behavior.
12+
* This matches the implementation in editable.js.
13+
*/
14+
const createHandlers = (editable, allowSelectionInViewMode) => ({
15+
handleClick: () => !editable && !allowSelectionInViewMode,
16+
handleDoubleClick: () => !editable && !allowSelectionInViewMode,
17+
handleTripleClick: () => !editable && !allowSelectionInViewMode,
18+
handlePaste: () => !editable,
19+
handleDrop: () => !editable,
20+
// mousedown and focus return true to block, false to allow
21+
handleMousedown: (event) => {
22+
if (!editable && !allowSelectionInViewMode) {
23+
return true; // blocked
24+
}
25+
return false; // allowed
26+
},
27+
handleFocus: () => {
28+
if (!editable && !allowSelectionInViewMode) {
29+
return true; // blocked
30+
}
31+
return false; // allowed
32+
},
33+
});
34+
35+
describe('Editable extension allowSelectionInViewMode click handling', () => {
36+
describe('when editable=false and allowSelectionInViewMode=false', () => {
37+
const handlers = createHandlers(false, false);
38+
39+
it('blocks click events', () => {
40+
expect(handlers.handleClick()).toBe(true);
41+
});
42+
43+
it('blocks double-click events', () => {
44+
expect(handlers.handleDoubleClick()).toBe(true);
45+
});
46+
47+
it('blocks triple-click events', () => {
48+
expect(handlers.handleTripleClick()).toBe(true);
49+
});
50+
51+
it('blocks mousedown events', () => {
52+
expect(handlers.handleMousedown({})).toBe(true);
53+
});
54+
55+
it('blocks focus events', () => {
56+
expect(handlers.handleFocus()).toBe(true);
57+
});
58+
});
59+
60+
describe('when editable=false and allowSelectionInViewMode=true', () => {
61+
const handlers = createHandlers(false, true);
62+
63+
it('allows click events for selection', () => {
64+
expect(handlers.handleClick()).toBe(false);
65+
});
66+
67+
it('allows double-click events for word selection', () => {
68+
expect(handlers.handleDoubleClick()).toBe(false);
69+
});
70+
71+
it('allows triple-click events for paragraph selection', () => {
72+
expect(handlers.handleTripleClick()).toBe(false);
73+
});
74+
75+
it('allows mousedown events for drag selection', () => {
76+
expect(handlers.handleMousedown({})).toBe(false);
77+
});
78+
79+
it('allows focus events', () => {
80+
expect(handlers.handleFocus()).toBe(false);
81+
});
82+
});
83+
84+
describe('when editable=true', () => {
85+
const handlers = createHandlers(true, false);
86+
87+
it('allows all click events', () => {
88+
expect(handlers.handleClick()).toBe(false);
89+
expect(handlers.handleDoubleClick()).toBe(false);
90+
expect(handlers.handleTripleClick()).toBe(false);
91+
expect(handlers.handleMousedown({})).toBe(false);
92+
expect(handlers.handleFocus()).toBe(false);
93+
});
94+
});
95+
});
96+
97+
describe('Editable extension paste and drop handling', () => {
98+
describe('when editable=false', () => {
99+
it('blocks paste regardless of allowSelectionInViewMode', () => {
100+
const handlers1 = createHandlers(false, false);
101+
const handlers2 = createHandlers(false, true);
102+
expect(handlers1.handlePaste()).toBe(true);
103+
expect(handlers2.handlePaste()).toBe(true);
104+
});
105+
106+
it('blocks drop regardless of allowSelectionInViewMode', () => {
107+
const handlers1 = createHandlers(false, false);
108+
const handlers2 = createHandlers(false, true);
109+
expect(handlers1.handleDrop()).toBe(true);
110+
expect(handlers2.handleDrop()).toBe(true);
111+
});
112+
});
113+
114+
describe('when editable=true', () => {
115+
const handlers = createHandlers(true, false);
116+
117+
it('allows paste', () => {
118+
expect(handlers.handlePaste()).toBe(false);
119+
});
120+
121+
it('allows drop', () => {
122+
expect(handlers.handleDrop()).toBe(false);
123+
});
124+
});
125+
});
126+
127+
describe('Editable extension allowSelectionInViewMode keyboard handling', () => {
128+
/**
129+
* Creates a handleKeyDown function that mimics the editable plugin behavior.
130+
* This matches the implementation in editable.js.
131+
*/
132+
const createHandleKeyDown = (editable, allowSelectionInViewMode) => {
133+
return (_view, event) => {
134+
if (!editable) {
135+
if (allowSelectionInViewMode) {
136+
// Allow navigation keys for selection
137+
const isNavigationKey = [
138+
'ArrowLeft',
139+
'ArrowRight',
140+
'ArrowUp',
141+
'ArrowDown',
142+
'Home',
143+
'End',
144+
'PageUp',
145+
'PageDown',
146+
].includes(event.key);
147+
148+
// Allow copy and select all
149+
const isCopyOrSelectAll = (event.ctrlKey || event.metaKey) && ['c', 'a'].includes(event.key.toLowerCase());
150+
151+
if (isNavigationKey || isCopyOrSelectAll) return false;
152+
}
153+
return true;
154+
}
155+
return false;
156+
};
157+
};
158+
159+
const createKeyEvent = (key, modifiers = {}) => ({
160+
key,
161+
ctrlKey: modifiers.ctrlKey || false,
162+
metaKey: modifiers.metaKey || false,
163+
shiftKey: modifiers.shiftKey || false,
164+
});
165+
166+
describe('when editable=false and allowSelectionInViewMode=false', () => {
167+
const handleKeyDown = createHandleKeyDown(false, false);
168+
169+
it('blocks all keyboard input', () => {
170+
expect(handleKeyDown(null, createKeyEvent('a'))).toBe(true);
171+
expect(handleKeyDown(null, createKeyEvent('ArrowRight'))).toBe(true);
172+
expect(handleKeyDown(null, createKeyEvent('c', { metaKey: true }))).toBe(true);
173+
});
174+
});
175+
176+
describe('when editable=false and allowSelectionInViewMode=true', () => {
177+
const handleKeyDown = createHandleKeyDown(false, true);
178+
179+
it('allows Cmd+C for copy', () => {
180+
expect(handleKeyDown(null, createKeyEvent('c', { metaKey: true }))).toBe(false);
181+
});
182+
183+
it('allows Ctrl+C for copy', () => {
184+
expect(handleKeyDown(null, createKeyEvent('c', { ctrlKey: true }))).toBe(false);
185+
});
186+
187+
it('allows Cmd+A for select all', () => {
188+
expect(handleKeyDown(null, createKeyEvent('a', { metaKey: true }))).toBe(false);
189+
});
190+
191+
it('allows Ctrl+A for select all', () => {
192+
expect(handleKeyDown(null, createKeyEvent('a', { ctrlKey: true }))).toBe(false);
193+
});
194+
195+
it('allows arrow key navigation', () => {
196+
expect(handleKeyDown(null, createKeyEvent('ArrowLeft'))).toBe(false);
197+
expect(handleKeyDown(null, createKeyEvent('ArrowRight'))).toBe(false);
198+
expect(handleKeyDown(null, createKeyEvent('ArrowUp'))).toBe(false);
199+
expect(handleKeyDown(null, createKeyEvent('ArrowDown'))).toBe(false);
200+
});
201+
202+
it('allows Home/End navigation', () => {
203+
expect(handleKeyDown(null, createKeyEvent('Home'))).toBe(false);
204+
expect(handleKeyDown(null, createKeyEvent('End'))).toBe(false);
205+
});
206+
207+
it('allows PageUp/PageDown navigation', () => {
208+
expect(handleKeyDown(null, createKeyEvent('PageUp'))).toBe(false);
209+
expect(handleKeyDown(null, createKeyEvent('PageDown'))).toBe(false);
210+
});
211+
212+
it('blocks regular character input', () => {
213+
expect(handleKeyDown(null, createKeyEvent('a'))).toBe(true);
214+
expect(handleKeyDown(null, createKeyEvent('z'))).toBe(true);
215+
expect(handleKeyDown(null, createKeyEvent('1'))).toBe(true);
216+
});
217+
218+
it('blocks other keyboard shortcuts', () => {
219+
expect(handleKeyDown(null, createKeyEvent('v', { metaKey: true }))).toBe(true); // paste
220+
expect(handleKeyDown(null, createKeyEvent('x', { metaKey: true }))).toBe(true); // cut
221+
expect(handleKeyDown(null, createKeyEvent('b', { metaKey: true }))).toBe(true); // bold
222+
});
223+
224+
it('blocks Enter and Backspace', () => {
225+
expect(handleKeyDown(null, createKeyEvent('Enter'))).toBe(true);
226+
expect(handleKeyDown(null, createKeyEvent('Backspace'))).toBe(true);
227+
expect(handleKeyDown(null, createKeyEvent('Delete'))).toBe(true);
228+
});
229+
});
230+
231+
describe('when editable=true', () => {
232+
const handleKeyDown = createHandleKeyDown(true, true);
233+
234+
it('allows all keyboard input regardless of allowSelectionInViewMode', () => {
235+
expect(handleKeyDown(null, createKeyEvent('a'))).toBe(false);
236+
expect(handleKeyDown(null, createKeyEvent('ArrowRight'))).toBe(false);
237+
expect(handleKeyDown(null, createKeyEvent('c', { metaKey: true }))).toBe(false);
238+
expect(handleKeyDown(null, createKeyEvent('Enter'))).toBe(false);
239+
});
240+
});
241+
});

packages/super-editor/src/core/extensions/editable.js

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,22 +27,54 @@ const handleBackwardReplaceInsertText = (view, event) => {
2727
return true;
2828
};
2929

30+
const NAVIGATION_KEYS = new Set([
31+
'ArrowLeft',
32+
'ArrowRight',
33+
'ArrowUp',
34+
'ArrowDown',
35+
'Home',
36+
'End',
37+
'PageUp',
38+
'PageDown',
39+
]);
40+
3041
/**
3142
* Editable extension controls whether the editor accepts user input.
3243
*
3344
* When editable is false, all user interactions are blocked:
3445
* - Text input via beforeinput events
35-
* - Mouse interactions via mousedown
36-
* - Focus via automatic blur
37-
* - Click, double-click, and triple-click events
46+
* - Mouse interactions via mousedown (unless allowSelectionInViewMode is true)
47+
* - Focus via automatic blur (unless allowSelectionInViewMode is true)
48+
* - Click, double-click, and triple-click events (unless allowSelectionInViewMode is true)
3849
* - Keyboard shortcuts via handleKeyDown
3950
* - Paste and drop events
51+
*
52+
* When allowSelectionInViewMode is true and editable is false:
53+
* - Mouse interactions are allowed for text selection
54+
* - Focus is allowed
55+
* - Click events are allowed for selection
56+
* - Navigation keys (arrows, Home/End, PageUp/PageDown) are allowed
57+
* - Copy (Ctrl/Cmd+C) and Select All (Ctrl/Cmd+A) are allowed
58+
* - IME/composition input, text input, paste, and drop remain blocked
4059
*/
4160
export const Editable = Extension.create({
4261
name: 'editable',
4362

4463
addPmPlugins() {
4564
const editor = this.editor;
65+
66+
/** True when all interaction should be blocked (not editable AND no selection-only override). */
67+
const isFullyBlocked = () => !editor.options.editable && !editor.options.allowSelectionInViewMode;
68+
69+
/** Block an event when the editor is not editable (regardless of allowSelectionInViewMode). */
70+
const blockWhenNotEditable = (_view, event) => {
71+
if (!editor.options.editable) {
72+
event.preventDefault();
73+
return true;
74+
}
75+
return false;
76+
};
77+
4678
const editablePlugin = new Plugin({
4779
key: new PluginKey('editable'),
4880
props: {
@@ -62,25 +94,43 @@ export const Editable = Extension.create({
6294
return false;
6395
},
6496
mousedown: (_view, event) => {
65-
if (!editor.options.editable) {
97+
if (isFullyBlocked()) {
6698
event.preventDefault();
6799
return true;
68100
}
69101
return false;
70102
},
71103
focus: (view, event) => {
72-
if (!editor.options.editable) {
104+
if (isFullyBlocked()) {
73105
event.preventDefault();
74106
view.dom.blur();
75107
return true;
76108
}
77109
return false;
78110
},
111+
// Block IME composition events when not editable.
112+
// The input bridge forwards composition events when view.editable is true
113+
// (e.g. allowSelectionInViewMode), but we must prevent document mutations.
114+
compositionstart: blockWhenNotEditable,
115+
compositionupdate: blockWhenNotEditable,
116+
compositionend: blockWhenNotEditable,
117+
},
118+
handleClick: () => isFullyBlocked(),
119+
handleDoubleClick: () => isFullyBlocked(),
120+
handleTripleClick: () => isFullyBlocked(),
121+
handleKeyDown: (_view, event) => {
122+
if (!editor.options.editable) {
123+
if (editor.options.allowSelectionInViewMode) {
124+
if (NAVIGATION_KEYS.has(event.key)) return false;
125+
126+
const isCopyOrSelectAll =
127+
(event.ctrlKey || event.metaKey) && ['c', 'a'].includes(event.key.toLowerCase());
128+
if (isCopyOrSelectAll) return false;
129+
}
130+
return true;
131+
}
132+
return false;
79133
},
80-
handleClick: () => !editor.options.editable,
81-
handleDoubleClick: () => !editor.options.editable,
82-
handleTripleClick: () => !editor.options.editable,
83-
handleKeyDown: () => !editor.options.editable,
84134
handlePaste: () => !editor.options.editable,
85135
handleDrop: () => !editor.options.editable,
86136
},

0 commit comments

Comments
 (0)