@keboola/design 13.1.1 → 13.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/dist/markdown-editor.d.ts +1 -1
- package/dist/markdown-editor.js +199 -95
- package/dist/markdown-editor.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# @keboola/design
|
|
2
2
|
|
|
3
|
+
## 13.1.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- `MarkdownEditor`'s link panel stays inside the viewport, and a nested list shows one marker per item.
|
|
8
|
+
- The link panel is a `Popover` anchored to the Insert Link button, in both `toolbarMode`s. It opens below the button at its left edge, shifts left to stay 8 px inside the viewport, and opens above the button when there is no room below. It used to hang off the button at a fixed 320 px, so in a narrow container (a reply box in a `Sheet`) it ran past the edge and the container clipped it.
|
|
9
|
+
- It renders in `document.body`, like the floating toolbar, so it wears the page theme and stacks over a surrounding `Modal` or `Sheet`. It takes the `Popover` surface: argo chrome gives it the larger radius.
|
|
10
|
+
- An outside click or focus closes it, and focus stays where the click or focus put it. It used to stay open.
|
|
11
|
+
- A second press of Insert Link closes it like Cancel. The button carries `aria-expanded` and `aria-controls`, and the panel is a dialog named `Insert Link`.
|
|
12
|
+
- The Label field takes focus, with its text selected, every time the panel opens, including a reopen while it is still animating out.
|
|
13
|
+
- Unchanged: both fields take clicks, Enter adds the link, Escape closes only the panel and returns focus to the editor (then the floating toolbar, then the dialog), and the editor's selection survives opening the panel and adding the link.
|
|
14
|
+
- With `saveOnBlur`, focus moving into anything the toolbar portals (the floating toolbar, the link panel, the Block type and Alignment menus) does not count as leaving the editor, in either mode. Opening either menu used to save. `onSave` still fires synchronously on a real blur.
|
|
15
|
+
- In a nested bullet, numbered or check list, the item that holds the nested list no longer draws a bullet, number or checkbox of its own beside the nested item's. The markdown it exports is unchanged.
|
|
16
|
+
|
|
17
|
+
## 13.1.2
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- `MarkdownEditor`'s floating toolbar works inside a modal `Modal` or `Sheet`.
|
|
22
|
+
- The toolbar is a `Popover` anchored to the selection, so it stacks over a surrounding Radix dialog like any popover. Its buttons take clicks, and the link panel's fields take clicks and typing. It used to be portalled to `document.body`, under the dialog's `pointer-events: none` and outside its focus trap.
|
|
23
|
+
- Escape goes to the top layer: the link panel, then the toolbar, then the dialog. Escape with the toolbar showing hides it until the selection changes, and still reaches the editor's own Escape handling (`onCancel` with `saveOnBlur` or `singleLine`). Escape with focus on a toolbar button sends focus back to the editor.
|
|
24
|
+
- Inside a dialog, the dialog's focus trap is paused while the toolbar shows, as with any non-modal popover. Tab cycles inside the toolbar.
|
|
25
|
+
- A click or focus outside the editor and the toolbar hides it, so Escape in another field reaches that field; focus back in the editor shows it again. It used to stay up, pointing at the editor's selection, with an open link panel inside it.
|
|
26
|
+
- The toolbar follows its text when the text moves (an alignment change, a container scroll, a window resize), hides while the text is scrolled out of view, and moves below the selection when there is no room above it. It used to move only on an editor update, not on a scroll or resize.
|
|
27
|
+
- Nothing else changes: it shows for a non-empty selection, a button press keeps the editor's focus and selection, and it still renders in `document.body`. `toolbarMode="fixed"` is unchanged.
|
|
28
|
+
|
|
3
29
|
## 13.1.1
|
|
4
30
|
|
|
5
31
|
### Patch Changes
|
|
@@ -3,7 +3,7 @@ import { ReactNode } from "react";
|
|
|
3
3
|
/**
|
|
4
4
|
* How the toolbar is presented.
|
|
5
5
|
*
|
|
6
|
-
* - `floating`: a
|
|
6
|
+
* - `floating`: a `Popover` over the selection, so it stacks over a surrounding dialog.
|
|
7
7
|
* - `fixed`: a static bar at the top of the editor, visible with no selection.
|
|
8
8
|
*/
|
|
9
9
|
type ToolbarMode = 'floating' | 'fixed';
|
package/dist/markdown-editor.js
CHANGED
|
@@ -6,6 +6,7 @@ import { ButtonGroup } from "./internal/components/ButtonGroup.js";
|
|
|
6
6
|
import { DropdownMenu } from "./DropdownMenu-BJGLjpVc.js";
|
|
7
7
|
import { Label } from "./internal/components/Label.js";
|
|
8
8
|
import { useStableCallback } from "./internal/hooks/useStableCallback.js";
|
|
9
|
+
import { Popover } from "./Popover-CEk915I1.js";
|
|
9
10
|
import { TextInput } from "./TextInput-BWYXbfs4.js";
|
|
10
11
|
import { FormGroup } from "./FormGroup-CmCppxOn.js";
|
|
11
12
|
import { Skeleton } from "./internal/components/Skeleton/Skeleton.js";
|
|
@@ -13,8 +14,6 @@ import { Kbd } from "./Kbd-J4n1J6i4.js";
|
|
|
13
14
|
import { toast } from "./toast-CyXEdaCZ.js";
|
|
14
15
|
import { Suspense, createContext, lazy, use, useCallback, useEffect, useId, useRef, useState } from "react";
|
|
15
16
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
16
|
-
import { createPortal } from "react-dom";
|
|
17
|
-
import { DismissableLayer } from "@radix-ui/react-dismissable-layer";
|
|
18
17
|
import { $createCodeNode, $isCodeNode, CodeNode } from "@lexical/code";
|
|
19
18
|
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
|
|
20
19
|
import { AutoLinkPlugin } from "@lexical/react/LexicalAutoLinkPlugin";
|
|
@@ -31,7 +30,7 @@ import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPl
|
|
|
31
30
|
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
|
|
32
31
|
import { TabIndentationPlugin } from "@lexical/react/LexicalTabIndentationPlugin";
|
|
33
32
|
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
|
|
34
|
-
import { $addUpdateTag, $createParagraphNode, $createTextNode, $getNodeByKey, $getSelection, $insertNodes, $isParagraphNode, $isRangeSelection, $isTextNode, $parseSerializedNode, $setSelection, COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, DROP_COMMAND, DecoratorNode, FORMAT_ELEMENT_COMMAND, FORMAT_TEXT_COMMAND, HISTORY_PUSH_TAG, KEY_DOWN_COMMAND, PASTE_COMMAND, PASTE_TAG, createEditor } from "lexical";
|
|
33
|
+
import { $addUpdateTag, $createParagraphNode, $createTextNode, $getNodeByKey, $getSelection, $insertNodes, $isParagraphNode, $isRangeSelection, $isTextNode, $parseSerializedNode, $setSelection, COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, DROP_COMMAND, DecoratorNode, FOCUS_COMMAND, FORMAT_ELEMENT_COMMAND, FORMAT_TEXT_COMMAND, HISTORY_PUSH_TAG, KEY_DOWN_COMMAND, PASTE_COMMAND, PASTE_TAG, createEditor } from "lexical";
|
|
35
34
|
import { $findMatchingParent, $getNearestBlockElementAncestorOrThrow, IS_APPLE } from "@lexical/utils";
|
|
36
35
|
import { $createLinkNode, $isAutoLinkNode, $isLinkNode, AutoLinkNode, LinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
|
|
37
36
|
import { $isListNode, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, ListItemNode, ListNode, REMOVE_LIST_COMMAND } from "@lexical/list";
|
|
@@ -42,6 +41,11 @@ import { $convertFromMarkdownString, $convertToMarkdownString, CHECK_LIST, TRANS
|
|
|
42
41
|
const ImageRendererContext = createContext(null);
|
|
43
42
|
const ImageRendererProvider = ImageRendererContext;
|
|
44
43
|
const useImageRenderer = () => use(ImageRendererContext);
|
|
44
|
+
const EditorLayerContext = createContext(void 0);
|
|
45
|
+
/** Takes the id `MarkdownEditor`'s blur guard matches on its toolbar's portalled layers. */
|
|
46
|
+
const EditorLayerProvider = EditorLayerContext;
|
|
47
|
+
/** Props for a layer the toolbar portals out of the editor, so its blur guard counts focus there. */
|
|
48
|
+
const useEditorLayer = () => ({ "data-editor-layer": use(EditorLayerContext) });
|
|
45
49
|
//#endregion
|
|
46
50
|
//#region src/blocks/MarkdownEditor/nodes/ImageNodeComponent.tsx
|
|
47
51
|
const isPlaceholder = (src) => src.startsWith("upload-placeholder-");
|
|
@@ -760,6 +764,7 @@ const TRIGGER_LABEL = "Text alignment";
|
|
|
760
764
|
const ALIGNMENT_ICONS = Object.fromEntries(ALIGNMENT_OPTIONS.map(({ value, icon }) => [value, icon]));
|
|
761
765
|
const AlignmentDropdown = ({ editor, alignment }) => {
|
|
762
766
|
const savedSelectionRef = useRef(null);
|
|
767
|
+
const editorLayer = useEditorLayer();
|
|
763
768
|
const handleOpenChange = (open) => {
|
|
764
769
|
if (open) editor.getEditorState().read(() => {
|
|
765
770
|
const selection = $getSelection();
|
|
@@ -789,6 +794,7 @@ const AlignmentDropdown = ({ editor, alignment }) => {
|
|
|
789
794
|
})
|
|
790
795
|
})
|
|
791
796
|
}), /* @__PURE__ */ jsx(DropdownMenu.Content, {
|
|
797
|
+
...editorLayer,
|
|
792
798
|
align: "start",
|
|
793
799
|
className: "tw:min-w-32",
|
|
794
800
|
children: ALIGNMENT_OPTIONS.map(({ value, label, icon: IconComp }) => /* @__PURE__ */ jsxs(DropdownMenu.Item, {
|
|
@@ -851,6 +857,7 @@ const BLOCK_TYPE_OPTIONS = Object.entries(BLOCK_TYPE_META).filter(([, meta]) =>
|
|
|
851
857
|
const isBlockType = (value) => Object.prototype.hasOwnProperty.call(BLOCK_TYPE_META, value);
|
|
852
858
|
const BlockTypeDropdown = ({ editor, blockType }) => {
|
|
853
859
|
const savedSelectionRef = useRef(null);
|
|
860
|
+
const editorLayer = useEditorLayer();
|
|
854
861
|
const currentMeta = isBlockType(blockType) ? BLOCK_TYPE_META[blockType] : null;
|
|
855
862
|
const CurrentBlockIcon = currentMeta?.icon ?? Type;
|
|
856
863
|
const handleOpenChange = (open) => {
|
|
@@ -898,6 +905,7 @@ const BlockTypeDropdown = ({ editor, blockType }) => {
|
|
|
898
905
|
})
|
|
899
906
|
})
|
|
900
907
|
}), /* @__PURE__ */ jsx(DropdownMenu.Content, {
|
|
908
|
+
...editorLayer,
|
|
901
909
|
align: "start",
|
|
902
910
|
className: "tw:min-w-40",
|
|
903
911
|
children: BLOCK_TYPE_OPTIONS.map(({ value, label, icon: IconComp }) => /* @__PURE__ */ jsxs(DropdownMenu.Item, {
|
|
@@ -922,7 +930,12 @@ const MOD_KEY = IS_APPLE ? {
|
|
|
922
930
|
aria: "Control",
|
|
923
931
|
symbol: "Ctrl"
|
|
924
932
|
};
|
|
925
|
-
|
|
933
|
+
/**
|
|
934
|
+
* An icon button named by its tooltip.
|
|
935
|
+
*
|
|
936
|
+
* - Other props reach the `<button>`, so it can be a trigger; they cannot rename it.
|
|
937
|
+
*/
|
|
938
|
+
const ToolbarButton = ({ active, tooltip, shortcutKey, children, className: extraClassName, ...props }) => /* @__PURE__ */ jsx(Tooltip, {
|
|
926
939
|
tooltip: shortcutKey ? /* @__PURE__ */ jsxs("span", {
|
|
927
940
|
className: "tw:inline-flex tw:items-center tw:gap-2",
|
|
928
941
|
children: [tooltip, /* @__PURE__ */ jsxs(Kbd.Group, {
|
|
@@ -941,16 +954,16 @@ const ToolbarButton = ({ active, disabled, onClick, tooltip, shortcutKey, childr
|
|
|
941
954
|
triggerAsChild: true,
|
|
942
955
|
children: /* @__PURE__ */ jsx("button", {
|
|
943
956
|
type: "button",
|
|
957
|
+
...props,
|
|
944
958
|
"aria-label": tooltip,
|
|
945
959
|
"aria-keyshortcuts": shortcutKey ? `${MOD_KEY.aria}+${shortcutKey}` : void 0,
|
|
946
|
-
disabled,
|
|
947
|
-
onClick,
|
|
948
960
|
className: cn("tw:flex tw:h-7 tw:min-w-7 tw:cursor-pointer tw:items-center tw:justify-center tw:rounded tw:border-0 tw:bg-transparent tw:px-1 tw:text-xs tw:text-muted-foreground", "tw:hover:bg-accent tw:disabled:cursor-not-allowed tw:disabled:opacity-30", active && "tw:bg-secondary-100 tw:text-secondary-500", extraClassName),
|
|
949
961
|
children
|
|
950
962
|
})
|
|
951
963
|
});
|
|
952
964
|
//#endregion
|
|
953
965
|
//#region src/blocks/MarkdownEditor/plugins/toolbar/LinkPanel.tsx
|
|
966
|
+
const PANEL_NAME = "Insert Link";
|
|
954
967
|
const insertLink = (editor, url, label, savedSelection) => {
|
|
955
968
|
editor.update(() => {
|
|
956
969
|
$setSelection(savedSelection);
|
|
@@ -969,13 +982,25 @@ const insertLink = (editor, url, label, savedSelection) => {
|
|
|
969
982
|
});
|
|
970
983
|
};
|
|
971
984
|
const LinkPanel = ({ editor, isLink }) => {
|
|
985
|
+
const editorLayer = useEditorLayer();
|
|
972
986
|
const [open, setOpen] = useState(false);
|
|
973
987
|
const [url, setUrl] = useState("");
|
|
974
988
|
const [label, setLabel] = useState("");
|
|
975
989
|
const savedSelectionRef = useRef(null);
|
|
976
990
|
const panelRef = useRef(null);
|
|
991
|
+
const labelRef = useRef(null);
|
|
977
992
|
const labelId = useId();
|
|
978
993
|
const urlId = useId();
|
|
994
|
+
/**
|
|
995
|
+
* Focuses and selects the Label field on every open.
|
|
996
|
+
*
|
|
997
|
+
* - `FocusScope` does it only on mount, and a reopen during the exit animation reuses the panel.
|
|
998
|
+
*/
|
|
999
|
+
useEffect(() => {
|
|
1000
|
+
if (!open) return;
|
|
1001
|
+
labelRef.current?.focus({ preventScroll: true });
|
|
1002
|
+
labelRef.current?.select();
|
|
1003
|
+
}, [open]);
|
|
979
1004
|
const handleOpen = () => {
|
|
980
1005
|
editor.getEditorState().read(() => {
|
|
981
1006
|
const selection = $getSelection();
|
|
@@ -998,14 +1023,14 @@ const LinkPanel = ({ editor, isLink }) => {
|
|
|
998
1023
|
editor.focus();
|
|
999
1024
|
};
|
|
1000
1025
|
/**
|
|
1001
|
-
*
|
|
1026
|
+
* Opens the panel, or closes it however Radix asks: Escape, its button again, an outside click.
|
|
1002
1027
|
*
|
|
1003
|
-
* -
|
|
1004
|
-
* - Focus
|
|
1028
|
+
* - Focus still in the panel (Escape, or the button, which leaves focus put): the editor takes it.
|
|
1029
|
+
* - Focus already elsewhere (an outside click or focus): it stays there.
|
|
1005
1030
|
*/
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
if (panelRef.current?.contains(document.activeElement)) handleClose();
|
|
1031
|
+
const handleOpenChange = (next) => {
|
|
1032
|
+
if (next) handleOpen();
|
|
1033
|
+
else if (panelRef.current?.contains(document.activeElement)) handleClose();
|
|
1009
1034
|
else resetPanel();
|
|
1010
1035
|
};
|
|
1011
1036
|
const handleSubmit = () => {
|
|
@@ -1026,58 +1051,62 @@ const LinkPanel = ({ editor, isLink }) => {
|
|
|
1026
1051
|
tooltip: "Remove Link",
|
|
1027
1052
|
children: /* @__PURE__ */ jsx(Link, { className: "tw:size-4" })
|
|
1028
1053
|
});
|
|
1029
|
-
return /* @__PURE__ */ jsxs(
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
onEscapeKeyDown: handleEscape,
|
|
1039
|
-
className: "tw:absolute tw:left-0 tw:top-full tw:z-50 tw:mt-1 tw:w-80 tw:rounded-md tw:border tw:border-border tw:bg-popover tw:p-3 tw:text-popover-foreground tw:shadow-lg",
|
|
1040
|
-
children: /* @__PURE__ */ jsxs("div", {
|
|
1041
|
-
className: "tw:flex tw:flex-col tw:gap-2",
|
|
1042
|
-
children: [
|
|
1043
|
-
/* @__PURE__ */ jsxs(FormGroup, { children: [/* @__PURE__ */ jsx(Label, {
|
|
1044
|
-
htmlFor: labelId,
|
|
1045
|
-
className: "tw:mb-1 tw:text-xs",
|
|
1046
|
-
children: "Label"
|
|
1047
|
-
}), /* @__PURE__ */ jsx(TextInput, {
|
|
1048
|
-
id: labelId,
|
|
1049
|
-
autoFocus: true,
|
|
1050
|
-
placeholder: "Link text",
|
|
1051
|
-
value: label,
|
|
1052
|
-
onValueChange: setLabel,
|
|
1053
|
-
onKeyDown: handleKeyDown,
|
|
1054
|
-
variant: "secondary"
|
|
1055
|
-
})] }),
|
|
1056
|
-
/* @__PURE__ */ jsxs(FormGroup, { children: [/* @__PURE__ */ jsx(Label, {
|
|
1057
|
-
htmlFor: urlId,
|
|
1058
|
-
className: "tw:mb-1 tw:text-xs",
|
|
1059
|
-
children: "URL"
|
|
1060
|
-
}), /* @__PURE__ */ jsx(TextInput, {
|
|
1061
|
-
id: urlId,
|
|
1062
|
-
placeholder: "https://…",
|
|
1063
|
-
value: url,
|
|
1064
|
-
onValueChange: setUrl,
|
|
1065
|
-
onKeyDown: handleKeyDown,
|
|
1066
|
-
variant: "secondary"
|
|
1067
|
-
})] }),
|
|
1068
|
-
/* @__PURE__ */ jsxs(ButtonGroup, { children: [/* @__PURE__ */ jsx(Button, {
|
|
1069
|
-
size: "small",
|
|
1070
|
-
variant: "outline",
|
|
1071
|
-
onClick: handleClose,
|
|
1072
|
-
children: "Cancel"
|
|
1073
|
-
}), /* @__PURE__ */ jsx(Button, {
|
|
1074
|
-
size: "small",
|
|
1075
|
-
onClick: handleSubmit,
|
|
1076
|
-
disabled: !url.trim(),
|
|
1077
|
-
children: "Add link"
|
|
1078
|
-
})] })
|
|
1079
|
-
]
|
|
1054
|
+
return /* @__PURE__ */ jsxs(Popover, {
|
|
1055
|
+
open,
|
|
1056
|
+
onOpenChange: handleOpenChange,
|
|
1057
|
+
children: [/* @__PURE__ */ jsx(Popover.Trigger, {
|
|
1058
|
+
asChild: true,
|
|
1059
|
+
children: /* @__PURE__ */ jsx(ToolbarButton, {
|
|
1060
|
+
active: open,
|
|
1061
|
+
tooltip: PANEL_NAME,
|
|
1062
|
+
children: /* @__PURE__ */ jsx(Link, { className: "tw:size-4" })
|
|
1080
1063
|
})
|
|
1064
|
+
}), /* @__PURE__ */ jsxs(Popover.Content, {
|
|
1065
|
+
ref: panelRef,
|
|
1066
|
+
"aria-label": PANEL_NAME,
|
|
1067
|
+
...editorLayer,
|
|
1068
|
+
align: "start",
|
|
1069
|
+
collisionPadding: 8,
|
|
1070
|
+
onCloseAutoFocus: (event) => event.preventDefault(),
|
|
1071
|
+
className: "tw:w-80 tw:max-w-[calc(100vw-1rem)] tw:gap-2 tw:p-3",
|
|
1072
|
+
children: [
|
|
1073
|
+
/* @__PURE__ */ jsxs(FormGroup, { children: [/* @__PURE__ */ jsx(Label, {
|
|
1074
|
+
htmlFor: labelId,
|
|
1075
|
+
className: "tw:mb-1 tw:text-xs",
|
|
1076
|
+
children: "Label"
|
|
1077
|
+
}), /* @__PURE__ */ jsx(TextInput, {
|
|
1078
|
+
ref: labelRef,
|
|
1079
|
+
id: labelId,
|
|
1080
|
+
placeholder: "Link text",
|
|
1081
|
+
value: label,
|
|
1082
|
+
onValueChange: setLabel,
|
|
1083
|
+
onKeyDown: handleKeyDown,
|
|
1084
|
+
variant: "secondary"
|
|
1085
|
+
})] }),
|
|
1086
|
+
/* @__PURE__ */ jsxs(FormGroup, { children: [/* @__PURE__ */ jsx(Label, {
|
|
1087
|
+
htmlFor: urlId,
|
|
1088
|
+
className: "tw:mb-1 tw:text-xs",
|
|
1089
|
+
children: "URL"
|
|
1090
|
+
}), /* @__PURE__ */ jsx(TextInput, {
|
|
1091
|
+
id: urlId,
|
|
1092
|
+
placeholder: "https://…",
|
|
1093
|
+
value: url,
|
|
1094
|
+
onValueChange: setUrl,
|
|
1095
|
+
onKeyDown: handleKeyDown,
|
|
1096
|
+
variant: "secondary"
|
|
1097
|
+
})] }),
|
|
1098
|
+
/* @__PURE__ */ jsxs(ButtonGroup, { children: [/* @__PURE__ */ jsx(Button, {
|
|
1099
|
+
size: "small",
|
|
1100
|
+
variant: "outline",
|
|
1101
|
+
onClick: handleClose,
|
|
1102
|
+
children: "Cancel"
|
|
1103
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
1104
|
+
size: "small",
|
|
1105
|
+
onClick: handleSubmit,
|
|
1106
|
+
disabled: !url.trim(),
|
|
1107
|
+
children: "Add link"
|
|
1108
|
+
})] })
|
|
1109
|
+
]
|
|
1081
1110
|
})]
|
|
1082
1111
|
});
|
|
1083
1112
|
};
|
|
@@ -1098,10 +1127,12 @@ const ELEMENT_FORMAT_TYPES = [
|
|
|
1098
1127
|
const isElementFormatType = (value) => ELEMENT_FORMAT_TYPES.includes(value);
|
|
1099
1128
|
const toElementFormatType = (value) => isElementFormatType(value) ? value : "left";
|
|
1100
1129
|
const TOOLBAR_BASE_CLASS = "tw:flex tw:items-center tw:gap-0.5 tw:rounded-lg tw:border tw:border-border tw:px-1.5 tw:py-1";
|
|
1130
|
+
const hasFocus = (element) => !!element?.contains(document.activeElement);
|
|
1101
1131
|
/**
|
|
1102
1132
|
* Keeps focus, and the editor selection with it, where it was when a toolbar control is pressed.
|
|
1103
1133
|
*
|
|
1104
1134
|
* - A link panel field takes focus as usual, so a click there lands the caret in it.
|
|
1135
|
+
* - The link panel is portalled, but React bubbles its events up here all the same.
|
|
1105
1136
|
*/
|
|
1106
1137
|
const keepEditorFocus = (event) => {
|
|
1107
1138
|
if (event.target instanceof HTMLInputElement) return;
|
|
@@ -1109,6 +1140,7 @@ const keepEditorFocus = (event) => {
|
|
|
1109
1140
|
};
|
|
1110
1141
|
const ToolbarPlugin = ({ mode = "floating" }) => {
|
|
1111
1142
|
const [editor] = useLexicalComposerContext();
|
|
1143
|
+
const editorLayer = useEditorLayer();
|
|
1112
1144
|
const [isBold, setIsBold] = useState(false);
|
|
1113
1145
|
const [isItalic, setIsItalic] = useState(false);
|
|
1114
1146
|
const [isUnderline, setIsUnderline] = useState(false);
|
|
@@ -1118,23 +1150,68 @@ const ToolbarPlugin = ({ mode = "floating" }) => {
|
|
|
1118
1150
|
const [blockType, setBlockType] = useState("paragraph");
|
|
1119
1151
|
const [alignment, setAlignment] = useState("left");
|
|
1120
1152
|
const [isVisible, setIsVisible] = useState(false);
|
|
1121
|
-
const [
|
|
1122
|
-
const toolbarRef = useRef(null);
|
|
1153
|
+
const [anchor, setAnchor] = useState(null);
|
|
1123
1154
|
const isMouseDownRef = useRef(false);
|
|
1124
1155
|
const hasSelectionRef = useRef(false);
|
|
1156
|
+
const toolbarRef = useRef(null);
|
|
1157
|
+
/** The selection an Escape hid the toolbar on; it stays hidden until the selection differs. */
|
|
1158
|
+
const dismissedRef = useRef(null);
|
|
1125
1159
|
const isFloating = mode === "floating";
|
|
1126
|
-
|
|
1160
|
+
/**
|
|
1161
|
+
* Anchors the floating toolbar to the DOM selection inside the editor.
|
|
1162
|
+
*
|
|
1163
|
+
* - The range is a copy, so it keeps measuring these nodes when the page selection moves.
|
|
1164
|
+
* - Measured live, so the toolbar follows the text on scroll and resize.
|
|
1165
|
+
* - A range whose nodes Lexical replaced measures zero; the last real rect stands in for it.
|
|
1166
|
+
* - A new anchor on every update, so floating-ui places the toolbar again when the text moves.
|
|
1167
|
+
*/
|
|
1168
|
+
const updateAnchor = useCallback(() => {
|
|
1127
1169
|
const nativeSelection = window.getSelection();
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1170
|
+
const root = editor.getRootElement();
|
|
1171
|
+
if (!root || !nativeSelection || nativeSelection.rangeCount === 0) return;
|
|
1172
|
+
const range = nativeSelection.getRangeAt(0).cloneRange();
|
|
1173
|
+
if (!root.contains(range.commonAncestorContainer)) return;
|
|
1174
|
+
let rect = range.getBoundingClientRect();
|
|
1175
|
+
if (rect.width === 0) return;
|
|
1176
|
+
const getBoundingClientRect = () => {
|
|
1177
|
+
const next = range.getBoundingClientRect();
|
|
1178
|
+
if (next.width > 0) rect = next;
|
|
1179
|
+
return rect;
|
|
1180
|
+
};
|
|
1181
|
+
setAnchor({
|
|
1182
|
+
getBoundingClientRect,
|
|
1183
|
+
contextElement: root
|
|
1184
|
+
});
|
|
1185
|
+
}, [editor]);
|
|
1186
|
+
/** Whether focus is in the editor or the toolbar, the places a selection shows the toolbar from. */
|
|
1187
|
+
const isFocusWithin = useCallback(() => hasFocus(editor.getRootElement()) || hasFocus(toolbarRef.current), [editor]);
|
|
1188
|
+
/**
|
|
1189
|
+
* Hides the floating toolbar on a click or focus outside it and the editor.
|
|
1190
|
+
*
|
|
1191
|
+
* - So Escape in another field reaches that field, not this layer.
|
|
1192
|
+
* - Radix counts the toolbar's own dropdown menus as inside.
|
|
1193
|
+
*/
|
|
1194
|
+
const handleInteractOutside = (event) => {
|
|
1195
|
+
const target = event.target;
|
|
1196
|
+
if (target instanceof Node && editor.getRootElement()?.contains(target)) return;
|
|
1197
|
+
setIsVisible(false);
|
|
1198
|
+
};
|
|
1199
|
+
/**
|
|
1200
|
+
* Escape on the floating toolbar, which `PopoverContent` stops at `document`.
|
|
1201
|
+
*
|
|
1202
|
+
* 1. Focus in the toolbar goes back to the editor, and the toolbar stays.
|
|
1203
|
+
* 2. Otherwise the toolbar hides until the selection changes; with focus in the editor, its own
|
|
1204
|
+
* Escape handling gets the key as a command.
|
|
1205
|
+
*/
|
|
1206
|
+
const handleEscape = (event) => {
|
|
1207
|
+
if (hasFocus(toolbarRef.current)) {
|
|
1208
|
+
editor.focus();
|
|
1209
|
+
return;
|
|
1136
1210
|
}
|
|
1137
|
-
|
|
1211
|
+
setIsVisible(false);
|
|
1212
|
+
dismissedRef.current = editor.getEditorState().read(() => $getSelection()?.clone() ?? null);
|
|
1213
|
+
if (hasFocus(editor.getRootElement())) editor.dispatchCommand(KEY_DOWN_COMMAND, event);
|
|
1214
|
+
};
|
|
1138
1215
|
useEffect(() => {
|
|
1139
1216
|
if (!isFloating) return;
|
|
1140
1217
|
const handleMouseDown = () => {
|
|
@@ -1142,15 +1219,24 @@ const ToolbarPlugin = ({ mode = "floating" }) => {
|
|
|
1142
1219
|
};
|
|
1143
1220
|
const handleMouseUp = () => {
|
|
1144
1221
|
isMouseDownRef.current = false;
|
|
1145
|
-
if (hasSelectionRef.current) setIsVisible(true);
|
|
1222
|
+
if (hasSelectionRef.current && !dismissedRef.current && isFocusWithin()) setIsVisible(true);
|
|
1146
1223
|
};
|
|
1147
1224
|
document.addEventListener("mousedown", handleMouseDown);
|
|
1148
1225
|
document.addEventListener("mouseup", handleMouseUp);
|
|
1226
|
+
const unregisterFocus = editor.registerCommand(FOCUS_COMMAND, () => {
|
|
1227
|
+
if (hasSelectionRef.current && !dismissedRef.current) setIsVisible(true);
|
|
1228
|
+
return false;
|
|
1229
|
+
}, COMMAND_PRIORITY_LOW);
|
|
1149
1230
|
return () => {
|
|
1150
1231
|
document.removeEventListener("mousedown", handleMouseDown);
|
|
1151
1232
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
1233
|
+
unregisterFocus();
|
|
1152
1234
|
};
|
|
1153
|
-
}, [
|
|
1235
|
+
}, [
|
|
1236
|
+
editor,
|
|
1237
|
+
isFloating,
|
|
1238
|
+
isFocusWithin
|
|
1239
|
+
]);
|
|
1154
1240
|
const updateToolbar = useCallback(() => {
|
|
1155
1241
|
const selection = $getSelection();
|
|
1156
1242
|
if (!$isRangeSelection(selection)) {
|
|
@@ -1160,9 +1246,10 @@ const ToolbarPlugin = ({ mode = "floating" }) => {
|
|
|
1160
1246
|
}
|
|
1161
1247
|
const hasSelection = !selection.isCollapsed() && selection.getTextContent().length > 0;
|
|
1162
1248
|
hasSelectionRef.current = hasSelection;
|
|
1249
|
+
if (dismissedRef.current && !selection.is(dismissedRef.current)) dismissedRef.current = null;
|
|
1163
1250
|
if (!hasSelection) setIsVisible(false);
|
|
1164
|
-
else if (!isMouseDownRef.current) setIsVisible(true);
|
|
1165
|
-
if (hasSelection)
|
|
1251
|
+
else if (!isMouseDownRef.current && !dismissedRef.current && isFocusWithin()) setIsVisible(true);
|
|
1252
|
+
if (hasSelection && isFloating) updateAnchor();
|
|
1166
1253
|
setIsBold(selection.hasFormat("bold"));
|
|
1167
1254
|
setIsItalic(selection.hasFormat("italic"));
|
|
1168
1255
|
setIsUnderline(selection.hasFormat("underline"));
|
|
@@ -1180,7 +1267,11 @@ const ToolbarPlugin = ({ mode = "floating" }) => {
|
|
|
1180
1267
|
else if ($isQuoteNode(blockElement)) setBlockType("quote");
|
|
1181
1268
|
else setBlockType("paragraph");
|
|
1182
1269
|
setAlignment(toElementFormatType(blockElement.getFormatType()));
|
|
1183
|
-
}, [
|
|
1270
|
+
}, [
|
|
1271
|
+
isFloating,
|
|
1272
|
+
isFocusWithin,
|
|
1273
|
+
updateAnchor
|
|
1274
|
+
]);
|
|
1184
1275
|
useEffect(() => {
|
|
1185
1276
|
const unregisterUpdate = editor.registerUpdateListener(({ editorState }) => {
|
|
1186
1277
|
editorState.read(() => {
|
|
@@ -1191,7 +1282,6 @@ const ToolbarPlugin = ({ mode = "floating" }) => {
|
|
|
1191
1282
|
unregisterUpdate();
|
|
1192
1283
|
};
|
|
1193
1284
|
}, [editor, updateToolbar]);
|
|
1194
|
-
if (isFloating && (!isVisible || !position)) return null;
|
|
1195
1285
|
const buttons = /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
1196
1286
|
/* @__PURE__ */ jsx(BlockTypeDropdown, {
|
|
1197
1287
|
editor,
|
|
@@ -1301,18 +1391,26 @@ const ToolbarPlugin = ({ mode = "floating" }) => {
|
|
|
1301
1391
|
className: cn(TOOLBAR_BASE_CLASS, "tw:mb-1 tw:flex-wrap tw:bg-background"),
|
|
1302
1392
|
children: buttons
|
|
1303
1393
|
});
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1394
|
+
if (!isVisible || !anchor) return null;
|
|
1395
|
+
return /* @__PURE__ */ jsxs(Popover, {
|
|
1396
|
+
open: true,
|
|
1397
|
+
children: [/* @__PURE__ */ jsx(Popover.Anchor, { virtualRef: { current: anchor } }), /* @__PURE__ */ jsx(Popover.Content, {
|
|
1398
|
+
...editorLayer,
|
|
1399
|
+
ref: toolbarRef,
|
|
1400
|
+
role: "toolbar",
|
|
1401
|
+
"aria-label": "Formatting options",
|
|
1402
|
+
side: "top",
|
|
1403
|
+
sideOffset: 8,
|
|
1404
|
+
collisionPadding: 8,
|
|
1405
|
+
hideWhenDetached: true,
|
|
1406
|
+
onOpenAutoFocus: (event) => event.preventDefault(),
|
|
1407
|
+
onEscapeKeyDown: handleEscape,
|
|
1408
|
+
onInteractOutside: handleInteractOutside,
|
|
1409
|
+
onMouseDown: keepEditorFocus,
|
|
1410
|
+
className: cn(TOOLBAR_BASE_CLASS, "tw:flex-row"),
|
|
1411
|
+
children: buttons
|
|
1412
|
+
})]
|
|
1413
|
+
});
|
|
1316
1414
|
};
|
|
1317
1415
|
//#endregion
|
|
1318
1416
|
//#region src/blocks/MarkdownEditor/theme.ts
|
|
@@ -1335,6 +1433,7 @@ const editorTheme = {
|
|
|
1335
1433
|
ol: "tw:!list-decimal",
|
|
1336
1434
|
ul: "tw:!list-disc",
|
|
1337
1435
|
checklist: "tw:!list-none tw:!pl-0",
|
|
1436
|
+
nested: { listitem: "tw:list-none tw:before:hidden tw:after:hidden" },
|
|
1338
1437
|
listitemChecked: [
|
|
1339
1438
|
"tw:relative tw:!pl-6 tw:list-none tw:outline-hidden tw:line-through tw:text-muted-foreground",
|
|
1340
1439
|
"tw:before:content-[\"\"] tw:before:absolute tw:before:left-0 tw:before:top-[3px]",
|
|
@@ -1378,12 +1477,14 @@ const MarkdownEditor = ({ value, onChange, readOnly = false, placeholder, autoFo
|
|
|
1378
1477
|
}
|
|
1379
1478
|
}));
|
|
1380
1479
|
const skipBlurSaveRef = useRef(false);
|
|
1480
|
+
const layerId = useId();
|
|
1381
1481
|
const handleBlur = saveOnBlur && onSave ? (event) => {
|
|
1382
1482
|
if (skipBlurSaveRef.current) {
|
|
1383
1483
|
skipBlurSaveRef.current = false;
|
|
1384
1484
|
return;
|
|
1385
1485
|
}
|
|
1386
|
-
|
|
1486
|
+
const next = event.relatedTarget;
|
|
1487
|
+
if (!event.currentTarget.contains(next) && !next?.closest(`[data-editor-layer="${layerId}"]`)) onSave();
|
|
1387
1488
|
} : void 0;
|
|
1388
1489
|
return /* @__PURE__ */ jsx(LexicalComposer, {
|
|
1389
1490
|
initialConfig,
|
|
@@ -1393,7 +1494,10 @@ const MarkdownEditor = ({ value, onChange, readOnly = false, placeholder, autoFo
|
|
|
1393
1494
|
className: cn(!compact && "tw:prose", compact && "tw:[&_p]:m-0 tw:[&_p+p]:mt-2", clampDisplay && "tw:[&:not(:focus-within)]:line-clamp-2", className),
|
|
1394
1495
|
onBlur: handleBlur,
|
|
1395
1496
|
children: [
|
|
1396
|
-
showToolbar && editable && /* @__PURE__ */ jsx(
|
|
1497
|
+
showToolbar && editable && /* @__PURE__ */ jsx(EditorLayerProvider, {
|
|
1498
|
+
value: layerId,
|
|
1499
|
+
children: /* @__PURE__ */ jsx(ToolbarPlugin, { mode: toolbarMode })
|
|
1500
|
+
}),
|
|
1397
1501
|
/* @__PURE__ */ jsx("div", {
|
|
1398
1502
|
className: cn("tw:relative tw:rounded-md tw:transition-colors", contentPadding, compact && "tw:-mx-1.5 tw:-my-0.5", editable && "tw:cursor-text tw:hover:bg-muted tw:focus-within:ring-2 tw:focus-within:ring-ring/40"),
|
|
1399
1503
|
children: /* @__PURE__ */ jsx(RichTextPlugin, {
|