@keboola/design 13.1.1 → 13.1.2

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @keboola/design
2
2
 
3
+ ## 13.1.2
4
+
5
+ ### Patch Changes
6
+
7
+ - `MarkdownEditor`'s floating toolbar works inside a modal `Modal` or `Sheet`.
8
+ - 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.
9
+ - 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.
10
+ - 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.
11
+ - 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.
12
+ - 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.
13
+ - 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.
14
+
3
15
  ## 13.1.1
4
16
 
5
17
  ### Patch Changes
@@ -3,7 +3,7 @@ import { ReactNode } from "react";
3
3
  /**
4
4
  * How the toolbar is presented.
5
5
  *
6
- * - `floating`: a popup over the selection, portalled to `document.body`.
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';
@@ -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,7 +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
17
  import { DismissableLayer } from "@radix-ui/react-dismissable-layer";
18
18
  import { $createCodeNode, $isCodeNode, CodeNode } from "@lexical/code";
19
19
  import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
@@ -31,7 +31,7 @@ import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPl
31
31
  import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
32
32
  import { TabIndentationPlugin } from "@lexical/react/LexicalTabIndentationPlugin";
33
33
  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";
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, FOCUS_COMMAND, FORMAT_ELEMENT_COMMAND, FORMAT_TEXT_COMMAND, HISTORY_PUSH_TAG, KEY_DOWN_COMMAND, PASTE_COMMAND, PASTE_TAG, createEditor } from "lexical";
35
35
  import { $findMatchingParent, $getNearestBlockElementAncestorOrThrow, IS_APPLE } from "@lexical/utils";
36
36
  import { $createLinkNode, $isAutoLinkNode, $isLinkNode, AutoLinkNode, LinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
37
37
  import { $isListNode, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, ListItemNode, ListNode, REMOVE_LIST_COMMAND } from "@lexical/list";
@@ -1098,6 +1098,7 @@ const ELEMENT_FORMAT_TYPES = [
1098
1098
  const isElementFormatType = (value) => ELEMENT_FORMAT_TYPES.includes(value);
1099
1099
  const toElementFormatType = (value) => isElementFormatType(value) ? value : "left";
1100
1100
  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";
1101
+ const hasFocus = (element) => !!element?.contains(document.activeElement);
1101
1102
  /**
1102
1103
  * Keeps focus, and the editor selection with it, where it was when a toolbar control is pressed.
1103
1104
  *
@@ -1118,23 +1119,68 @@ const ToolbarPlugin = ({ mode = "floating" }) => {
1118
1119
  const [blockType, setBlockType] = useState("paragraph");
1119
1120
  const [alignment, setAlignment] = useState("left");
1120
1121
  const [isVisible, setIsVisible] = useState(false);
1121
- const [position, setPosition] = useState(null);
1122
- const toolbarRef = useRef(null);
1122
+ const [anchor, setAnchor] = useState(null);
1123
1123
  const isMouseDownRef = useRef(false);
1124
1124
  const hasSelectionRef = useRef(false);
1125
+ const toolbarRef = useRef(null);
1126
+ /** The selection an Escape hid the toolbar on; it stays hidden until the selection differs. */
1127
+ const dismissedRef = useRef(null);
1125
1128
  const isFloating = mode === "floating";
