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