|
3 | 3 | import {gt} from 'svelte-i18n-lingui'; |
4 | 4 | import {cn} from '$lib/utils'; |
5 | 5 |
|
| 6 | + /* |
| 7 | + whitespace: pre tells prose-mirror how to parse the dom, not how to render it, that's our job |
| 8 | + */ |
6 | 9 | const textSchema = new Schema({ |
7 | 10 | nodes: { |
8 | | - text: {}, |
| 11 | + text: { |
| 12 | + whitespace: 'pre', |
| 13 | + }, |
9 | 14 | /** |
10 | 15 | * Note: it seems that our spans likely should have been modeled as "marks" rather than "nodes". |
11 | 16 | * Conceptually our users "mark" up a text. |
|
15 | 20 | span: { |
16 | 21 | selectable: false, |
17 | 22 | content: 'text*', |
| 23 | + inline: true, |
18 | 24 | whitespace: 'pre', |
19 | 25 | toDOM: (node) => { |
20 | 26 | return ['span', { |
|
27 | 33 | //this allows us to update the text property without having to map all the span properties into the schema |
28 | 34 | attrs: {richSpan: {default: {}}, className: {default: ''}} |
29 | 35 | }, |
30 | | - doc: {content: 'span*', attrs: {}} |
| 36 | + doc: { |
| 37 | + whitespace: 'pre', |
| 38 | + inline: true, |
| 39 | + content: 'span*', |
| 40 | + attrs: {} |
| 41 | + } |
31 | 42 | } |
32 | 43 | }); |
33 | 44 |
|
|
41 | 52 | import type {IRichString} from '$lib/dotnet-types/generated-types/MiniLcm/Models/IRichString'; |
42 | 53 | import {Label} from '$lib/components/ui/label'; |
43 | 54 | import {EditorView} from 'prosemirror-view'; |
44 | | - import {AllSelection, EditorState} from 'prosemirror-state'; |
| 55 | + import {AllSelection, EditorState, Selection} from 'prosemirror-state'; |
45 | 56 | import {keymap} from 'prosemirror-keymap'; |
46 | 57 | import {baseKeymap} from 'prosemirror-commands'; |
47 | 58 | import {undo, redo, history} from 'prosemirror-history'; |
|
50 | 61 | import type {HTMLAttributes} from 'svelte/elements'; |
51 | 62 | import {IsUsingKeyboard} from 'bits-ui'; |
52 | 63 | import type {IRichSpan} from '$lib/dotnet-types/generated-types/MiniLcm/Models/IRichSpan'; |
53 | | - import {inputVariants} from '../ui/input/input.svelte'; |
54 | 64 | 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'; |
55 | 68 |
|
56 | 69 | let { |
57 | 70 | value = $bindable(), |
|
63 | 76 | readonly = false, |
64 | 77 | onchange = () => {}, |
65 | 78 | autofocus, |
| 79 | + class: className, |
| 80 | + enterkeyhint = 'next', |
66 | 81 | ...rest |
67 | 82 | }: |
68 | 83 | { |
|
81 | 96 | let editor: EditorView | null = null; |
82 | 97 |
|
83 | 98 | const isUsingKeyboard = new IsUsingKeyboard(); |
| 99 | + // isUsingKeyboard.current isn't entirely reliable on mobile due to virtual keyboards |
| 100 | + let pointerDown = false; |
84 | 101 |
|
85 | 102 | onMount(() => { |
86 | 103 | // docs https://prosemirror.net/docs/ |
|
99 | 116 | editor.updateState(newState); |
100 | 117 | }, |
101 | 118 | attributes: { |
102 | | - class: inputVariants({class: 'min-h-10 h-auto block'}), |
103 | 119 | // todo: the distribution of props between the editor and the elementRef is not good |
104 | 120 | // there should probably be a wrapper component that provides the elementRef to this one |
105 | 121 | ...(id ? {id} : {}), |
106 | 122 | ...(ariaLabelledby ? {'aria-labelledby': ariaLabelledby} : {}), |
107 | 123 | ...(ariaLabel ? {'aria-label': ariaLabel} : {}), |
108 | 124 | role: 'textbox', |
109 | 125 | spellcheck: 'false', |
| 126 | + ...(enterkeyhint ? {enterkeyhint} : {}), |
110 | 127 | }, |
111 | 128 | editable() { |
112 | 129 | return !readonly; |
113 | 130 | }, |
114 | 131 | 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 | + }, |
116 | 139 | 'blur': onblur, |
| 140 | + keydown(_view, event) { |
| 141 | + if (event.key === 'Enter') { |
| 142 | + onEnterKey(); |
| 143 | + } |
| 144 | + }, |
117 | 145 | } |
118 | 146 | }); |
119 | 147 | editor.dom.setAttribute('tabindex', '0'); |
|
123 | 151 | if (relatedLabel) return on(relatedLabel, 'click', onFocusTargetClick); |
124 | 152 | }); |
125 | 153 |
|
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 | + } |
129 | 170 | } |
130 | 171 | } |
131 | 172 |
|
|
134 | 175 | onchange(value); |
135 | 176 | dirty = false; |
136 | 177 | } |
| 178 | + prevSelection = editor.state.selection; |
137 | 179 | clearSelection(editor); |
138 | 180 | } |
139 | 181 |
|
|
165 | 207 | keymap({ |
166 | 208 | 'Mod-z': undo, |
167 | 209 | '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 | + }, |
168 | 219 | 'Shift-Enter': (state, dispatch) => { |
169 | 220 | if (dispatch) dispatch(state.tr.insertText(newLine)); |
170 | 221 | return true; |
|
175 | 226 | }); |
176 | 227 | } |
177 | 228 |
|
| 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 | +
|
178 | 237 | function valueToDoc(): Node { |
179 | 238 | return textSchema.node('doc', {}, value?.spans |
180 | 239 | .filter(s => s.text) |
|
195 | 254 | } |
196 | 255 |
|
197 | 256 | 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); |
199 | 267 | } |
200 | 268 |
|
201 | 269 | function clearSelection(editor: EditorView) { |
|
207 | 275 | } |
208 | 276 | } |
209 | 277 |
|
| 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 | +
|
210 | 291 | //lcm expects line separators, but html does not render them, so we replace them with \n |
211 | 292 | function replaceNewLineWithLineSeparator(text: string) { |
212 | 293 | return text.replaceAll(newLine, lineSeparator); |
|
216 | 297 | } |
217 | 298 |
|
218 | 299 | 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 |
222 | 307 | } |
| 308 | +
|
| 309 | + const wrapperClasses = 'px-3 min-h-10 h-max block overflow-hidden cursor-text place-content-center focus:ring-2'; |
223 | 310 | </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; |
226 | 317 | flex-grow: 1; |
227 | 318 | outline: none; |
228 | 319 | 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; |
231 | 325 |
|
232 | 326 | :global(.customized) { |
233 | 327 | text-decoration: underline; |
|
244 | 338 | } |
245 | 339 | </style> |
246 | 340 |
|
| 341 | +<!-- tabindex=-1 allows focus, but not tabbing, which makes focus:ring-2 work |
| 342 | +when clicking around the real editor (just aesthetics) --> |
247 | 343 | {#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} /> |
251 | 350 | </div> |
252 | 351 | {: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} /> |
254 | 356 | {/if} |
0 commit comments