1126
- const updatePosition = useCallback(() => {
1129
+ /**
1130
+ * Anchors the floating toolbar to the DOM selection inside the editor.
1131
+ *
1132
+ * - The range is a copy, so it keeps measuring these nodes when the page selection moves.
1133
+ * - Measured live, so the toolbar follows the text on scroll and resize.
1134
+ * - A range whose nodes Lexical replaced measures zero; the last real rect stands in for it.
1135
+ * - A new anchor on every update, so floating-ui places the toolbar again when the text moves.
1136
+ */
1137
+ const updateAnchor = useCallback(() => {
1127
1138
  const nativeSelection = window.getSelection();
1128
- if (!nativeSelection || nativeSelection.rangeCount === 0) return;
1129
- const rect = nativeSelection.getRangeAt(0).getBoundingClientRect();
1130
- if (rect.width > 0) {
1131
- const toolbarWidth = toolbarRef.current?.offsetWidth ?? 300;
1132
- setPosition({
1133
- top: rect.top + window.scrollY - 8,
1134
- left: Math.max(8, rect.left + window.scrollX + rect.width / 2 - toolbarWidth / 2)
1135
- });
1139
+ const root = editor.getRootElement();
1140
+ if (!root || !nativeSelection || nativeSelection.rangeCount === 0) return;
1141
+ const range = nativeSelection.getRangeAt(0).cloneRange();
1142
+ if (!root.contains(range.commonAncestorContainer)) return;
1143
+ let rect = range.getBoundingClientRect();
1144
+ if (rect.width === 0) return;
1145
+ const getBoundingClientRect = () => {
1146
+ const next = range.getBoundingClientRect();
1147
+ if (next.width > 0) rect = next;
1148
+ return rect;
1149
+ };
1150
+ setAnchor({
1151
+ getBoundingClientRect,
1152
+ contextElement: root
1153
+ });
1154
+ }, [editor]);
1155
+ /** Whether focus is in the editor or the toolbar, the places a selection shows the toolbar from. */
1156
+ const isFocusWithin = useCallback(() => hasFocus(editor.getRootElement()) || hasFocus(toolbarRef.current), [editor]);
1157
+ /**
1158
+ * Hides the floating toolbar on a click or focus outside it and the editor.
1159
+ *
1160
+ * - So Escape in another field reaches that field, not this layer.
1161
+ * - Radix counts the toolbar's own dropdown menus as inside.
1162
+ */
1163
+ const handleInteractOutside = (event) => {
1164
+ const target = event.target;
1165
+ if (target instanceof Node && editor.getRootElement()?.contains(target)) return;
1166
+ setIsVisible(false);
1167
+ };
1168
+ /**
1169
+ * Escape on the floating toolbar, which `PopoverContent` stops at `document`.
1170
+ *
1171
+ * 1. Focus in the toolbar goes back to the editor, and the toolbar stays.
1172
+ * 2. Otherwise the toolbar hides until the selection changes; with focus in the editor, its own
1173
+ * Escape handling gets the key as a command.
1174
+ */
1175
+ const handleEscape = (event) => {
1176
+ if (hasFocus(toolbarRef.current)) {
1177
+ editor.focus();
1178
+ return;
1136
1179
  }
1137
- }, []);
1180
+ setIsVisible(false);
1181
+ dismissedRef.current = editor.getEditorState().read(() => $getSelection()?.clone() ?? null);
1182
+ if (hasFocus(editor.getRootElement())) editor.dispatchCommand(KEY_DOWN_COMMAND, event);
1183
+ };
1138
1184
  useEffect(() => {
1139
1185
  if (!isFloating) return;
1140
1186
  const handleMouseDown = () => {
@@ -1142,15 +1188,24 @@ const ToolbarPlugin = ({ mode = "floating" }) => {
1142
1188
  };
1143
1189
  const handleMouseUp = () => {
1144
1190
  isMouseDownRef.current = false;
1145
- if (hasSelectionRef.current) setIsVisible(true);
1191
+ if (hasSelectionRef.current && !dismissedRef.current && isFocusWithin()) setIsVisible(true);
1146
1192
  };
1147
1193
  document.addEventListener("mousedown", handleMouseDown);
1148
1194
  document.addEventListener("mouseup", handleMouseUp);
1195
+ const unregisterFocus = editor.registerCommand(FOCUS_COMMAND, () => {
1196
+ if (hasSelectionRef.current && !dismissedRef.current) setIsVisible(true);
1197
+ return false;
1198
+ }, COMMAND_PRIORITY_LOW);
1149
1199
  return () => {
1150
1200
  document.removeEventListener("mousedown", handleMouseDown);
1151
1201
  document.removeEventListener("mouseup", handleMouseUp);
1202
+ unregisterFocus();
1152
1203
  };
1153
- }, [isFloating]);
1204
+ }, [
1205
+ editor,
1206
+ isFloating,
1207
+ isFocusWithin
1208
+ ]);
1154
1209
  const updateToolbar = useCallback(() => {
1155
1210
  const selection = $getSelection();
1156
1211
  if (!$isRangeSelection(selection)) {
@@ -1160,9 +1215,10 @@ const ToolbarPlugin = ({ mode = "floating" }) => {
1160
1215
  }
1161
1216
  const hasSelection = !selection.isCollapsed() && selection.getTextContent().length > 0;
1162
1217
  hasSelectionRef.current = hasSelection;
1218
+ if (dismissedRef.current && !selection.is(dismissedRef.current)) dismissedRef.current = null;
1163
1219
  if (!hasSelection) setIsVisible(false);
1164
- else if (!isMouseDownRef.current) setIsVisible(true);
1165
- if (hasSelection) updatePosition();
1220
+ else if (!isMouseDownRef.current && !dismissedRef.current && isFocusWithin()) setIsVisible(true);
1221
+ if (hasSelection && isFloating) updateAnchor();
1166
1222
  setIsBold(selection.hasFormat("bold"));
1167
1223
  setIsItalic(selection.hasFormat("italic"));
1168
1224
  setIsUnderline(selection.hasFormat("underline"));
@@ -1180,7 +1236,11 @@ const ToolbarPlugin = ({ mode = "floating" }) => {
1180
1236
  else if ($isQuoteNode(blockElement)) setBlockType("quote");
1181
1237
  else setBlockType("paragraph");
1182
1238
  setAlignment(toElementFormatType(blockElement.getFormatType()));
1183
- }, [updatePosition]);
1239
+ }, [
1240
+ isFloating,
1241
+ isFocusWithin,
1242
+ updateAnchor
1243
+ ]);
1184
1244
  useEffect(() => {
1185
1245
  const unregisterUpdate = editor.registerUpdateListener(({ editorState }) => {
1186
1246
  editorState.read(() => {
@@ -1191,7 +1251,6 @@ const ToolbarPlugin = ({ mode = "floating" }) => {
1191
1251
  unregisterUpdate();
1192
1252
  };
1193
1253
  }, [editor, updateToolbar]);
1194
- if (isFloating && (!isVisible || !position)) return null;
1195
1254
  const buttons = /* @__PURE__ */ jsxs(Fragment$1, { children: [
1196
1255
  /* @__PURE__ */ jsx(BlockTypeDropdown, {
1197
1256
  editor,
@@ -1301,18 +1360,25 @@ const ToolbarPlugin = ({ mode = "floating" }) => {
1301
1360
  className: cn(TOOLBAR_BASE_CLASS, "tw:mb-1 tw:flex-wrap tw:bg-background"),
1302
1361
  children: buttons
1303
1362
  });
1304
- return createPortal(/* @__PURE__ */ jsx("div", {
1305
- ref: toolbarRef,
1306
- role: "toolbar",
1307
- "aria-label": "Formatting options",
1308
- onMouseDown: keepEditorFocus,
1309
- style: {
1310
- top: position?.top,
1311
- left: position?.left
1312
- },
1313
- className: cn(TOOLBAR_BASE_CLASS, "tw:absolute tw:z-[1060] tw:-translate-y-full tw:bg-popover tw:text-popover-foreground tw:shadow-lg"),
1314
- children: buttons
1315
- }), document.body);
1363
+ if (!isVisible || !anchor) return null;
1364
+ return /* @__PURE__ */ jsxs(Popover, {
1365
+ open: true,
1366
+ children: [/* @__PURE__ */ jsx(Popover.Anchor, { virtualRef: { current: anchor } }), /* @__PURE__ */ jsx(Popover.Content, {
1367
+ ref: toolbarRef,
1368
+ role: "toolbar",
1369
+ "aria-label": "Formatting options",
1370
+ side: "top",
1371
+ sideOffset: 8,
1372
+ collisionPadding: 8,
1373
+ hideWhenDetached: true,
1374
+ onOpenAutoFocus: (event) => event.preventDefault(),
1375
+ onEscapeKeyDown: handleEscape,
1376
+ onInteractOutside: handleInteractOutside,
1377
+ onMouseDown: keepEditorFocus,
1378
+ className: cn(TOOLBAR_BASE_CLASS, "tw:flex-row tw:overflow-visible"),
1379
+ children: buttons
1380
+ })]
1381
+ });
1316
1382
  };
1317
1383
  //#endregion
1318
1384
  //#region src/blocks/MarkdownEditor/theme.ts