|
1 | 1 | <script lang="ts" module> |
2 | | - import {type Node} from 'prosemirror-model'; |
| 2 | + import {Fragment, Slice, type Node} from 'prosemirror-model'; |
3 | 3 | import {cn} from '$lib/utils'; |
4 | 4 | import {textSchema} from './editor-schema'; |
5 | 5 |
|
|
54 | 54 | } & HTMLAttributes<HTMLDivElement> = $props(); |
55 | 55 |
|
56 | 56 | let elementRef: HTMLElement | null = $state(null); |
57 | | - let dirty = $state(false); |
| 57 | + // should not be state unless we catch errors. See onBlur. |
| 58 | + let dirty = false; |
58 | 59 | let editor: EditorView | null = null; |
59 | 60 |
|
60 | 61 | const isUsingKeyboard = new IsUsingKeyboard(); |
|
92 | 93 | editable() { |
93 | 94 | return !readonly; |
94 | 95 | }, |
| 96 | + handlePaste(view, _event, slice) { |
| 97 | + if (view.state.doc.content.size === 0) { |
| 98 | + // if the field is cleared, the resulting selection breaks paste (on older devices at least) |
| 99 | + selectAll(view); |
| 100 | + } |
| 101 | +
|
| 102 | + // When a user copies a whole field. It includes the trailing <br>. |
| 103 | + // Pasting it often causes errors, so we remove them. |
| 104 | + function withoutBrs(nodes: readonly Node[]): Node[] { |
| 105 | + return nodes |
| 106 | + .filter(node => node.type.name !== textSchema.nodes.br.name) |
| 107 | + .map(node => { |
| 108 | + if (node.isText) return node; |
| 109 | + return node.copy(Fragment.fromArray(withoutBrs(node.content.content))); |
| 110 | + }); |
| 111 | + } |
| 112 | + const cleanFragment = Fragment.fromArray(withoutBrs(slice.content.content)); |
| 113 | + const cleanSlice = new Slice(cleanFragment, slice.openStart, slice.openEnd); |
| 114 | +
|
| 115 | + // Below code is copied from prosemirror's doPaste |
| 116 | + // https://github.com/ProseMirror/prosemirror-view/blob/381c163b0abde96cabd609a8c4fc72ed2891b0e1/src/input.ts#L624 |
| 117 | + function sliceSingleNode(slice: Slice): Node | null { |
| 118 | + return slice.openStart == 0 && slice.openEnd == 0 && slice.content.childCount == 1 ? slice.content.firstChild : null; |
| 119 | + } |
| 120 | + let singleNode = sliceSingleNode(cleanSlice); |
| 121 | + let tr = singleNode |
| 122 | + ? view.state.tr.replaceSelectionWith(singleNode, false) |
| 123 | + : view.state.tr.replaceSelection(cleanSlice); |
| 124 | + view.dispatch(tr.scrollIntoView().setMeta('paste', true).setMeta('uiEvent', 'paste')); |
| 125 | + return true; |
| 126 | + }, |
95 | 127 | handleDOMEvents: { |
96 | 128 | pointerdown() { |
97 | 129 | pointerDown = true; |
|
122 | 154 | if (usingKeyboard) { // tabbed in |
123 | 155 | if (IsMobile.value) { |
124 | 156 | if (prevSelection) { |
125 | | - const prevSelectionForCurrentDoc = Selection.fromJSON(editor.state.doc, prevSelection.toJSON()); |
126 | | - setSelection(prevSelectionForCurrentDoc); |
| 157 | + // We can land here when the field gets cleared for some reason. |
| 158 | + // In that case fromJSON doesn't like the prevSelection (on older devices at least) |
| 159 | + if (editor.state.doc.content.size) { |
| 160 | + try { |
| 161 | + const prevSelectionForCurrentDoc = Selection.fromJSON(editor.state.doc, prevSelection.toJSON()); |
| 162 | + setSelection(prevSelectionForCurrentDoc); |
| 163 | + } catch { |
| 164 | + console.warn('Could not restore previous selection', prevSelection.toJSON(), editor.state.doc); |
| 165 | + } |
| 166 | + } |
127 | 167 | prevSelection = undefined; |
128 | 168 | } else { |
129 | 169 | setSelection(Selection.atEnd(editor.state.doc)); |
|
135 | 175 | } |
136 | 176 |
|
137 | 177 | function onblur(editor: EditorView) { |
| 178 | + // we cannot set and $state variables here, because if this is called due to navigation |
| 179 | + // it's too late to update state and doing so will throw. |
| 180 | + // See stomp-safe-lcm-rich-text-editor for how we handle that case. |
138 | 181 | if (dirty && value) { |
139 | 182 | onchange(value); |
140 | 183 | dirty = false; |
|
184 | 227 | if (dispatch) dispatch(state.tr.insertText(newLine)); |
185 | 228 | return true; |
186 | 229 | }, |
| 230 | + // eslint-disable-next-line @typescript-eslint/naming-convention |
| 231 | + 'Backspace': (state) => { |
| 232 | + // If the field is empty, backspace results in an error (on older devices at least) |
| 233 | + return state.doc.content.size === 0; |
| 234 | + }, |
187 | 235 | }), |
188 | 236 | keymap(baseKeymap) |
189 | 237 | ] |
|
0 commit comments