Skip to content

Commit ba0b57f

Browse files
myieyehahn-kev
andauthored
Rich-text fixes (Mobile, Enter, Range errors, alignment etc.) (#1802)
* Make baseline-alignment work for empty, readonly rich-text fields * Add story for comparing inputs in different states * Make storybook accessible on local network * Further conform prose-mirror into the image of input * Revert making more like input and less like textarea Wrapping is good * Add inline clarifying comments * Add <br> so trailing \n is rendered. * Slight mobile height standardization * resolve merge issues --------- Co-authored-by: Kevin Hahn <kevin_hahn@sil.org>
1 parent 56ed923 commit ba0b57f

10 files changed

Lines changed: 647 additions & 31 deletions

File tree

frontend/viewer/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,13 @@
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",
3637
"i18n:extract": "lingui extract --clean && lingui extract",
3738
"generate-icon-types": "node ./generate-icon-types.js",
38-
"storybook": "storybook dev -p 6006",
39+
"storybook": "storybook dev -p 6006 --host 0.0.0.0",
3940
"build-storybook": "storybook build"
4041
},
4142
"devDependencies": {

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

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import {Schema} from 'prosemirror-model';
22
import {gt} from 'svelte-i18n-lingui';
33
import {cn} from '$lib/utils';
4-
4+
/*
5+
whitespace: pre tells prose-mirror how to parse the dom, not how to render it, that's our job
6+
*/
57
export const textSchema = new Schema({
68
nodes: {
7-
text: {},
9+
text: {
10+
whitespace: 'pre',
11+
},
812
/**
913
* Note: it seems that our spans likely should have been modeled as "marks" rather than "nodes".
1014
* Conceptually our users "mark" up a text.
@@ -13,7 +17,9 @@ export const textSchema = new Schema({
1317
* */
1418
span: {
1519
selectable: false,
16-
content: 'text*',
20+
content: 'text* br?',
21+
// If we remove this Backspace + Delete start removing whole spans
22+
inline: true,
1723
whitespace: 'pre',
1824
toDOM: (node) => {
1925
return ['span', {
@@ -28,6 +34,20 @@ export const textSchema = new Schema({
2834
//this allows us to update the text property without having to map all the span properties into the schema
2935
attrs: {richSpan: {default: {}}, className: {default: ''}}
3036
},
31-
doc: {content: 'span*', attrs: {}}
37+
br: {
38+
inline: true,
39+
group: 'inline',
40+
selectable: false,
41+
linebreakReplacement: true,
42+
toDOM: () => ['br'],
43+
parseDOM: [{tag: 'br'}]
44+
},
45+
doc: {
46+
whitespace: 'pre',
47+
// if we remove this Shift + Enter creates new spans and then Backspace starts removing whole spans
48+
inline: true,
49+
content: 'span*',
50+
attrs: {}
51+
}
3252
}
3353
});

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

Lines changed: 108 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import type {IRichString} from '$lib/dotnet-types/generated-types/MiniLcm/Models/IRichString';
1414
import {Label} from '$lib/components/ui/label';
1515
import {EditorView} from 'prosemirror-view';
16-
import {AllSelection, EditorState} from 'prosemirror-state';
16+
import {AllSelection, EditorState, Selection} from 'prosemirror-state';
1717
import {keymap} from 'prosemirror-keymap';
1818
import {baseKeymap} from 'prosemirror-commands';
1919
import {undo, redo, history} from 'prosemirror-history';
@@ -22,8 +22,10 @@
2222
import type {HTMLAttributes} from 'svelte/elements';
2323
import {IsUsingKeyboard} from 'bits-ui';
2424
import type {IRichSpan} from '$lib/dotnet-types/generated-types/MiniLcm/Models/IRichSpan';
25-
import {inputVariants} from '../ui/input/input.svelte';
2625
import {on} from 'svelte/events';
26+
import {IsMobile} from '$lib/hooks/is-mobile.svelte';
27+
import {findNextTabbable} from '$lib/utils/tabbable';
28+
import {inputVariants} from '../ui/input/input.svelte';
2729
2830
let {
2931
value = $bindable(),
@@ -36,6 +38,8 @@
3638
readonly = false,
3739
onchange = () => {},
3840
autofocus,
41+
class: className,
42+
enterkeyhint = 'next',
3943
...rest
4044
}:
4145
{
@@ -54,6 +58,8 @@
5458
let editor: EditorView | null = null;
5559
5660
const isUsingKeyboard = new IsUsingKeyboard();
61+
// isUsingKeyboard.current isn't entirely reliable on mobile due to virtual keyboards
62+
let pointerDown = false;
5763
5864
onMount(() => {
5965
// docs https://prosemirror.net/docs/
@@ -72,7 +78,7 @@
7278
editor.updateState(newState);
7379
},
7480
attributes: {
75-
class: inputVariants({class: 'min-h-10 h-auto block'}),
81+
class: inputVariants({class: 'min-h-10 h-auto pb-1.5'}),
7682
// todo: the distribution of props between the editor and the elementRef is not good
7783
// there should probably be a wrapper component that provides the elementRef to this one
7884
...(id ? {id} : {}),
@@ -81,13 +87,25 @@
8187
role: 'textbox',
8288
...(autocapitalize ? {autocapitalize} : {}),
8389
spellcheck: 'false',
90+
...(enterkeyhint ? {enterkeyhint} : {}),
8491
},
8592
editable() {
8693
return !readonly;
8794
},
8895
handleDOMEvents: {
89-
'focus': onfocus,
96+
pointerdown() {
97+
pointerDown = true;
98+
setTimeout(() => pointerDown = false, 100); // yes, apparently we need a decently high timeout value
99+
},
100+
'focus'(editor) {
101+
onfocus(editor, !pointerDown);
102+
},
90103
'blur': onblur,
104+
keydown(_view, event) {
105+
if (event.key === 'Enter') {
106+
onEnterKey();
107+
}
108+
},
91109
}
92110
});
93111
editor.dom.setAttribute('tabindex', '0');
@@ -97,9 +115,22 @@
97115
if (relatedLabel) return on(relatedLabel, 'click', onFocusTargetClick);
98116
});
99117
100-
function onfocus(editor: EditorView) {
101-
if (isUsingKeyboard.current) { // tabbed in
102-
selectAll(editor);
118+
let prevSelection: Selection | undefined;
119+
function onfocus(editor: EditorView, viaKeyboard: boolean) {
120+
const usingKeyboard = isUsingKeyboard.current ||
121+
IsMobile.value && viaKeyboard;
122+
if (usingKeyboard) { // tabbed in
123+
if (IsMobile.value) {
124+
if (prevSelection) {
125+
const prevSelectionForCurrentDoc = Selection.fromJSON(editor.state.doc, prevSelection.toJSON());
126+
setSelection(prevSelectionForCurrentDoc);
127+
prevSelection = undefined;
128+
} else {
129+
setSelection(Selection.atEnd(editor.state.doc));
130+
}
131+
} else {
132+
selectAll(editor);
133+
}
103134
}
104135
}
105136
@@ -108,6 +139,7 @@
108139
onchange(value);
109140
dirty = false;
110141
}
142+
prevSelection = editor.state.selection;
111143
clearSelection(editor);
112144
}
113145
@@ -139,6 +171,15 @@
139171
keymap({
140172
'Mod-z': undo,
141173
'Mod-y': redo,
174+
// eslint-disable-next-line @typescript-eslint/naming-convention
175+
'Enter': () => {
176+
// This handler is not triggered reliably on mobile, so we're using handleDOMEvents.keydown instead
177+
// if (IsMobile.value) onEnterKey();
178+
179+
// and we never want to do anything on desktop
180+
// (partially because it causes range errors - on mobile too)
181+
return true;
182+
},
142183
'Shift-Enter': (state, dispatch) => {
143184
if (dispatch) dispatch(state.tr.insertText(newLine));
144185
return true;
@@ -149,27 +190,49 @@
149190
});
150191
}
151192
193+
function onEnterKey(): void {
194+
if (IsMobile.value && enterkeyhint === 'next' && editor?.dom) {
195+
// mimic <input> 'next' behaviour
196+
const nextTabbable = findNextTabbable(editor.dom);
197+
nextTabbable?.focus();
198+
}
199+
}
200+
152201
function valueToDoc(): Node {
153202
return textSchema.node('doc', {}, value?.spans
154203
.filter(s => s.text)
155-
.map(s => richSpanToNode(s)) ?? []);
204+
.map((s, i, all) => richSpanToNode(s, i === all.length - 1)) ?? []);
156205
}
157206
158-
function richSpanToNode(s: IRichSpan) {
207+
function richSpanToNode(s: IRichSpan, isLast: boolean): Node {
159208
//we must pull text out of what is stored on the node attrs
160209
//ProseMirror will keep the text up to date itself, if we store it on the richSpan attr then it will become out of date
161210
let {text, ...rest} = s;
162211
//if the ws doesn't match expected, or there's more than just the ws key in props
163212
const isCustomized = (!!s.ws && !!normalWs && normalWs !== s.ws) || Object.keys(rest).length > 1;
164-
return textSchema.node('span', {richSpan: rest, className: cn(isCustomized && 'customized')}, [textSchema.text(replaceLineSeparatorWithNewLine(text))]);
213+
return textSchema.node('span', {richSpan: rest, className: cn(isCustomized && 'customized')}, [
214+
textSchema.text(replaceLineSeparatorWithNewLine(text)),
215+
// a <br> seems to be the only thing that will cause a trailing \n to be rendered
216+
// this is what Prose-Mirror does if inline: false, which we can't use
217+
...(isLast ? [textSchema.node('br')] : [])
218+
]);
165219
}
166220
167221
function richSpanFromNode(node: Node) {
168222
return {...node.attrs.richSpan, text: replaceNewLineWithLineSeparator(node.textContent)};
169223
}
170224
171225
function selectAll(editor: EditorView) {
172-
editor.dispatch(editor.state.tr.setSelection(new AllSelection(editor.state.doc)));
226+
setSelection(new AllSelection(editor.state.doc));
227+
228+
if (!readonly) return; // happens automatically if not readonly
229+
230+
const selection = window.getSelection();
231+
if (!selection) return;
232+
const range = document.createRange();
233+
range.selectNodeContents(editor.dom);
234+
selection.removeAllRanges();
235+
selection.addRange(range);
173236
}
174237
175238
function clearSelection(editor: EditorView) {
@@ -181,6 +244,19 @@
181244
}
182245
}
183246
247+
function setSelection(selection: Selection): void {
248+
if (!editor) return;
249+
editor.dispatch(editor.state.tr.setSelection(selection));
250+
setTimeout(() => {
251+
if (!editor) return;
252+
if (editor.state.selection.eq(selection) || selection.$anchor.doc !== editor.state.doc) return;
253+
// when tabbing back and forth between 2 rich text editors, the previous setSelection is not sufficient 🤷
254+
// (It doesn't have anything to do with the clearSelection() below)
255+
// The first call is kept, because it avoid a visible delay when possible
256+
editor.dispatch(editor.state.tr.setSelection(selection));
257+
}, 1); // 0 is not enough on Firefox
258+
}
259+
184260
//lcm expects line separators, but html does not render them, so we replace them with \n
185261
function replaceNewLineWithLineSeparator(text: string) {
186262
return text.replaceAll(newLine, lineSeparator);
@@ -190,17 +266,24 @@
190266
}
191267
192268
function onFocusTargetClick(event: MouseEvent) {
193-
if (!editor) return;
194-
if (event.target === editor?.dom) return; // the editor will handle focus itself
195-
editor.focus();
269+
if (!editor || !event.target) return;
270+
if (event.target === editor.dom || event.target instanceof globalThis.Node && editor.dom.contains(event.target))
271+
return; // the editor will handle focus itself
272+
editor?.focus();
273+
// Minorly dissatisfying is that when you click on the padding to the right of prose-mirror,
274+
// the caret is not intelligently placed at the end of the text (and on the correct line if multi-line)
275+
// Everything else seems good
196276
}
197277
</script>
198-
<style lang="postcss">
199-
:global(.ProseMirror) {
278+
279+
<style lang="postcss" global>
280+
.ProseMirror {
281+
display: block;
282+
place-content: center;
200283
flex-grow: 1;
201284
outline: none;
202285
cursor: text;
203-
/*white-space must be here, if it's directly on span then it will crash with a null node error*/
286+
204287
white-space: pre-wrap;
205288
206289
:global(.customized) {
@@ -210,14 +293,19 @@
210293
:global(.customized ~ .customized) {
211294
margin-left: 2px;
212295
}
296+
297+
&::before {
298+
/* Ensure baseline-alignment even works when empty */
299+
content: '\200B';
300+
}
213301
}
214302
</style>
215303

216304
{#if label}
217-
<div {...rest}>
218-
<Label onclick={onFocusTargetClick}>{label}</Label>
305+
<div class={className} {...rest}>
306+
<Label>{label}</Label>
219307
<div bind:this={elementRef}></div>
220308
</div>
221309
{:else}
222-
<div bind:this={elementRef} {...rest}></div>
310+
<div bind:this={elementRef} class={className} {...rest}></div>
223311
{/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)