Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/editors/slate/slate-react/components/editable.tsx
5895 views
1
import React, {
2
useCallback,
3
useEffect,
4
useMemo,
5
useRef,
6
useState,
7
} from "react";
8
import {
9
Editor,
10
Element,
11
Node,
12
NodeEntry,
13
Path,
14
Range,
15
Text,
16
Transforms,
17
} from "slate";
18
19
import Children from "./children";
20
import { WindowingParams } from "./children";
21
import Hotkeys from "../utils/hotkeys";
22
import {
23
IS_FIREFOX,
24
IS_SAFARI,
25
HAS_BEFORE_INPUT_SUPPORT,
26
} from "../utils/environment";
27
import { ReactEditor } from "..";
28
import { ReadOnlyContext } from "../hooks/use-read-only";
29
import { useSlate } from "../hooks/use-slate";
30
import { useIsomorphicLayoutEffect } from "../hooks/use-isomorphic-layout-effect";
31
import { DecorateContext } from "../hooks/use-decorate";
32
import {
33
DOMElement,
34
isDOMElement,
35
isDOMNode,
36
DOMStaticRange,
37
isPlainTextOnlyPaste,
38
} from "../utils/dom";
39
import {
40
EDITOR_TO_ELEMENT,
41
ELEMENT_TO_NODE,
42
IS_READ_ONLY,
43
NODE_TO_ELEMENT,
44
IS_FOCUSED,
45
PLACEHOLDER_SYMBOL,
46
} from "../utils/weak-maps";
47
import { debounce } from "lodash";
48
import getDirection from "direction";
49
import { useDOMSelectionChange, useUpdateDOMSelection } from "./selection-sync";
50
import { hasEditableTarget, hasTarget } from "./dom-utils";
51
52
/**
53
* `RenderElementProps` are passed to the `renderElement` handler.
54
*/
55
56
export interface RenderElementProps {
57
children: any;
58
element: Element;
59
attributes: {
60
"data-slate-node": "element";
61
"data-slate-inline"?: true;
62
"data-slate-void"?: true;
63
dir?: "rtl";
64
ref: any;
65
};
66
}
67
export const RenderElementProps = null; // webpack + TS es2020 modules need this
68
69
/**
70
* `RenderLeafProps` are passed to the `renderLeaf` handler.
71
*/
72
73
export interface RenderLeafProps {
74
children: any;
75
leaf: Text;
76
text: Text;
77
attributes: {
78
"data-slate-leaf": true;
79
};
80
}
81
export const RenderLeafProps = null; // webpack + TS es2020 modules need this
82
83
/**
84
* `EditableProps` are passed to the `<Editable>` component.
85
*/
86
87
export type EditableProps = {
88
decorate?: (entry: NodeEntry) => Range[];
89
onDOMBeforeInput?: (event: Event) => void;
90
placeholder?: string;
91
readOnly?: boolean;
92
role?: string;
93
style?: React.CSSProperties;
94
renderElement?: React.FC<RenderElementProps>;
95
renderLeaf?: React.FC<RenderLeafProps>;
96
as?: React.ElementType;
97
windowing?: WindowingParams;
98
divref?;
99
} & React.TextareaHTMLAttributes<HTMLDivElement>;
100
101
/**
102
* Editable.
103
*/
104
105
export const Editable: React.FC<EditableProps> = (props: EditableProps) => {
106
const {
107
windowing,
108
autoFocus,
109
decorate = defaultDecorate,
110
onDOMBeforeInput: propsOnDOMBeforeInput,
111
placeholder,
112
readOnly = false,
113
renderElement,
114
renderLeaf,
115
style = {},
116
as: Component = "div",
117
...attributes
118
} = props;
119
const editor = useSlate();
120
const fallbackRef = useRef<HTMLDivElement>(null);
121
const ref = props.divref ?? fallbackRef;
122
123
// Return true if the given event should be handled
124
// by the event handler code defined below.
125
// Regarding slateIgnore below, we use this with codemirror
126
// editor for fenced code blocks and math.
127
// I couldn't find any way to get codemirror to allow the copy to happen,
128
// but at the same time to not let the event propogate.
129
const shouldHandle = useCallback(
130
(
131
{
132
event, // the event itself
133
name, // name of the event, e.g., "onClick"
134
notReadOnly, // require doc to not be readOnly (ignored if not specified)
135
editableTarget, // require event target to be editable (defaults to true if not specified!)
136
}: {
137
event;
138
name: string;
139
notReadOnly?: boolean;
140
editableTarget?: boolean;
141
}, // @ts-ignore
142
) =>
143
!event.nativeEvent?.slateIgnore &&
144
(notReadOnly == null || notReadOnly == !readOnly) &&
145
((editableTarget ?? true) == true
146
? hasEditableTarget(editor, event.target)
147
: hasTarget(editor, event.target)) &&
148
!isEventHandled(event, attributes[name]),
149
[editor, attributes, readOnly],
150
);
151
152
// Update internal state on each render.
153
IS_READ_ONLY.set(editor, readOnly);
154
155
// Keep track of some state for the event handler logic.
156
const state: {
157
isComposing: boolean;
158
latestElement: DOMElement | null;
159
shiftKey: boolean;
160
ignoreSelection: boolean;
161
} = useMemo(
162
() => ({
163
isComposing: false,
164
latestElement: null as DOMElement | null,
165
shiftKey: false,
166
ignoreSelection: false,
167
}),
168
[],
169
);
170
171
// start ignoring the selection sync
172
editor.setIgnoreSelection = (value: boolean) => {
173
state.ignoreSelection = value;
174
};
175
// stop ignoring selection sync
176
editor.getIgnoreSelection = () => {
177
return state.ignoreSelection;
178
};
179
180
// state whose change causes an update
181
const [hiddenChildren, setHiddenChildren] = useState<Set<number>>(
182
new Set([]),
183
);
184
185
editor.updateHiddenChildren = useCallback(() => {
186
if (!ReactEditor.isUsingWindowing(editor)) return;
187
const hiddenChildren0: number[] = [];
188
let isCollapsed: boolean = false;
189
let level: number = 0;
190
let index: number = 0;
191
let hasAll: boolean = true;
192
for (const child of editor.children) {
193
if (!Element.isElement(child)) {
194
throw Error("bug");
195
}
196
if (child.type != "heading" || (isCollapsed && child.level > level)) {
197
if (isCollapsed) {
198
hiddenChildren0.push(index);
199
if (hasAll && !hiddenChildren.has(index)) {
200
hasAll = false;
201
}
202
}
203
} else {
204
// it's a heading of a high enough level, and it sets the new state.
205
// It is always visible.
206
isCollapsed = !!editor.collapsedSections.get(child);
207
level = child.level;
208
}
209
index += 1;
210
}
211
if (hasAll && hiddenChildren0.length == hiddenChildren.size) {
212
// no actual change (since subset and same cardinality), so don't
213
// cause re-render.
214
return;
215
}
216
setHiddenChildren(new Set(hiddenChildren0));
217
}, [editor.children, hiddenChildren]);
218
219
const updateHiddenChildrenDebounce = useMemo(() => {
220
return debounce(() => editor.updateHiddenChildren(), 1000);
221
}, []);
222
223
// When the actual document changes we soon update the
224
// hidden children set, since it is a list of indexes
225
// into editor.children, so may change. That said, we
226
// don't want this to impact performance when typing, so
227
// we debounce it, and it is unlikely that things change
228
// when the content (but not number) of children changes.
229
useEffect(updateHiddenChildrenDebounce, [editor.children]);
230
// We *always* immediately update when the number of children changes, since
231
// that is highly likely to make the hiddenChildren data structure wrong.
232
useEffect(() => editor.updateHiddenChildren(), [editor.children.length]);
233
234
// Update element-related weak maps with the DOM element ref.
235
useIsomorphicLayoutEffect(() => {
236
if (ref.current) {
237
EDITOR_TO_ELEMENT.set(editor, ref.current);
238
NODE_TO_ELEMENT.set(editor, ref.current);
239
ELEMENT_TO_NODE.set(ref.current, editor);
240
} else {
241
NODE_TO_ELEMENT.delete(editor);
242
}
243
});
244
245
// The autoFocus TextareaHTMLAttribute doesn't do anything on a div, so it
246
// needs to be manually focused.
247
useEffect(() => {
248
if (ref.current && autoFocus) {
249
ref.current.focus();
250
}
251
}, [autoFocus]);
252
253
useIsomorphicLayoutEffect(() => {
254
// Whenever the selection changes and is collapsed, make
255
// sure the cursor is visible. Also, have a facility to
256
// ignore a single iteration of this, which we use when
257
// the selection change is being caused by realtime
258
// collaboration.
259
260
// @ts-ignore
261
const skip = editor.syncCausedUpdate;
262
if (
263
editor.selection != null &&
264
Range.isCollapsed(editor.selection) &&
265
!skip
266
) {
267
editor.scrollCaretIntoView();
268
}
269
}, [editor.selection]);
270
271
// Listen on the native `beforeinput` event to get real "Level 2" events. This
272
// is required because React's `beforeinput` is fake and never really attaches
273
// to the real event sadly. (2019/11/01)
274
// https://github.com/facebook/react/issues/11211
275
const onDOMBeforeInput = useCallback(
276
(
277
event: Event & {
278
data: string | null;
279
dataTransfer: DataTransfer | null;
280
getTargetRanges(): DOMStaticRange[];
281
inputType: string;
282
isComposing: boolean;
283
ctrlKey?: boolean;
284
altKey?: boolean;
285
metaKey?: boolean;
286
},
287
) => {
288
if (
289
!readOnly &&
290
hasEditableTarget(editor, event.target) &&
291
!isDOMEventHandled(event, propsOnDOMBeforeInput)
292
) {
293
const { selection } = editor;
294
const { inputType: type } = event;
295
const data = event.dataTransfer || event.data || undefined;
296
297
// These two types occur while a user is composing text and can't be
298
// canceled. Let them through and wait for the composition to end.
299
if (
300
type === "insertCompositionText" ||
301
type === "deleteCompositionText"
302
) {
303
return;
304
}
305
306
event.preventDefault();
307
308
if (
309
type == "insertText" &&
310
!event.isComposing &&
311
event.data == " " &&
312
!(event.ctrlKey || event.altKey || event.metaKey)
313
) {
314
editor.insertText(" ", {});
315
return;
316
}
317
318
// COMPAT: For the deleting forward/backward input types we don't want
319
// to change the selection because it is the range that will be deleted,
320
// and those commands determine that for themselves.
321
if (!type.startsWith("delete") || type.startsWith("deleteBy")) {
322
const [targetRange] = event.getTargetRanges();
323
324
if (targetRange) {
325
let range;
326
try {
327
range = ReactEditor.toSlateRange(editor, targetRange);
328
} catch (err) {
329
console.warn(
330
"WARNING: onDOMBeforeInput -- unable to find SlateRange",
331
targetRange,
332
err,
333
);
334
return;
335
}
336
337
if (!selection || !Range.equals(selection, range)) {
338
Transforms.select(editor, range);
339
}
340
}
341
}
342
343
// COMPAT: If the selection is expanded, even if the command seems like
344
// a delete forward/backward command it should delete the selection.
345
if (
346
selection &&
347
Range.isExpanded(selection) &&
348
type.startsWith("delete")
349
) {
350
Editor.deleteFragment(editor);
351
return;
352
}
353
354
switch (type) {
355
case "deleteByComposition":
356
case "deleteByCut":
357
case "deleteByDrag": {
358
Editor.deleteFragment(editor);
359
break;
360
}
361
362
case "deleteContent":
363
case "deleteContentForward": {
364
Editor.deleteForward(editor);
365
break;
366
}
367
368
case "deleteContentBackward": {
369
Editor.deleteBackward(editor);
370
break;
371
}
372
373
case "deleteEntireSoftLine": {
374
Editor.deleteBackward(editor, { unit: "line" });
375
Editor.deleteForward(editor, { unit: "line" });
376
break;
377
}
378
379
case "deleteHardLineBackward": {
380
Editor.deleteBackward(editor, { unit: "block" });
381
break;
382
}
383
384
case "deleteSoftLineBackward": {
385
Editor.deleteBackward(editor, { unit: "line" });
386
break;
387
}
388
389
case "deleteHardLineForward": {
390
Editor.deleteForward(editor, { unit: "block" });
391
break;
392
}
393
394
case "deleteSoftLineForward": {
395
Editor.deleteForward(editor, { unit: "line" });
396
break;
397
}
398
399
case "deleteWordBackward": {
400
Editor.deleteBackward(editor, { unit: "word" });
401
break;
402
}
403
404
case "deleteWordForward": {
405
Editor.deleteForward(editor, { unit: "word" });
406
break;
407
}
408
409
case "insertLineBreak":
410
case "insertParagraph": {
411
Editor.insertBreak(editor);
412
break;
413
}
414
415
case "insertFromComposition": {
416
// COMPAT: in safari, `compositionend` event is dispatched after
417
// the beforeinput event with the inputType "insertFromComposition" has been dispatched.
418
// https://www.w3.org/TR/input-events-2/
419
// so the following code is the right logic
420
// because DOM selection in sync will be exec before `compositionend` event
421
// isComposing is true will prevent DOM selection being update correctly.
422
state.isComposing = false;
423
}
424
case "insertFromDrop":
425
case "insertFromPaste":
426
case "insertFromYank":
427
case "insertReplacementText":
428
case "insertText": {
429
try {
430
if (data instanceof DataTransfer) {
431
ReactEditor.insertData(editor, data);
432
} else if (typeof data === "string") {
433
Editor.insertText(editor, data);
434
}
435
} catch (err) {
436
// I've seen this crash several times in a way I can't reproduce, maybe
437
// when focusing (not sure). Better make it a warning with useful info.
438
console.warn(
439
`SLATE -- issue with DOM insertText/insertData operation ${err}, ${data}`,
440
);
441
}
442
443
break;
444
}
445
}
446
}
447
},
448
[readOnly, propsOnDOMBeforeInput],
449
);
450
451
// Attach a native DOM event handler for `beforeinput` events, because React's
452
// built-in `onBeforeInput` is actually a leaky polyfill that doesn't expose
453
// real `beforeinput` events sadly... (2019/11/04)
454
// https://github.com/facebook/react/issues/11211
455
useIsomorphicLayoutEffect(() => {
456
if (ref.current && HAS_BEFORE_INPUT_SUPPORT) {
457
// @ts-ignore The `beforeinput` event isn't recognized.
458
ref.current.addEventListener("beforeinput", onDOMBeforeInput);
459
}
460
return () => {
461
if (ref.current && HAS_BEFORE_INPUT_SUPPORT) {
462
// @ts-ignore The `beforeinput` event isn't recognized.
463
ref.current.removeEventListener("beforeinput", onDOMBeforeInput);
464
}
465
};
466
}, [onDOMBeforeInput]);
467
468
useUpdateDOMSelection({ editor, state });
469
const DOMSelectionChange = useDOMSelectionChange({ editor, state, readOnly });
470
471
const decorations = decorate([editor, []]);
472
473
if (
474
placeholder &&
475
editor.children.length === 1 &&
476
Array.from(Node.texts(editor)).length === 1 &&
477
Node.string(editor) === ""
478
) {
479
const start = Editor.start(editor, []);
480
decorations.push({
481
[PLACEHOLDER_SYMBOL]: true,
482
placeholder,
483
anchor: start,
484
focus: start,
485
} as any);
486
}
487
488
return (
489
<ReadOnlyContext.Provider value={readOnly}>
490
<Component
491
role={readOnly ? undefined : "textbox"}
492
{...attributes}
493
// COMPAT: Certain browsers don't support the `beforeinput` event, so we'd
494
// have to use hacks to make these replacement-based features work.
495
spellCheck={
496
!HAS_BEFORE_INPUT_SUPPORT ? undefined : attributes.spellCheck
497
}
498
autoCorrect={
499
!HAS_BEFORE_INPUT_SUPPORT ? undefined : attributes.autoCorrect
500
}
501
autoCapitalize={
502
!HAS_BEFORE_INPUT_SUPPORT ? undefined : attributes.autoCapitalize
503
}
504
data-slate-editor
505
data-slate-node="value"
506
contentEditable={readOnly ? undefined : true}
507
suppressContentEditableWarning
508
ref={ref}
509
style={{
510
// Prevent the default outline styles.
511
outline: "none",
512
// Preserve adjacent whitespace and new lines.
513
whiteSpace: "pre-wrap",
514
// Allow words to break if they are too long.
515
wordWrap: "break-word",
516
// Allow for passed-in styles to override anything.
517
...style,
518
}}
519
onScroll={
520
// When height is auto it's critical to keep the div from
521
// scrolling at all. Otherwise, especially when moving the
522
// cursor above and back down from a fenced code block, things
523
// will get scrolled off the screen and not be visible.
524
// The following code ensures that scrollTop is always 0
525
// in case of height:'auto'.
526
style.height === "auto"
527
? () => {
528
if (ref.current != null) {
529
ref.current.scrollTop = 0;
530
}
531
}
532
: undefined
533
}
534
onBeforeInput={useCallback(
535
(event: React.FormEvent<HTMLDivElement>) => {
536
// COMPAT: Certain browsers don't support the `beforeinput` event, so we
537
// fall back to React's leaky polyfill instead just for it. It
538
// only works for the `insertText` input type.
539
if (
540
!HAS_BEFORE_INPUT_SUPPORT &&
541
shouldHandle({ event, name: "onBeforeInput", notReadOnly: true })
542
) {
543
event.preventDefault();
544
const text = (event as any).data as string;
545
Editor.insertText(editor, text);
546
}
547
},
548
[readOnly],
549
)}
550
onBlur={useCallback(
551
(event: React.FocusEvent<HTMLDivElement>) => {
552
if (!shouldHandle({ event, name: "onBlur", notReadOnly: true })) {
553
return;
554
}
555
556
// COMPAT: If the current `activeElement` is still the previous
557
// one, this is due to the window being blurred when the tab
558
// itself becomes unfocused, so we want to abort early to allow to
559
// editor to stay focused when the tab becomes focused again.
560
if (state.latestElement === window.document.activeElement) {
561
return;
562
}
563
564
const { relatedTarget } = event;
565
const el = ReactEditor.toDOMNode(editor, editor);
566
567
// COMPAT: The event should be ignored if the focus is returning
568
// to the editor from an embedded editable element (eg. an <input>
569
// element inside a void node).
570
if (relatedTarget === el) {
571
return;
572
}
573
574
// COMPAT: The event should be ignored if the focus is moving from
575
// the editor to inside a void node's spacer element.
576
if (
577
isDOMElement(relatedTarget) &&
578
relatedTarget.hasAttribute("data-slate-spacer")
579
) {
580
return;
581
}
582
583
// COMPAT: The event should be ignored if the focus is moving to a
584
// non- editable section of an element that isn't a void node (eg.
585
// a list item of the check list example).
586
if (
587
relatedTarget != null &&
588
isDOMNode(relatedTarget) &&
589
ReactEditor.hasDOMNode(editor, relatedTarget)
590
) {
591
const node = ReactEditor.toSlateNode(editor, relatedTarget);
592
593
if (Element.isElement(node) && !editor.isVoid(node)) {
594
return;
595
}
596
}
597
598
IS_FOCUSED.delete(editor);
599
},
600
[readOnly, attributes.onBlur],
601
)}
602
onClick={useCallback(
603
(event: React.MouseEvent<HTMLDivElement>) => {
604
if (
605
shouldHandle({
606
event,
607
name: "onClick",
608
notReadOnly: true,
609
editableTarget: false,
610
}) &&
611
isDOMNode(event.target)
612
) {
613
let node;
614
try {
615
node = ReactEditor.toSlateNode(editor, event.target);
616
} catch (err) {
617
// node not actually in editor.
618
return;
619
}
620
let path;
621
try {
622
path = ReactEditor.findPath(editor, node);
623
} catch (err) {
624
console.warn(
625
"WARNING: onClick -- unable to find path to node",
626
node,
627
err,
628
);
629
return;
630
}
631
const start = Editor.start(editor, path);
632
const end = Editor.end(editor, path);
633
634
const startVoid = Editor.void(editor, { at: start });
635
const endVoid = Editor.void(editor, { at: end });
636
637
// We set selection either if we're not
638
// focused *or* clicking on a void. The
639
// not focused part isn't upstream, but we
640
// need it to have codemirror blocks.
641
if (
642
editor.selection == null ||
643
!ReactEditor.isFocused(editor) ||
644
(startVoid && endVoid && Path.equals(startVoid[1], endVoid[1]))
645
) {
646
const range = Editor.range(editor, start);
647
Transforms.select(editor, range);
648
}
649
}
650
},
651
[readOnly, attributes.onClick],
652
)}
653
onCompositionEnd={useCallback(
654
(event: React.CompositionEvent<HTMLDivElement>) => {
655
if (
656
shouldHandle({
657
event,
658
name: "onCompositionEnd",
659
notReadOnly: true,
660
})
661
) {
662
state.isComposing = false;
663
// console.log(`onCompositionEnd :'${event.data}'`);
664
665
// COMPAT: In Chrome, `beforeinput` events for compositions
666
// aren't correct and never fire the "insertFromComposition"
667
// type that we need. So instead, insert whenever a composition
668
// ends since it will already have been committed to the DOM.
669
if (!IS_SAFARI && !IS_FIREFOX && event.data) {
670
Editor.insertText(editor, event.data);
671
}
672
}
673
},
674
[attributes.onCompositionEnd],
675
)}
676
onCompositionStart={useCallback(
677
(event: React.CompositionEvent<HTMLDivElement>) => {
678
if (
679
shouldHandle({
680
event,
681
name: "onCompositionStart",
682
notReadOnly: true,
683
})
684
) {
685
state.isComposing = true;
686
// console.log("onCompositionStart");
687
}
688
},
689
[attributes.onCompositionStart],
690
)}
691
onCopy={useCallback(
692
(event: React.ClipboardEvent<HTMLDivElement>) => {
693
if (shouldHandle({ event, name: "onCopy" })) {
694
event.preventDefault();
695
ReactEditor.setFragmentData(editor, event.clipboardData);
696
}
697
},
698
[attributes.onCopy],
699
)}
700
onCut={useCallback(
701
(event: React.ClipboardEvent<HTMLDivElement>) => {
702
if (shouldHandle({ event, name: "onCut", notReadOnly: true })) {
703
event.preventDefault();
704
ReactEditor.setFragmentData(editor, event.clipboardData);
705
const { selection } = editor;
706
707
if (selection) {
708
if (Range.isExpanded(selection)) {
709
Editor.deleteFragment(editor);
710
} else {
711
const node = Node.parent(editor, selection.anchor.path);
712
if (Element.isElement(node) && Editor.isVoid(editor, node)) {
713
Transforms.delete(editor);
714
}
715
}
716
}
717
1;
718
}
719
},
720
[readOnly, attributes.onCut],
721
)}
722
onDragOver={useCallback(
723
(event: React.DragEvent<HTMLDivElement>) => {
724
if (
725
shouldHandle({
726
event,
727
name: "onDragOver",
728
editableTarget: false,
729
})
730
) {
731
if (!hasTarget(editor, event.target)) return; // for typescript only
732
// Only when the target is void, call `preventDefault` to signal
733
// that drops are allowed. Editable content is droppable by
734
// default, and calling `preventDefault` hides the cursor.
735
const node = ReactEditor.toSlateNode(editor, event.target);
736
737
if (Element.isElement(node) && Editor.isVoid(editor, node)) {
738
event.preventDefault();
739
}
740
}
741
},
742
[attributes.onDragOver],
743
)}
744
onDragStart={useCallback(
745
(event: React.DragEvent<HTMLDivElement>) => {
746
if (
747
shouldHandle({
748
event,
749
name: "onDragStart",
750
editableTarget: false,
751
})
752
) {
753
if (!hasTarget(editor, event.target)) return; // for typescript only
754
const node = ReactEditor.toSlateNode(editor, event.target);
755
let path;
756
try {
757
path = ReactEditor.findPath(editor, node);
758
} catch (err) {
759
console.warn(
760
"WARNING: onDragStart -- unable to find path to node",
761
node,
762
err,
763
);
764
return;
765
}
766
const voidMatch = Editor.void(editor, { at: path });
767
768
// If starting a drag on a void node, make sure it is selected
769
// so that it shows up in the selection's fragment.
770
if (voidMatch) {
771
const range = Editor.range(editor, path);
772
Transforms.select(editor, range);
773
}
774
775
ReactEditor.setFragmentData(editor, event.dataTransfer);
776
}
777
},
778
[attributes.onDragStart],
779
)}
780
onDrop={useCallback(
781
(event: React.DragEvent<HTMLDivElement>) => {
782
if (
783
shouldHandle({
784
event,
785
name: "onDrop",
786
editableTarget: false,
787
notReadOnly: true,
788
})
789
) {
790
// COMPAT: Certain browsers don't fire `beforeinput` events at all, and
791
// Chromium browsers don't properly fire them for files being
792
// dropped into a `contenteditable`. (2019/11/26)
793
// https://bugs.chromium.org/p/chromium/issues/detail?id=1028668
794
if (
795
!HAS_BEFORE_INPUT_SUPPORT ||
796
(!IS_SAFARI && event.dataTransfer.files.length > 0)
797
) {
798
event.preventDefault();
799
let range;
800
try {
801
range = ReactEditor.findEventRange(editor, event);
802
} catch (err) {
803
console.warn("WARNING: onDrop -- unable to find range", err);
804
return;
805
}
806
const data = event.dataTransfer;
807
Transforms.select(editor, range);
808
ReactEditor.insertData(editor, data);
809
}
810
}
811
},
812
[readOnly, attributes.onDrop],
813
)}
814
onFocus={useCallback(
815
(event: React.FocusEvent<HTMLDivElement>) => {
816
if (shouldHandle({ event, name: "onFocus", notReadOnly: true })) {
817
// Call DOMSelectionChange so we can capture what was just
818
// selected in the DOM to cause this focus.
819
DOMSelectionChange();
820
state.latestElement = window.document.activeElement;
821
IS_FOCUSED.set(editor, true);
822
}
823
},
824
[readOnly, attributes.onFocus],
825
)}
826
onKeyUp={useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
827
state.shiftKey = event.shiftKey;
828
}, [])}
829
onKeyDown={useCallback(
830
(event: React.KeyboardEvent<HTMLDivElement>) => {
831
state.shiftKey = event.shiftKey;
832
if (
833
state.isComposing ||
834
!shouldHandle({ event, name: "onKeyDown", notReadOnly: true })
835
) {
836
return;
837
}
838
839
const { nativeEvent } = event;
840
const { selection } = editor;
841
842
// COMPAT: Since we prevent the default behavior on
843
// `beforeinput` events, the browser doesn't think there's ever
844
// any history stack to undo or redo, so we have to manage these
845
// hotkeys ourselves. (2019/11/06)
846
if (Hotkeys.isRedo(nativeEvent)) {
847
event.preventDefault();
848
849
/*
850
if (HistoryEditor.isHistoryEditor(editor)) {
851
editor.redo();
852
}
853
*/
854
855
return;
856
}
857
858
if (Hotkeys.isUndo(nativeEvent)) {
859
event.preventDefault();
860
861
/*
862
if (HistoryEditor.isHistoryEditor(editor)) {
863
editor.undo();
864
}*/
865
866
return;
867
}
868
869
// COMPAT: Certain browsers don't handle the selection updates
870
// properly. In Chrome, the selection isn't properly extended.
871
// And in Firefox, the selection isn't properly collapsed.
872
// (2017/10/17)
873
if (Hotkeys.isMoveLineBackward(nativeEvent)) {
874
event.preventDefault();
875
Transforms.move(editor, { unit: "line", reverse: true });
876
return;
877
}
878
879
if (Hotkeys.isMoveLineForward(nativeEvent)) {
880
event.preventDefault();
881
Transforms.move(editor, { unit: "line" });
882
return;
883
}
884
885
if (Hotkeys.isExtendLineBackward(nativeEvent)) {
886
event.preventDefault();
887
Transforms.move(editor, {
888
unit: "line",
889
edge: "focus",
890
reverse: true,
891
});
892
return;
893
}
894
895
if (Hotkeys.isExtendLineForward(nativeEvent)) {
896
event.preventDefault();
897
Transforms.move(editor, { unit: "line", edge: "focus" });
898
return;
899
}
900
901
const element = editor.children[selection?.focus.path[0] ?? 0];
902
// It's definitely possible in edge cases that element is undefined. I've hit this in production,
903
// resulting in "Node.string(undefined)", which crashes.
904
const isRTL =
905
element != null && getDirection(Node.string(element)) === "rtl";
906
907
// COMPAT: If a void node is selected, or a zero-width text node
908
// adjacent to an inline is selected, we need to handle these
909
// hotkeys manually because browsers won't be able to skip over
910
// the void node with the zero-width space not being an empty
911
// string.
912
if (Hotkeys.isMoveBackward(nativeEvent)) {
913
event.preventDefault();
914
915
if (selection && Range.isCollapsed(selection)) {
916
Transforms.move(editor, { reverse: !isRTL });
917
} else {
918
Transforms.collapse(editor, { edge: "start" });
919
}
920
921
return;
922
}
923
924
if (Hotkeys.isMoveForward(nativeEvent)) {
925
event.preventDefault();
926
927
if (selection && Range.isCollapsed(selection)) {
928
Transforms.move(editor, { reverse: isRTL });
929
} else {
930
Transforms.collapse(editor, { edge: "end" });
931
}
932
933
return;
934
}
935
936
if (Hotkeys.isMoveWordBackward(nativeEvent)) {
937
event.preventDefault();
938
Transforms.move(editor, { unit: "word", reverse: !isRTL });
939
return;
940
}
941
942
if (Hotkeys.isMoveWordForward(nativeEvent)) {
943
event.preventDefault();
944
Transforms.move(editor, { unit: "word", reverse: isRTL });
945
return;
946
}
947
948
// COMPAT: Certain browsers don't support the `beforeinput` event, so we
949
// fall back to guessing at the input intention for hotkeys.
950
// COMPAT: In iOS, some of these hotkeys are handled in the
951
if (!HAS_BEFORE_INPUT_SUPPORT) {
952
// We don't have a core behavior for these, but they change the
953
// DOM if we don't prevent them, so we have to.
954
if (
955
Hotkeys.isBold(nativeEvent) ||
956
Hotkeys.isItalic(nativeEvent) ||
957
Hotkeys.isTransposeCharacter(nativeEvent)
958
) {
959
event.preventDefault();
960
return;
961
}
962
963
if (Hotkeys.isSplitBlock(nativeEvent)) {
964
event.preventDefault();
965
Editor.insertBreak(editor);
966
return;
967
}
968
969
if (Hotkeys.isDeleteBackward(nativeEvent)) {
970
event.preventDefault();
971
972
if (selection && Range.isExpanded(selection)) {
973
Editor.deleteFragment(editor);
974
} else {
975
Editor.deleteBackward(editor);
976
}
977
978
return;
979
}
980
981
if (Hotkeys.isDeleteForward(nativeEvent)) {
982
event.preventDefault();
983
984
if (selection && Range.isExpanded(selection)) {
985
Editor.deleteFragment(editor);
986
} else {
987
Editor.deleteForward(editor);
988
}
989
990
return;
991
}
992
993
if (Hotkeys.isDeleteLineBackward(nativeEvent)) {
994
event.preventDefault();
995
996
if (selection && Range.isExpanded(selection)) {
997
Editor.deleteFragment(editor);
998
} else {
999
Editor.deleteBackward(editor, { unit: "line" });
1000
}
1001
1002
return;
1003
}
1004
1005
if (Hotkeys.isDeleteLineForward(nativeEvent)) {
1006
event.preventDefault();
1007
1008
if (selection && Range.isExpanded(selection)) {
1009
Editor.deleteFragment(editor);
1010
} else {
1011
Editor.deleteForward(editor, { unit: "line" });
1012
}
1013
1014
return;
1015
}
1016
1017
if (Hotkeys.isDeleteWordBackward(nativeEvent)) {
1018
event.preventDefault();
1019
1020
if (selection && Range.isExpanded(selection)) {
1021
Editor.deleteFragment(editor);
1022
} else {
1023
Editor.deleteBackward(editor, { unit: "word" });
1024
}
1025
1026
return;
1027
}
1028
1029
if (Hotkeys.isDeleteWordForward(nativeEvent)) {
1030
event.preventDefault();
1031
1032
if (selection && Range.isExpanded(selection)) {
1033
Editor.deleteFragment(editor);
1034
} else {
1035
Editor.deleteForward(editor, { unit: "word" });
1036
}
1037
1038
return;
1039
}
1040
}
1041
1042
if (
1043
!event.altKey &&
1044
!event.ctrlKey &&
1045
!event.metaKey &&
1046
event.key.length == 1 &&
1047
!ReactEditor.selectionIsInDOM(editor)
1048
) {
1049
// user likely typed a character so insert it
1050
editor.insertText(event.key);
1051
event.preventDefault();
1052
return;
1053
}
1054
},
1055
[readOnly, attributes.onKeyDown],
1056
)}
1057
onPaste={useCallback(
1058
(event: React.ClipboardEvent<HTMLDivElement>) => {
1059
// COMPAT: Certain browsers don't support the `beforeinput` event, so we
1060
// fall back to React's `onPaste` here instead.
1061
// COMPAT: Firefox, Chrome and Safari are not emitting `beforeinput` events
1062
// when "paste without formatting" option is used.
1063
// This unfortunately needs to be handled with paste events instead.
1064
if (
1065
shouldHandle({ event, name: "onPaste", notReadOnly: true }) &&
1066
(!HAS_BEFORE_INPUT_SUPPORT ||
1067
isPlainTextOnlyPaste(event.nativeEvent))
1068
) {
1069
event.preventDefault();
1070
ReactEditor.insertData(editor, event.clipboardData);
1071
}
1072
},
1073
[readOnly, attributes.onPaste],
1074
)}
1075
>
1076
<DecorateContext.Provider value={decorate}>
1077
<Children
1078
isComposing={state.isComposing}
1079
decorations={decorations}
1080
node={editor}
1081
renderElement={renderElement}
1082
renderLeaf={renderLeaf}
1083
selection={editor.selection}
1084
hiddenChildren={hiddenChildren}
1085
windowing={windowing}
1086
onScroll={() => {
1087
if (editor.scrollCaretAfterNextScroll) {
1088
editor.scrollCaretAfterNextScroll = false;
1089
}
1090
editor.updateDOMSelection?.();
1091
props.onScroll?.({} as any);
1092
}}
1093
/>
1094
</DecorateContext.Provider>
1095
</Component>
1096
</ReadOnlyContext.Provider>
1097
);
1098
};
1099
1100
/**
1101
* A default memoized decorate function.
1102
*/
1103
1104
const defaultDecorate: (entry: NodeEntry) => Range[] = () => [];
1105
1106
/**
1107
* Check if an event is overrided by a handler.
1108
*/
1109
1110
const isEventHandled = <
1111
EventType extends React.SyntheticEvent<unknown, unknown>,
1112
>(
1113
event: EventType,
1114
handler?: (event: EventType) => void,
1115
) => {
1116
if (!handler) {
1117
return false;
1118
}
1119
1120
handler(event);
1121
return event.isDefaultPrevented() || event.isPropagationStopped();
1122
};
1123
1124
/**
1125
* Check if a DOM event is overrided by a handler.
1126
*/
1127
1128
const isDOMEventHandled = (event: Event, handler?: (event: Event) => void) => {
1129
if (!handler) {
1130
return false;
1131
}
1132
1133
handler(event);
1134
return event.defaultPrevented;
1135
};
1136
1137