Skip to content

Commit 0041f96

Browse files
committed
Further conform prose-mirror into the image of input
1 parent 08c4df4 commit 0041f96

7 files changed

Lines changed: 379 additions & 25 deletions

File tree

frontend/viewer/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"test:watch": "vitest",
3131
"test:storybook": "vitest --project=storybook",
3232
"test:unit": "vitest --project=unit",
33+
"test:browser": "vitest --project=browser",
3334
"check": "svelte-check",
3435
"lint": "eslint .",
3536
"lint:report": "eslint . --output-file eslint_report.json --format json",

frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte

Lines changed: 123 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@
33
import {gt} from 'svelte-i18n-lingui';
44
import {cn} from '$lib/utils';
55
6+
/*
7+
whitespace: pre tells prose-mirror how to parse the dom, not how to render it, that's our job
8+
*/
69
const textSchema = new Schema({
710
nodes: {
8-
text: {},
11+
text: {
12+
whitespace: 'pre',
13+
},
914
/**
1015
* Note: it seems that our spans likely should have been modeled as "marks" rather than "nodes".
1116
* Conceptually our users "mark" up a text.
@@ -15,6 +20,7 @@
1520
span: {
1621
selectable: false,
1722
content: 'text*',
23+
inline: true,
1824
whitespace: 'pre',
1925
toDOM: (node) => {
2026
return ['span', {
@@ -27,7 +33,12 @@
2733
//this allows us to update the text property without having to map all the span properties into the schema
2834
attrs: {richSpan: {default: {}}, className: {default: ''}}
2935
},
30-
doc: {content: 'span*', attrs: {}}
36+
doc: {
37+
whitespace: 'pre',
38+
inline: true,
39+
content: 'span*',
40+
attrs: {}
41+
}
3142
}
3243
});
3344
@@ -41,7 +52,7 @@
4152
import type {IRichString} from '$lib/dotnet-types/generated-types/MiniLcm/Models/IRichString';
4253
import {Label} from '$lib/components/ui/label';
4354
import {EditorView} from 'prosemirror-view';
44-
import {AllSelection, EditorState} from 'prosemirror-state';
55+
import {AllSelection, EditorState, Selection} from 'prosemirror-state';
4556
import {keymap} from 'prosemirror-keymap';
4657
import {baseKeymap} from 'prosemirror-commands';
4758
import {undo, redo, history} from 'prosemirror-history';
@@ -50,8 +61,10 @@
5061
import type {HTMLAttributes} from 'svelte/elements';
5162
import {IsUsingKeyboard} from 'bits-ui';
5263
import type {IRichSpan} from '$lib/dotnet-types/generated-types/MiniLcm/Models/IRichSpan';
53-
import {inputVariants} from '../ui/input/input.svelte';
5464
import {on} from 'svelte/events';
65+
import InputShell from '../ui/input/input-shell.svelte';
66+
import {IsMobile} from '$lib/hooks/is-mobile.svelte';
67+
import {findNextTabbable} from '$lib/utils/tabbable';
5568
5669
let {
5770
value = $bindable(),
@@ -63,6 +76,8 @@
6376
readonly = false,
6477
onchange = () => {},
6578
autofocus,
79+
class: className,
80+
enterkeyhint = 'next',
6681
...rest
6782
}:
6883
{
@@ -81,6 +96,8 @@
8196
let editor: EditorView | null = null;
8297
8398
const isUsingKeyboard = new IsUsingKeyboard();
99+
// isUsingKeyboard.current isn't entirely reliable on mobile due to virtual keyboards
100+
let pointerDown = false;
84101
85102
onMount(() => {
86103
// docs https://prosemirror.net/docs/
@@ -99,21 +116,32 @@
99116
editor.updateState(newState);
100117
},
101118
attributes: {
102-
class: inputVariants({class: 'min-h-10 h-auto block'}),
103119
// todo: the distribution of props between the editor and the elementRef is not good
104120
// there should probably be a wrapper component that provides the elementRef to this one
105121
...(id ? {id} : {}),
106122
...(ariaLabelledby ? {'aria-labelledby': ariaLabelledby} : {}),
107123
...(ariaLabel ? {'aria-label': ariaLabel} : {}),
108124
role: 'textbox',
109125
spellcheck: 'false',
126+
...(enterkeyhint ? {enterkeyhint} : {}),
110127
},
111128
editable() {
112129
return !readonly;
113130
},
114131
handleDOMEvents: {
115-
'focus': onfocus,
132+
pointerdown() {
133+
pointerDown = true;
134+
setTimeout(() => pointerDown = false, 100); // yes, apparently we need a decently high timeout value
135+
},
136+
'focus'(editor) {
137+
onfocus(editor, !pointerDown);
138+
},
116139
'blur': onblur,
140+
keydown(_view, event) {
141+
if (event.key === 'Enter') {
142+
onEnterKey();
143+
}
144+
},
117145
}
118146
});
119147
editor.dom.setAttribute('tabindex', '0');
@@ -123,9 +151,22 @@
123151
if (relatedLabel) return on(relatedLabel, 'click', onFocusTargetClick);
124152
});
125153
126-
function onfocus(editor: EditorView) {
127-
if (isUsingKeyboard.current) { // tabbed in
128-
selectAll(editor);
154+
let prevSelection: Selection | undefined;
155+
function onfocus(editor: EditorView, viaKeyboard: boolean) {
156+
const usingKeyboard = isUsingKeyboard.current ||
157+
IsMobile.value && viaKeyboard;
158+
if (usingKeyboard) { // tabbed in
159+
if (IsMobile.value) {
160+
if (prevSelection) {
161+
const prevSelectionForCurrentDoc = Selection.fromJSON(editor.state.doc, prevSelection.toJSON());
162+
setSelection(prevSelectionForCurrentDoc);
163+
prevSelection = undefined;
164+
} else {
165+
setSelection(Selection.atEnd(editor.state.doc));
166+
}
167+
} else {
168+
selectAll(editor);
169+
}
129170
}
130171
}
131172
@@ -134,6 +175,7 @@
134175
onchange(value);
135176
dirty = false;
136177
}
178+
prevSelection = editor.state.selection;
137179
clearSelection(editor);
138180
}
139181
@@ -165,6 +207,15 @@
165207
keymap({
166208
'Mod-z': undo,
167209
'Mod-y': redo,
210+
// eslint-disable-next-line @typescript-eslint/naming-convention
211+
'Enter': () => {
212+
// This handler is not triggered reliably on mobile, so we're using handleDOMEvents.keydown instead
213+
// if (IsMobile.value) onEnterKey();
214+
215+
// and we never want to do anything on desktop
216+
// (partially because it causes range errors - on mobile too)
217+
return true;
218+
},
168219
'Shift-Enter': (state, dispatch) => {
169220
if (dispatch) dispatch(state.tr.insertText(newLine));
170221
return true;
@@ -175,6 +226,14 @@
175226
});
176227
}
177228
229+
function onEnterKey(): void {
230+
if (IsMobile.value && enterkeyhint === 'next' && editor?.dom) {
231+
// mimic <input> 'next' behaviour
232+
const nextTabbable = findNextTabbable(editor.dom);
233+
nextTabbable?.focus();
234+
}
235+
}
236+
178237
function valueToDoc(): Node {
179238
return textSchema.node('doc', {}, value?.spans
180239
.filter(s => s.text)
@@ -195,7 +254,16 @@
195254
}
196255
197256
function selectAll(editor: EditorView) {
198-
editor.dispatch(editor.state.tr.setSelection(new AllSelection(editor.state.doc)));
257+
setSelection(new AllSelection(editor.state.doc));
258+
259+
if (!readonly) return; // happens automatically if not readonly
260+
261+
const selection = window.getSelection();
262+
if (!selection) return;
263+
const range = document.createRange();
264+
range.selectNodeContents(editor.dom);
265+
selection.removeAllRanges();
266+
selection.addRange(range);
199267
}
200268
201269
function clearSelection(editor: EditorView) {
@@ -207,6 +275,19 @@
207275
}
208276
}
209277
278+
function setSelection(selection: Selection): void {
279+
if (!editor) return;
280+
editor.dispatch(editor.state.tr.setSelection(selection));
281+
setTimeout(() => {
282+
if (!editor) return;
283+
if (editor.state.selection.eq(selection) || selection.$anchor.doc !== editor.state.doc) return;
284+
// when tabbing back and forth between 2 rich text editors, the previous setSelection is not sufficient 🤷
285+
// (It doesn't have anything to do with the clearSelection() below)
286+
// The first call is kept, because it avoid a visible delay when possible
287+
editor.dispatch(editor.state.tr.setSelection(selection));
288+
}, 1); // 0 is not enough on Firefox
289+
}
290+
210291
//lcm expects line separators, but html does not render them, so we replace them with \n
211292
function replaceNewLineWithLineSeparator(text: string) {
212293
return text.replaceAll(newLine, lineSeparator);
@@ -216,18 +297,31 @@
216297
}
217298
218299
function onFocusTargetClick(event: MouseEvent) {
219-
if (!editor) return;
220-
if (event.target === editor?.dom) return; // the editor will handle focus itself
221-
editor.focus();
300+
if (!editor || !event.target) return;
301+
if (event.target === editor.dom || event.target instanceof globalThis.Node && editor.dom.contains(event.target))
302+
return; // the editor will handle focus itself
303+
editor?.focus();
304+
// Minorly dissatisfying is that when you click on the padding to the right of prose-mirror,
305+
// the caret is not intelligently placed at the end of the text (and on the correct line if multi-line)
306+
// Everything else seems good
222307
}
308+
309+
const wrapperClasses = 'px-3 min-h-10 h-max block overflow-hidden cursor-text place-content-center focus:ring-2';
223310
</script>
224-
<style lang="postcss">
225-
:global(.ProseMirror) {
311+
312+
<style lang="postcss" global>
313+
.ProseMirror {
314+
height: 100%;
315+
@apply py-1.5;
316+
place-content: center;
226317
flex-grow: 1;
227318
outline: none;
228319
cursor: text;
229-
/*white-space must be here, if it's directly on span then it will crash with a null node error*/
230-
white-space: pre-wrap;
320+
overflow: auto;
321+
scrollbar-width: none;
322+
323+
/* "pre" => only wrap for line breaks, NOT simply because the text doesn't have enough space */
324+
white-space: pre;
231325
232326
:global(.customized) {
233327
text-decoration: underline;
@@ -244,11 +338,19 @@
244338
}
245339
</style>
246340

341+
<!-- tabindex=-1 allows focus, but not tabbing, which makes focus:ring-2 work
342+
when clicking around the real editor (just aesthetics) -->
247343
{#if label}
248-
<div {...rest}>
249-
<Label onclick={onFocusTargetClick}>{label}</Label>
250-
<div bind:this={elementRef}></div>
344+
<div class={className} {...rest}>
345+
<Label>{label}</Label>
346+
<InputShell onclick={onFocusTargetClick}
347+
class={wrapperClasses}
348+
bind:ref={elementRef}
349+
tabindex={-1} />
251350
</div>
252351
{:else}
253-
<div bind:this={elementRef} {...rest}></div>
352+
<InputShell onclick={onFocusTargetClick}
353+
class={cn(wrapperClasses, className)} bind:ref={elementRef}
354+
tabindex={-1}
355+
{...rest} />
254356
{/if}

frontend/viewer/src/lib/components/ui/input/input-shell.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
focusRingClass?: string;
99
}
1010
11-
const anyChildHasFocusRing = 'has-[:focus-visible]:ring-ring has-[:focus-visible]:outline-none has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-offset-2';
11+
const anyChildHasFocusRing = 'ring-ring outline-none ring-offset-2 has-[:focus-visible]:ring-2';
1212
1313
let {
1414
ref = $bindable(null),

0 commit comments

Comments
 (0)