@haklex/rich-plugin-floating-toolbar 0.58.0 → 0.59.0

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/dist/index.mjs CHANGED
@@ -1,23 +1,32 @@
1
1
  import { getDOMRectFromTextSelection, useTextSelectionSnapshot } from "@haklex/rich-editor";
2
2
  import { $selectionTouchesSpoiler, $toggleSpoilerSelection } from "@haklex/rich-editor/commands";
3
3
  import { $createRubyNode, $isKaTeXInlineNode, $isRubyNode } from "@haklex/rich-editor/nodes";
4
- import { ColorPicker } from "@haklex/rich-editor-ui";
4
+ import { ActionButton, ColorPicker, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, Kbd, TextInput, TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger, createTooltipHandle } from "@haklex/rich-editor-ui";
5
5
  import { usePortalContainer, usePortalTheme, vars } from "@haklex/rich-style-token";
6
- import { $isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
7
6
  import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
8
7
  import { $getSelectionStyleValueForProperty, $patchStyleText } from "@lexical/selection";
9
- import { $createTextNode, $getNodeByKey, $getSelection, $isRangeSelection, COMMAND_PRIORITY_LOW, FORMAT_TEXT_COMMAND, SELECTION_CHANGE_COMMAND } from "lexical";
10
- import { Bold, Check, Code, EyeOff, Highlighter, Italic, Languages, Link, Strikethrough, Subscript, Superscript, Trash2, Underline, X } from "lucide-react";
11
- import { useCallback, useEffect, useRef, useState } from "react";
8
+ import { $createTextNode, $getNearestNodeFromDOMNode, $getNodeByKey, $getSelection, $isRangeSelection, $isTextNode, $setSelection, COMMAND_PRIORITY_LOW, FORMAT_TEXT_COMMAND, KEY_DOWN_COMMAND, SELECTION_CHANGE_COMMAND } from "lexical";
9
+ import { Bold, Code, Ellipsis, EyeOff, Highlighter, Italic, Languages, Link, RemoveFormatting, Strikethrough, Subscript, Superscript, Trash2, Underline, Unlink, X } from "lucide-react";
10
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
12
11
  import { createPortal } from "react-dom";
12
+ import { $isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
13
13
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
14
- //#region src/styles.css.ts
15
- var toolbar = "_1m6axz71";
16
- var btn = "_1m6axz72";
17
- var btnActive = "_1m6axz73";
18
- var rubyEditor = "_1m6axz75";
19
- //#endregion
20
- //#region src/FloatingToolbarPlugin.tsx
14
+ //#region src/selection-state.ts
15
+ var INITIAL_STATE = {
16
+ isBold: false,
17
+ isItalic: false,
18
+ isUnderline: false,
19
+ isStrikethrough: false,
20
+ isSuperscript: false,
21
+ isSubscript: false,
22
+ isCode: false,
23
+ isHighlight: false,
24
+ isLink: false,
25
+ isRuby: false,
26
+ isSpoiler: false,
27
+ fontColor: "",
28
+ backgroundColor: ""
29
+ };
21
30
  function isEffectiveLinkNode(node) {
22
31
  if (!$isLinkNode(node)) return false;
23
32
  return !$isAutoLinkNode(node) || !node.getIsUnlinked();
@@ -25,6 +34,19 @@ function isEffectiveLinkNode(node) {
25
34
  function isRegularLinkNode(node) {
26
35
  return $isLinkNode(node) && !$isAutoLinkNode(node);
27
36
  }
37
+ function $selectionHasLink(selection) {
38
+ return selection.getNodes().some((node) => {
39
+ return isEffectiveLinkNode(node.getParent()) || isEffectiveLinkNode(node);
40
+ });
41
+ }
42
+ function $getSelectedLinkUrl(selection) {
43
+ let current = selection.anchor.getNode();
44
+ while (current) {
45
+ if ($isLinkNode(current)) return current.getURL();
46
+ current = current.getParent();
47
+ }
48
+ return "";
49
+ }
28
50
  function collectSelectedActiveAutoLinkNodes(selection) {
29
51
  const autoLinkNodes = /* @__PURE__ */ new Map();
30
52
  for (const node of selection.getNodes()) {
@@ -34,20 +56,6 @@ function collectSelectedActiveAutoLinkNodes(selection) {
34
56
  }
35
57
  return Array.from(autoLinkNodes.values());
36
58
  }
37
- var INITIAL_STATE = {
38
- isBold: false,
39
- isItalic: false,
40
- isUnderline: false,
41
- isStrikethrough: false,
42
- isSuperscript: false,
43
- isSubscript: false,
44
- isCode: false,
45
- isHighlight: false,
46
- isLink: false,
47
- isRuby: false,
48
- isSpoiler: false,
49
- fontColor: ""
50
- };
51
59
  function findRubyAncestor(node) {
52
60
  let current = node;
53
61
  while (current) {
@@ -66,10 +74,6 @@ function getSelectedRubyNodes(nodes) {
66
74
  }
67
75
  function getSelectionState(selection) {
68
76
  const nodes = selection.getNodes();
69
- const hasLink = nodes.some((node) => {
70
- return isEffectiveLinkNode(node.getParent()) || isEffectiveLinkNode(node);
71
- });
72
- const hasRuby = getSelectedRubyNodes(nodes).length > 0;
73
77
  let fontColor = $getSelectionStyleValueForProperty(selection, "color", "");
74
78
  if (!fontColor) {
75
79
  const katex = nodes.find((n) => $isKaTeXInlineNode(n));
@@ -84,42 +88,641 @@ function getSelectionState(selection) {
84
88
  isSubscript: selection.hasFormat("subscript"),
85
89
  isCode: selection.hasFormat("code"),
86
90
  isHighlight: selection.hasFormat("highlight"),
87
- isLink: hasLink,
88
- isRuby: hasRuby,
91
+ isLink: $selectionHasLink(selection),
92
+ isRuby: getSelectedRubyNodes(nodes).length > 0,
89
93
  isSpoiler: $selectionTouchesSpoiler(selection),
90
- fontColor
94
+ fontColor,
95
+ backgroundColor: $getSelectionStyleValueForProperty(selection, "background-color", "")
91
96
  };
92
97
  }
93
- function computePosition(rect, toolbar, container) {
94
- if (rect.width === 0 && rect.height === 0) return null;
95
- const toolbarWidth = toolbar.offsetWidth;
96
- const toolbarHeight = toolbar.offsetHeight;
97
- const containerRect = container === document.body ? void 0 : container.getBoundingClientRect();
98
- const offsetX = containerRect?.left ?? 0;
99
- const offsetY = containerRect?.top ?? 0;
100
- const availableWidth = containerRect?.width ?? window.innerWidth;
101
- const rawLeft = rect.left - offsetX + rect.width / 2 - toolbarWidth / 2;
102
- const clampedLeft = Math.max(8, Math.min(rawLeft, availableWidth - toolbarWidth - 8));
98
+ //#endregion
99
+ //#region src/actions.ts
100
+ var SCHEME = /^[a-z][\d+.a-z-]*:/i;
101
+ var LOOKS_LIKE_URL = /^(?:https?:\/\/|www\.)\S+$|^[\w-]+(?:\.[\w-]+)+(?:[#/?]\S*)?$/i;
102
+ function normalizeUrl(input) {
103
+ const url = input.trim();
104
+ if (!url) return "";
105
+ if (SCHEME.test(url) || url.startsWith("/") || url.startsWith("#")) return url;
106
+ return `https://${url}`;
107
+ }
108
+ function looksLikeUrl(text) {
109
+ return LOOKS_LIKE_URL.test(text.trim());
110
+ }
111
+ async function readClipboardUrl() {
112
+ try {
113
+ if ((await navigator.permissions?.query({ name: "clipboard-read" }))?.state !== "granted") return "";
114
+ const text = (await navigator.clipboard?.readText())?.trim() ?? "";
115
+ return looksLikeUrl(text) ? text : "";
116
+ } catch {
117
+ return "";
118
+ }
119
+ }
120
+ function $removeLinks(editor, selection) {
121
+ for (const autoLinkNode of collectSelectedActiveAutoLinkNodes(selection)) {
122
+ autoLinkNode.setIsUnlinked(true);
123
+ autoLinkNode.markDirty();
124
+ }
125
+ if (selection.getNodes().some((node) => {
126
+ return isRegularLinkNode(node.getParent()) || isRegularLinkNode(node);
127
+ })) editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
128
+ }
129
+ function $clearFormatting() {
130
+ const selection = $getSelection();
131
+ if (!$isRangeSelection(selection)) return;
132
+ for (const node of selection.extract()) if ($isTextNode(node)) {
133
+ node.setFormat(0);
134
+ node.setStyle("");
135
+ } else if ($isKaTeXInlineNode(node)) node.setColor(null);
136
+ }
137
+ function $wrapSelectionInRuby() {
138
+ const selection = $getSelection();
139
+ if (!$isRangeSelection(selection)) return null;
140
+ const text = selection.getTextContent();
141
+ if (!text.trim()) return null;
142
+ selection.removeText();
143
+ const rubyNode = $createRubyNode("");
144
+ rubyNode.append($createTextNode(text));
145
+ const freshSelection = $getSelection();
146
+ if ($isRangeSelection(freshSelection)) freshSelection.insertNodes([rubyNode]);
103
147
  return {
104
- top: rect.top - offsetY - toolbarHeight - 10,
105
- left: clampedLeft
148
+ nodeKey: rubyNode.getKey(),
149
+ reading: "",
150
+ isNew: true
106
151
  };
107
152
  }
108
- function ToolbarButton({ active, onClick, ariaLabel, children }) {
109
- return /* @__PURE__ */ jsxs("button", {
110
- "aria-label": ariaLabel,
111
- "aria-pressed": active,
112
- className: `${btn}${active ? ` ${btnActive}` : ""}`,
113
- type: "button",
114
- onMouseDown: (e) => {
115
- e.preventDefault();
116
- onClick();
153
+ function $unwrapRuby(nodeKey) {
154
+ const node = $getNodeByKey(nodeKey);
155
+ if (!$isRubyNode(node)) return;
156
+ for (const child of node.getChildren()) node.insertBefore(child);
157
+ node.remove();
158
+ }
159
+ function $setRubyReading(nodeKey, reading) {
160
+ const node = $getNodeByKey(nodeKey);
161
+ if ($isRubyNode(node)) node.setReading(reading);
162
+ }
163
+ //#endregion
164
+ //#region src/styles.css.ts
165
+ var toolbar = "_1m6axz71";
166
+ var btn = "_1m6axz74";
167
+ var colorTrigger = "_1m6axz75";
168
+ var separator = "_1m6axz76";
169
+ var inputRow = "_1m6axz77";
170
+ var inputIcon = "_1m6axz78";
171
+ var inlineInput = "_1m6axz79";
172
+ //#endregion
173
+ //#region src/ToolbarButton.tsx
174
+ function createToolbarTooltipHandle() {
175
+ return createTooltipHandle();
176
+ }
177
+ function ToolbarButton({ active, children, handle, label, onClick, render, shortcut }) {
178
+ return /* @__PURE__ */ jsx(TooltipTrigger, {
179
+ "aria-label": label,
180
+ "data-shortcut": shortcut,
181
+ "data-tooltip": label,
182
+ handle,
183
+ payload: {
184
+ label,
185
+ shortcut
117
186
  },
118
- children: [children, active && /* @__PURE__ */ jsx("span", { className: "_1m6axz74" })]
187
+ render: render ?? /* @__PURE__ */ jsx("button", {
188
+ "aria-pressed": active,
189
+ className: "_1m6axz74",
190
+ type: "button",
191
+ onClick,
192
+ onMouseDown: (e) => e.preventDefault()
193
+ }),
194
+ children
195
+ });
196
+ }
197
+ //#endregion
198
+ //#region src/FormatBar.tsx
199
+ var ICON$1 = {
200
+ size: 16,
201
+ strokeWidth: 2
202
+ };
203
+ var MARKS = [
204
+ {
205
+ format: "bold",
206
+ label: "Bold",
207
+ shortcut: "Mod+B",
208
+ icon: /* @__PURE__ */ jsx(Bold, { ...ICON$1 })
209
+ },
210
+ {
211
+ format: "italic",
212
+ label: "Italic",
213
+ shortcut: "Mod+I",
214
+ icon: /* @__PURE__ */ jsx(Italic, { ...ICON$1 })
215
+ },
216
+ {
217
+ format: "underline",
218
+ label: "Underline",
219
+ shortcut: "Mod+U",
220
+ icon: /* @__PURE__ */ jsx(Underline, { ...ICON$1 })
221
+ },
222
+ {
223
+ format: "strikethrough",
224
+ label: "Strikethrough",
225
+ icon: /* @__PURE__ */ jsx(Strikethrough, { ...ICON$1 })
226
+ }
227
+ ];
228
+ var STATE_KEY = {
229
+ bold: "isBold",
230
+ italic: "isItalic",
231
+ underline: "isUnderline",
232
+ strikethrough: "isStrikethrough"
233
+ };
234
+ function Separator() {
235
+ return /* @__PURE__ */ jsx("span", {
236
+ "aria-hidden": true,
237
+ className: separator
119
238
  });
120
239
  }
121
- var ICON_SIZE = 15;
122
- var ICON_STROKE = 2;
240
+ function FormatBar({ actions, handle, onBackgroundColor, onClearFormatting, onColor, onFormat, onLink, onRuby, onSpoiler, state }) {
241
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
242
+ MARKS.map((mark) => /* @__PURE__ */ jsx(ToolbarButton, {
243
+ active: Boolean(state[STATE_KEY[mark.format]]),
244
+ handle,
245
+ label: mark.label,
246
+ shortcut: mark.shortcut,
247
+ onClick: () => onFormat(mark.format),
248
+ children: mark.icon
249
+ }, mark.format)),
250
+ /* @__PURE__ */ jsx(Separator, {}),
251
+ /* @__PURE__ */ jsx(ToolbarButton, {
252
+ active: state.isCode,
253
+ handle,
254
+ label: "Inline code",
255
+ onClick: () => onFormat("code"),
256
+ children: /* @__PURE__ */ jsx(Code, { ...ICON$1 })
257
+ }),
258
+ /* @__PURE__ */ jsx(ToolbarButton, {
259
+ active: state.isHighlight,
260
+ handle,
261
+ label: "Highlight",
262
+ onClick: () => onFormat("highlight"),
263
+ children: /* @__PURE__ */ jsx(Highlighter, { ...ICON$1 })
264
+ }),
265
+ /* @__PURE__ */ jsx(ToolbarButton, {
266
+ active: state.isSpoiler,
267
+ handle,
268
+ label: "Spoiler",
269
+ onClick: onSpoiler,
270
+ children: /* @__PURE__ */ jsx(EyeOff, { ...ICON$1 })
271
+ }),
272
+ /* @__PURE__ */ jsx(Separator, {}),
273
+ /* @__PURE__ */ jsx(ToolbarButton, {
274
+ active: state.isLink,
275
+ handle,
276
+ label: "Link",
277
+ shortcut: "Mod+K",
278
+ onClick: onLink,
279
+ children: /* @__PURE__ */ jsx(Link, { ...ICON$1 })
280
+ }),
281
+ /* @__PURE__ */ jsx(ToolbarButton, {
282
+ active: state.isRuby,
283
+ handle,
284
+ label: "Ruby",
285
+ onClick: onRuby,
286
+ children: /* @__PURE__ */ jsx(Languages, { ...ICON$1 })
287
+ }),
288
+ /* @__PURE__ */ jsx(Separator, {}),
289
+ /* @__PURE__ */ jsx(ColorPicker, {
290
+ className: colorTrigger,
291
+ currentBackground: state.backgroundColor,
292
+ currentColor: state.fontColor || "inherit",
293
+ tooltip: "Text color",
294
+ onSelect: onColor,
295
+ onSelectBackground: onBackgroundColor
296
+ }),
297
+ /* @__PURE__ */ jsxs(DropdownMenu, {
298
+ modal: false,
299
+ children: [/* @__PURE__ */ jsx(ToolbarButton, {
300
+ handle,
301
+ label: "More formatting",
302
+ render: /* @__PURE__ */ jsx(DropdownMenuTrigger, { render: /* @__PURE__ */ jsx("button", {
303
+ className: btn,
304
+ type: "button",
305
+ onMouseDown: (e) => e.preventDefault()
306
+ }) }),
307
+ onClick: () => {},
308
+ children: /* @__PURE__ */ jsx(Ellipsis, { ...ICON$1 })
309
+ }), /* @__PURE__ */ jsxs(DropdownMenuContent, {
310
+ align: "end",
311
+ positionMethod: "fixed",
312
+ sideOffset: 8,
313
+ children: [
314
+ /* @__PURE__ */ jsxs(DropdownMenuCheckboxItem, {
315
+ checked: state.isSuperscript,
316
+ onCheckedChange: () => onFormat("superscript"),
317
+ children: [/* @__PURE__ */ jsx(Superscript, { ...ICON$1 }), "Superscript"]
318
+ }),
319
+ /* @__PURE__ */ jsxs(DropdownMenuCheckboxItem, {
320
+ checked: state.isSubscript,
321
+ onCheckedChange: () => onFormat("subscript"),
322
+ children: [/* @__PURE__ */ jsx(Subscript, { ...ICON$1 }), "Subscript"]
323
+ }),
324
+ /* @__PURE__ */ jsx(DropdownMenuSeparator, {}),
325
+ /* @__PURE__ */ jsxs(DropdownMenuItem, {
326
+ onClick: onClearFormatting,
327
+ children: [/* @__PURE__ */ jsx(RemoveFormatting, { ...ICON$1 }), "Clear formatting"]
328
+ })
329
+ ]
330
+ })]
331
+ }),
332
+ actions && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Separator, {}), actions] })
333
+ ] });
334
+ }
335
+ //#endregion
336
+ //#region src/InlineInputBar.tsx
337
+ function InlineInputBar({ handle, icon, label, onCancel, onChange, onSubmit, placeholder, secondary, type = "text", value }) {
338
+ const inputRef = useRef(null);
339
+ useEffect(() => {
340
+ inputRef.current?.focus({ preventScroll: true });
341
+ }, []);
342
+ return /* @__PURE__ */ jsxs("div", {
343
+ className: inputRow,
344
+ children: [
345
+ /* @__PURE__ */ jsx("span", {
346
+ "aria-hidden": true,
347
+ className: inputIcon,
348
+ children: icon
349
+ }),
350
+ /* @__PURE__ */ jsx(TextInput, {
351
+ "aria-label": label,
352
+ autoComplete: "off",
353
+ className: inlineInput,
354
+ placeholder,
355
+ ref: inputRef,
356
+ spellCheck: false,
357
+ type,
358
+ value,
359
+ onChange: (e) => onChange(e.target.value),
360
+ onKeyDown: (e) => {
361
+ if (e.nativeEvent.isComposing) return;
362
+ if (e.key === "Enter") {
363
+ e.preventDefault();
364
+ onSubmit();
365
+ } else if (e.key === "Escape") {
366
+ e.preventDefault();
367
+ e.stopPropagation();
368
+ onCancel();
369
+ }
370
+ }
371
+ }),
372
+ /* @__PURE__ */ jsxs(ActionButton, {
373
+ "aria-label": "Save",
374
+ size: "md",
375
+ variant: "solid",
376
+ onClick: onSubmit,
377
+ onMouseDown: (e) => e.preventDefault(),
378
+ children: ["Save", /* @__PURE__ */ jsx(Kbd, {
379
+ keys: "enter",
380
+ size: "sm",
381
+ tone: "inverted"
382
+ })]
383
+ }),
384
+ /* @__PURE__ */ jsx(TooltipTrigger, {
385
+ handle,
386
+ payload: {
387
+ label: secondary.label,
388
+ shortcut: secondary.shortcut
389
+ },
390
+ render: /* @__PURE__ */ jsx(ActionButton, {
391
+ icon: true,
392
+ "aria-label": secondary.label,
393
+ danger: secondary.danger,
394
+ size: "md",
395
+ onClick: secondary.onClick,
396
+ onMouseDown: (e) => e.preventDefault()
397
+ }),
398
+ children: secondary.icon
399
+ })
400
+ ]
401
+ });
402
+ }
403
+ //#endregion
404
+ //#region src/roving.ts
405
+ function items(toolbar) {
406
+ return Array.from(toolbar.querySelectorAll("button:not(:disabled)"));
407
+ }
408
+ function syncRovingTabIndex(toolbar, focused) {
409
+ const buttons = items(toolbar);
410
+ if (!buttons.includes(focused)) return;
411
+ for (const button of buttons) button.tabIndex = button === focused ? 0 : -1;
412
+ }
413
+ function focusFirstItem(toolbar) {
414
+ if (!toolbar) return false;
415
+ const first = items(toolbar)[0];
416
+ first?.focus();
417
+ return Boolean(first);
418
+ }
419
+ function moveToolbarFocus(e) {
420
+ const buttons = items(e.currentTarget);
421
+ const index = buttons.indexOf(document.activeElement);
422
+ if (index === -1) return false;
423
+ const last = buttons.length - 1;
424
+ const next = e.key === "ArrowRight" ? index === last ? 0 : index + 1 : e.key === "ArrowLeft" ? index === 0 ? last : index - 1 : e.key === "Home" ? 0 : e.key === "End" ? last : -1;
425
+ if (next === -1) return false;
426
+ e.preventDefault();
427
+ buttons[next]?.focus();
428
+ return true;
429
+ }
430
+ //#endregion
431
+ //#region src/use-editor-bindings.ts
432
+ function useEditorBindings({ editor, editRuby, modeRef, openLink, toolbarRef, updateToolbar }) {
433
+ useEffect(() => {
434
+ const onRootClick = (event) => {
435
+ const target = event.target;
436
+ if (!target) return;
437
+ if (window.getSelection()?.isCollapsed === false) return;
438
+ const ruby = editor.read(() => {
439
+ const node = findRubyAncestor($getNearestNodeFromDOMNode(target));
440
+ return node ? {
441
+ key: node.getKey(),
442
+ reading: node.getReading()
443
+ } : null;
444
+ });
445
+ if (ruby) editRuby(ruby.key, ruby.reading);
446
+ };
447
+ let attachedRoot = null;
448
+ return mergeCleanups(editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
449
+ updateToolbar();
450
+ return false;
451
+ }, COMMAND_PRIORITY_LOW), editor.registerUpdateListener(({ editorState }) => {
452
+ editorState.read(updateToolbar);
453
+ }), editor.registerCommand(KEY_DOWN_COMMAND, (event) => {
454
+ const isModK = event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey;
455
+ const isAltF10 = event.key === "F10" && event.altKey;
456
+ if (!isModK && !isAltF10 || modeRef.current.kind !== "format") return false;
457
+ const selection = $getSelection();
458
+ if (!$isRangeSelection(selection) || selection.isCollapsed()) return false;
459
+ if (isAltF10 && !toolbarRef.current) return false;
460
+ event.preventDefault();
461
+ if (isAltF10) {
462
+ queueMicrotask(() => focusFirstItem(toolbarRef.current));
463
+ return true;
464
+ }
465
+ queueMicrotask(() => openLink("editor"));
466
+ return true;
467
+ }, COMMAND_PRIORITY_LOW), editor.registerRootListener((root) => {
468
+ attachedRoot?.removeEventListener("click", onRootClick);
469
+ attachedRoot = root;
470
+ root?.addEventListener("click", onRootClick);
471
+ }), () => attachedRoot?.removeEventListener("click", onRootClick));
472
+ }, [
473
+ editRuby,
474
+ editor,
475
+ modeRef,
476
+ openLink,
477
+ toolbarRef,
478
+ updateToolbar
479
+ ]);
480
+ }
481
+ function mergeCleanups(...cleanups) {
482
+ return () => {
483
+ for (const cleanup of cleanups) cleanup();
484
+ };
485
+ }
486
+ //#endregion
487
+ //#region src/use-inline-modes.ts
488
+ var FORMAT = { kind: "format" };
489
+ function useInlineModes(editor, onClose) {
490
+ const [mode, setModeState] = useState(FORMAT);
491
+ const modeRef = useRef(mode);
492
+ const savedSelectionRef = useRef(null);
493
+ const setMode = useCallback((next) => {
494
+ const resolved = typeof next === "function" ? next(modeRef.current) : next;
495
+ modeRef.current = resolved;
496
+ setModeState(resolved);
497
+ }, []);
498
+ const close = useCallback((restoreFocus) => {
499
+ const current = modeRef.current;
500
+ setMode(FORMAT);
501
+ if (!restoreFocus || current.kind === "format") {
502
+ onClose(null);
503
+ return;
504
+ }
505
+ if (current.origin === "editor") onClose("editor");
506
+ else onClose(current.kind === "link" ? "Link" : "Ruby");
507
+ }, [onClose, setMode]);
508
+ const openLink = useCallback((origin) => {
509
+ const info = editor.read(() => {
510
+ const selection = $getSelection();
511
+ if (!$isRangeSelection(selection) || selection.isCollapsed()) return null;
512
+ const hasLink = $selectionHasLink(selection);
513
+ const existing = hasLink ? $getSelectedLinkUrl(selection) : "";
514
+ const text = selection.getTextContent().trim();
515
+ return {
516
+ hasLink,
517
+ initial: existing || (looksLikeUrl(text) ? text : ""),
518
+ selection: selection.clone()
519
+ };
520
+ });
521
+ if (!info) return;
522
+ savedSelectionRef.current = info.selection;
523
+ setMode({
524
+ kind: "link",
525
+ draft: info.initial,
526
+ dirty: false,
527
+ existing: info.hasLink,
528
+ origin
529
+ });
530
+ if (info.initial) return;
531
+ readClipboardUrl().then((url) => {
532
+ if (!url) return;
533
+ setMode((prev) => prev.kind === "link" && !prev.dirty && !prev.draft ? {
534
+ ...prev,
535
+ draft: url
536
+ } : prev);
537
+ });
538
+ }, [editor, setMode]);
539
+ const setLinkDraft = useCallback((draft) => {
540
+ setMode((prev) => prev.kind === "link" ? {
541
+ ...prev,
542
+ draft,
543
+ dirty: true
544
+ } : prev);
545
+ }, [setMode]);
546
+ const saveLink = useCallback((restoreFocus = true) => {
547
+ const current = modeRef.current;
548
+ if (current.kind !== "link") return;
549
+ const url = normalizeUrl(current.draft);
550
+ if (url) editor.update(() => {
551
+ const saved = savedSelectionRef.current;
552
+ const selection = $getSelection();
553
+ if (saved && (!$isRangeSelection(selection) || selection.isCollapsed())) $setSelection(saved.clone());
554
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, url);
555
+ });
556
+ savedSelectionRef.current = null;
557
+ close(restoreFocus);
558
+ }, [close, editor]);
559
+ const removeLink = useCallback(() => {
560
+ if (modeRef.current.kind !== "link") return;
561
+ editor.update(() => {
562
+ const saved = savedSelectionRef.current;
563
+ const selection = $getSelection();
564
+ if (saved && (!$isRangeSelection(selection) || selection.isCollapsed())) $setSelection(saved.clone());
565
+ const target = $getSelection();
566
+ if ($isRangeSelection(target)) $removeLinks(editor, target);
567
+ });
568
+ savedSelectionRef.current = null;
569
+ close(true);
570
+ }, [close, editor]);
571
+ const openRuby = useCallback((origin) => {
572
+ const existing = editor.read(() => {
573
+ const selection = $getSelection();
574
+ if (!$isRangeSelection(selection)) return void 0;
575
+ const ruby = getSelectedRubyNodes(selection.getNodes())[0];
576
+ return ruby ? {
577
+ nodeKey: ruby.getKey(),
578
+ reading: ruby.getReading(),
579
+ isNew: false
580
+ } : null;
581
+ });
582
+ if (existing === void 0) return;
583
+ if (existing) {
584
+ setMode({
585
+ kind: "ruby",
586
+ draft: existing,
587
+ origin
588
+ });
589
+ return;
590
+ }
591
+ let draft = null;
592
+ editor.update(() => {
593
+ draft = $wrapSelectionInRuby();
594
+ }, { discrete: true });
595
+ if (draft) setMode({
596
+ kind: "ruby",
597
+ draft,
598
+ origin
599
+ });
600
+ }, [editor, setMode]);
601
+ const editRuby = useCallback((nodeKey, reading) => {
602
+ setMode({
603
+ kind: "ruby",
604
+ draft: {
605
+ nodeKey,
606
+ reading,
607
+ isNew: false
608
+ },
609
+ origin: "editor"
610
+ });
611
+ }, [setMode]);
612
+ const setRubyDraft = useCallback((reading) => {
613
+ setMode((prev) => prev.kind === "ruby" ? {
614
+ ...prev,
615
+ draft: {
616
+ ...prev.draft,
617
+ reading
618
+ }
619
+ } : prev);
620
+ }, [setMode]);
621
+ const saveRuby = useCallback((restoreFocus = true) => {
622
+ const current = modeRef.current;
623
+ if (current.kind !== "ruby") return;
624
+ const reading = current.draft.reading.trim();
625
+ editor.update(() => {
626
+ if (reading) $setRubyReading(current.draft.nodeKey, reading);
627
+ else $unwrapRuby(current.draft.nodeKey);
628
+ });
629
+ close(restoreFocus);
630
+ }, [close, editor]);
631
+ const cancelRuby = useCallback(() => {
632
+ const current = modeRef.current;
633
+ if (current.kind !== "ruby") return;
634
+ if (current.draft.isNew) editor.update(() => $unwrapRuby(current.draft.nodeKey));
635
+ close(true);
636
+ }, [close, editor]);
637
+ const removeRuby = useCallback(() => {
638
+ const current = modeRef.current;
639
+ if (current.kind !== "ruby") return;
640
+ editor.update(() => $unwrapRuby(current.draft.nodeKey));
641
+ close(true);
642
+ }, [close, editor]);
643
+ return {
644
+ mode,
645
+ modeRef,
646
+ cancel: close,
647
+ cancelRuby,
648
+ commitOnOutsideClick: useCallback(() => {
649
+ const current = modeRef.current;
650
+ if (current.kind === "link") {
651
+ if (current.dirty && current.draft.trim()) saveLink(false);
652
+ else close(false);
653
+ } else if (current.kind === "ruby") saveRuby(false);
654
+ }, [
655
+ close,
656
+ saveLink,
657
+ saveRuby
658
+ ]),
659
+ editRuby,
660
+ openLink,
661
+ openRuby,
662
+ removeLink,
663
+ removeRuby,
664
+ saveLink,
665
+ saveRuby,
666
+ setLinkDraft,
667
+ setRubyDraft
668
+ };
669
+ }
670
+ //#endregion
671
+ //#region src/position.ts
672
+ var GAP = 8;
673
+ var EDGE = 8;
674
+ function computeToolbarPosition({ anchor, size, bounds }) {
675
+ const above = anchor.top - GAP - size.height;
676
+ const below = anchor.bottom + GAP;
677
+ const fitsAbove = above >= bounds.top;
678
+ const fitsBelow = below + size.height <= bounds.bottom;
679
+ const placement = fitsAbove || !fitsBelow ? "top" : "bottom";
680
+ const top = placement === "top" ? Math.max(above, bounds.top) : below;
681
+ const center = (anchor.left + anchor.right) / 2;
682
+ const minLeft = bounds.left + EDGE;
683
+ const maxLeft = bounds.right - size.width - EDGE;
684
+ return {
685
+ top,
686
+ left: Math.max(minLeft, Math.min(center - size.width / 2, maxLeft)),
687
+ placement
688
+ };
689
+ }
690
+ function isPinned(el) {
691
+ const { position } = window.getComputedStyle(el);
692
+ return position === "sticky" || position === "fixed";
693
+ }
694
+ function findScrollParent(el) {
695
+ let current = el?.parentElement ?? null;
696
+ while (current) {
697
+ const { overflowY } = window.getComputedStyle(current);
698
+ if (overflowY === "auto" || overflowY === "scroll") return current;
699
+ current = current.parentElement;
700
+ }
701
+ return null;
702
+ }
703
+ function getViewportBounds(rootElement, own, anchorTop) {
704
+ const bounds = {
705
+ top: 0,
706
+ left: 0,
707
+ right: window.innerWidth,
708
+ bottom: window.innerHeight
709
+ };
710
+ const scrollParent = findScrollParent(rootElement);
711
+ if (scrollParent) {
712
+ const rect = scrollParent.getBoundingClientRect();
713
+ bounds.top = Math.max(bounds.top, rect.top);
714
+ bounds.bottom = Math.min(bounds.bottom, rect.bottom);
715
+ }
716
+ const rootRect = rootElement.getBoundingClientRect();
717
+ for (const bar of document.querySelectorAll("[role=\"toolbar\"]")) {
718
+ if (bar === own || own?.contains(bar) || !isPinned(bar)) continue;
719
+ const rect = bar.getBoundingClientRect();
720
+ if (rect.right > rootRect.left && rect.left < rootRect.right && rect.top < anchorTop && rect.bottom > bounds.top) bounds.top = Math.min(rect.bottom, anchorTop);
721
+ }
722
+ return bounds;
723
+ }
724
+ //#endregion
725
+ //#region src/use-toolbar-position.ts
123
726
  function extractCssVarName(value) {
124
727
  return value.match(/^var\((--[^\s),]+)(?:,[^)]+)?\)$/)?.[1] ?? null;
125
728
  }
@@ -133,472 +736,263 @@ function collectThemeVarNames(contract, output) {
133
736
  return output;
134
737
  }
135
738
  var THEME_VAR_NAMES = Array.from(collectThemeVarNames(vars, /* @__PURE__ */ new Set()));
739
+ function applyThemeVars(editor, toolbar) {
740
+ const rootElement = editor.getRootElement();
741
+ if (!rootElement) return;
742
+ const computed = window.getComputedStyle(rootElement);
743
+ for (const name of THEME_VAR_NAMES) {
744
+ const value = computed.getPropertyValue(name).trim();
745
+ if (value) toolbar.style.setProperty(name, value);
746
+ }
747
+ }
748
+ function useToolbarPosition({ active, editor, getAnchor, portalContainer, toolbarRef, trigger }) {
749
+ useLayoutEffect(() => {
750
+ if (!active) return;
751
+ const position = () => {
752
+ const toolbar = toolbarRef.current;
753
+ const rootElement = editor.getRootElement();
754
+ if (!toolbar || !rootElement) return;
755
+ applyThemeVars(editor, toolbar);
756
+ const anchor = getAnchor();
757
+ if (!anchor) return;
758
+ const pos = computeToolbarPosition({
759
+ anchor,
760
+ size: {
761
+ width: toolbar.offsetWidth,
762
+ height: toolbar.offsetHeight
763
+ },
764
+ bounds: getViewportBounds(rootElement, toolbar, anchor.top)
765
+ });
766
+ const containerRect = portalContainer === document.body ? null : portalContainer.getBoundingClientRect();
767
+ toolbar.style.top = `${pos.top - (containerRect?.top ?? 0)}px`;
768
+ toolbar.style.left = `${pos.left - (containerRect?.left ?? 0)}px`;
769
+ toolbar.dataset.placement = pos.placement;
770
+ };
771
+ position();
772
+ const frame = requestAnimationFrame(position);
773
+ const onScroll = () => requestAnimationFrame(position);
774
+ window.addEventListener("scroll", onScroll, {
775
+ capture: true,
776
+ passive: true
777
+ });
778
+ window.addEventListener("resize", onScroll);
779
+ return () => {
780
+ cancelAnimationFrame(frame);
781
+ window.removeEventListener("scroll", onScroll, { capture: true });
782
+ window.removeEventListener("resize", onScroll);
783
+ };
784
+ }, [
785
+ active,
786
+ editor,
787
+ getAnchor,
788
+ portalContainer,
789
+ toolbarRef,
790
+ trigger
791
+ ]);
792
+ }
793
+ //#endregion
794
+ //#region src/FloatingToolbarPlugin.tsx
795
+ var ICON = {
796
+ size: 16,
797
+ strokeWidth: 2
798
+ };
799
+ function toBox(rect) {
800
+ if (rect.width === 0 && rect.height === 0) return null;
801
+ return {
802
+ top: rect.top,
803
+ bottom: rect.bottom,
804
+ left: rect.left,
805
+ right: rect.right
806
+ };
807
+ }
136
808
  function FloatingToolbarPlugin({ actions } = {}) {
137
809
  const [editor] = useLexicalComposerContext();
138
810
  const { className: portalClassName, theme: portalTheme } = usePortalTheme();
139
811
  const portalContainer = usePortalContainer();
140
812
  const selectionSnapshot = useTextSelectionSnapshot();
141
813
  const toolbarRef = useRef(null);
814
+ const [handle] = useState(createToolbarTooltipHandle);
142
815
  const [visible, setVisible] = useState(false);
143
816
  const [state, setState] = useState(INITIAL_STATE);
144
- const [rubyEdit, setRubyEdit] = useState(null);
145
- const rubyEditorRef = useRef(null);
146
- const rubyInputRef = useRef(null);
147
- const rubyEditRef = useRef(rubyEdit);
148
- rubyEditRef.current = rubyEdit;
149
- const updateToolbar = useCallback(() => {
150
- const selection = $getSelection();
151
- if (!$isRangeSelection(selection) || selection.isCollapsed()) {
152
- setVisible(false);
153
- return;
154
- }
155
- setState(getSelectionState(selection));
156
- setVisible(true);
157
- }, []);
158
- const applyThemeVars = useCallback((toolbar) => {
159
- const rootElement = editor.getRootElement();
160
- if (!rootElement) return;
161
- const computed = window.getComputedStyle(rootElement);
162
- for (const name of THEME_VAR_NAMES) {
163
- const value = computed.getPropertyValue(name).trim();
164
- if (value) toolbar.style.setProperty(name, value);
165
- }
166
- }, [editor]);
167
- useEffect(() => {
168
- const unregisterCommand = editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
169
- updateToolbar();
170
- return false;
171
- }, COMMAND_PRIORITY_LOW);
172
- const unregisterUpdate = editor.registerUpdateListener(({ editorState }) => {
173
- editorState.read(() => {
174
- updateToolbar();
175
- });
176
- });
177
- return () => {
178
- unregisterCommand();
179
- unregisterUpdate();
180
- };
181
- }, [editor, updateToolbar]);
817
+ const [pendingFocus, setPendingFocus] = useState(null);
818
+ const lastAnchorRef = useRef(null);
819
+ const modes = useInlineModes(editor, useCallback((target) => {
820
+ setPendingFocus(target ? { target } : null);
821
+ }, []));
822
+ const { mode, modeRef, openLink, openRuby, editRuby, commitOnOutsideClick } = modes;
823
+ useEditorBindings({
824
+ editor,
825
+ editRuby,
826
+ modeRef,
827
+ openLink,
828
+ toolbarRef,
829
+ updateToolbar: useCallback(() => {
830
+ const selection = $getSelection();
831
+ if (!$isRangeSelection(selection) || selection.isCollapsed()) {
832
+ setVisible(false);
833
+ return;
834
+ }
835
+ setState(getSelectionState(selection));
836
+ setVisible(true);
837
+ }, [])
838
+ });
182
839
  useEffect(() => {
183
840
  if (!selectionSnapshot) setVisible(false);
184
841
  }, [selectionSnapshot]);
185
842
  useEffect(() => {
186
- if (!visible || !toolbarRef.current || !selectionSnapshot) return;
187
- const positionToolbar = () => {
188
- const toolbar = toolbarRef.current;
189
- if (!toolbar) return;
190
- applyThemeVars(toolbar);
191
- const rootElement = editor.getRootElement();
192
- if (!rootElement) {
193
- setVisible(false);
194
- return;
195
- }
196
- const rect = getDOMRectFromTextSelection(rootElement, selectionSnapshot);
197
- if (!rect) {
198
- setVisible(false);
199
- return;
200
- }
201
- const pos = computePosition(rect, toolbar, portalContainer);
202
- if (!pos) {
203
- setVisible(false);
204
- return;
205
- }
206
- toolbar.style.top = `${pos.top}px`;
207
- toolbar.style.left = `${pos.left}px`;
843
+ if (!pendingFocus) return;
844
+ const button = pendingFocus.target === "editor" ? null : toolbarRef.current?.querySelector(`[aria-label="${pendingFocus.target}"]`);
845
+ if (button) button.focus();
846
+ else editor.focus();
847
+ setPendingFocus(null);
848
+ }, [editor, pendingFocus]);
849
+ useEffect(() => {
850
+ if (mode.kind === "format") return;
851
+ const onPointerDown = (event) => {
852
+ const target = event.target;
853
+ if (toolbarRef.current?.contains(target)) return;
854
+ commitOnOutsideClick();
855
+ };
856
+ const timer = setTimeout(() => document.addEventListener("mousedown", onPointerDown), 0);
857
+ return () => {
858
+ clearTimeout(timer);
859
+ document.removeEventListener("mousedown", onPointerDown);
208
860
  };
209
- requestAnimationFrame(positionToolbar);
861
+ }, [commitOnOutsideClick, mode.kind]);
862
+ const rubyKey = mode.kind === "ruby" ? mode.draft.nodeKey : null;
863
+ const getAnchor = useCallback(() => {
210
864
  const rootElement = editor.getRootElement();
211
- if (!rootElement) return;
212
- let scrollParent = window;
213
- let el = rootElement.parentElement;
214
- while (el) {
215
- const { overflowY } = window.getComputedStyle(el);
216
- if (overflowY === "auto" || overflowY === "scroll") {
217
- scrollParent = el;
218
- break;
219
- }
220
- el = el.parentElement;
865
+ let anchor = null;
866
+ if (rubyKey) {
867
+ const dom = editor.getElementByKey(rubyKey);
868
+ anchor = dom ? toBox(dom.getBoundingClientRect()) : null;
869
+ } else if (rootElement && selectionSnapshot) {
870
+ const rect = getDOMRectFromTextSelection(rootElement, selectionSnapshot);
871
+ anchor = rect ? toBox(rect) : null;
221
872
  }
222
- const onScroll = () => requestAnimationFrame(positionToolbar);
223
- scrollParent.addEventListener("scroll", onScroll, { passive: true });
224
- return () => scrollParent.removeEventListener("scroll", onScroll);
873
+ if (anchor) lastAnchorRef.current = anchor;
874
+ return anchor ?? lastAnchorRef.current;
225
875
  }, [
226
- applyThemeVars,
227
876
  editor,
228
- portalContainer,
229
- selectionSnapshot,
230
- state,
231
- visible
877
+ rubyKey,
878
+ selectionSnapshot
232
879
  ]);
233
- const handleFormat = useCallback((format) => {
234
- editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
235
- }, [editor]);
236
- const handleLink = useCallback(() => {
880
+ const shown = visible || mode.kind !== "format";
881
+ useToolbarPosition({
882
+ active: shown,
883
+ editor,
884
+ getAnchor,
885
+ portalContainer,
886
+ toolbarRef,
887
+ trigger: `${mode.kind}:${JSON.stringify(state)}`
888
+ });
889
+ const handleFormat = useCallback((format) => editor.dispatchCommand(FORMAT_TEXT_COMMAND, format), [editor]);
890
+ const patchSelection = useCallback((styles, katexColor) => {
237
891
  editor.update(() => {
238
892
  const selection = $getSelection();
239
893
  if (!$isRangeSelection(selection)) return;
240
- const nodes = selection.getNodes();
241
- if (nodes.some((node) => {
242
- return isEffectiveLinkNode(node.getParent()) || isEffectiveLinkNode(node);
243
- })) {
244
- for (const autoLinkNode of collectSelectedActiveAutoLinkNodes(selection)) {
245
- autoLinkNode.setIsUnlinked(true);
246
- autoLinkNode.markDirty();
247
- }
248
- if (nodes.some((node) => {
249
- return isRegularLinkNode(node.getParent()) || isRegularLinkNode(node);
250
- })) editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
251
- } else {
252
- const text = selection.getTextContent();
253
- const url = /^https?:\/\//.test(text) ? text : "";
254
- editor.dispatchCommand(TOGGLE_LINK_COMMAND, url || "https://");
255
- }
894
+ $patchStyleText(selection, styles);
895
+ if (katexColor === void 0) return;
896
+ for (const node of selection.getNodes()) if ($isKaTeXInlineNode(node)) node.setColor(katexColor);
256
897
  });
257
898
  }, [editor]);
258
899
  const handleColor = useCallback((value) => {
259
- editor.update(() => {
260
- const sel = $getSelection();
261
- if (!$isRangeSelection(sel)) return;
262
- const color = value === "inherit" ? null : value;
263
- $patchStyleText(sel, { color });
264
- for (const node of sel.getNodes()) if ($isKaTeXInlineNode(node)) node.setColor(color);
265
- });
266
- }, [editor]);
267
- const handleRuby = useCallback(() => {
268
- editor.update(() => {
269
- const selection = $getSelection();
270
- if (!$isRangeSelection(selection)) return;
271
- const rubyNodes = getSelectedRubyNodes(selection.getNodes());
272
- if (rubyNodes.length > 0) {
273
- const rubyNode = rubyNodes[0];
274
- if (!rubyNode) return;
275
- setRubyEdit({
276
- nodeKey: rubyNode.getKey(),
277
- reading: rubyNode.getReading(),
278
- baseText: rubyNode.getTextContent(),
279
- isNew: false
280
- });
281
- } else {
282
- const text = selection.getTextContent();
283
- if (!text.trim()) return;
284
- selection.removeText();
285
- const rubyNode = $createRubyNode("");
286
- rubyNode.append($createTextNode(text));
287
- const freshSelection = $getSelection();
288
- if ($isRangeSelection(freshSelection)) freshSelection.insertNodes([rubyNode]);
289
- setRubyEdit({
290
- nodeKey: rubyNode.getKey(),
291
- reading: "",
292
- baseText: text,
293
- isNew: true
294
- });
295
- }
296
- });
297
- }, [editor]);
298
- const handleRubyConfirm = useCallback(() => {
299
- const edit = rubyEditRef.current;
300
- if (!edit) return;
301
- editor.update(() => {
302
- const node = $getNodeByKey(edit.nodeKey);
303
- if ($isRubyNode(node)) node.setReading(edit.reading);
304
- });
305
- setRubyEdit(null);
306
- }, [editor]);
307
- const handleRubyCancel = useCallback(() => {
308
- const edit = rubyEditRef.current;
309
- if (!edit) return;
310
- if (edit.isNew) editor.update(() => {
311
- const node = $getNodeByKey(edit.nodeKey);
312
- if ($isRubyNode(node)) {
313
- const children = node.getChildren();
314
- for (const child of children) node.insertBefore(child);
315
- node.remove();
316
- }
317
- });
318
- setRubyEdit(null);
319
- }, [editor]);
320
- const handleRubyDelete = useCallback(() => {
321
- const edit = rubyEditRef.current;
322
- if (!edit) return;
323
- editor.update(() => {
324
- const node = $getNodeByKey(edit.nodeKey);
325
- if ($isRubyNode(node)) {
326
- const children = node.getChildren();
327
- for (const child of children) node.insertBefore(child);
328
- node.remove();
329
- }
330
- });
331
- setRubyEdit(null);
332
- }, [editor]);
333
- const isRubyEditing = rubyEdit !== null;
334
- useEffect(() => {
335
- if (!isRubyEditing) return;
336
- const handleClickOutside = (e) => {
337
- const currentEdit = rubyEditRef.current;
338
- if (!currentEdit) return;
339
- if (rubyEditorRef.current && !rubyEditorRef.current.contains(e.target)) {
340
- if (currentEdit.reading.trim()) editor.update(() => {
341
- const node = $getNodeByKey(currentEdit.nodeKey);
342
- if ($isRubyNode(node)) node.setReading(currentEdit.reading);
343
- });
344
- else if (currentEdit.isNew) editor.update(() => {
345
- const node = $getNodeByKey(currentEdit.nodeKey);
346
- if ($isRubyNode(node)) {
347
- const children = node.getChildren();
348
- for (const child of children) node.insertBefore(child);
349
- node.remove();
350
- }
351
- });
352
- setRubyEdit(null);
353
- }
354
- };
355
- const timer = setTimeout(() => {
356
- document.addEventListener("mousedown", handleClickOutside);
357
- }, 0);
358
- return () => {
359
- clearTimeout(timer);
360
- document.removeEventListener("mousedown", handleClickOutside);
361
- };
362
- }, [editor, isRubyEditing]);
363
- useEffect(() => {
364
- if (!rubyEdit || !rubyEditorRef.current) return;
365
- const positionEditor = () => {
366
- const editorEl = rubyEditorRef.current;
367
- if (!editorEl) return;
368
- applyThemeVars(editorEl);
369
- const rubyDom = editor.getElementByKey(rubyEdit.nodeKey);
370
- if (!rubyDom) return;
371
- const rect = rubyDom.getBoundingClientRect();
372
- const containerRect = portalContainer === document.body ? void 0 : portalContainer.getBoundingClientRect();
373
- const oX = containerRect?.left ?? 0;
374
- const oY = containerRect?.top ?? 0;
375
- const availW = containerRect?.width ?? window.innerWidth;
376
- const editorWidth = editorEl.offsetWidth;
377
- const rawLeft = rect.left - oX + rect.width / 2 - editorWidth / 2;
378
- const clampedLeft = Math.max(8, Math.min(rawLeft, availW - editorWidth - 8));
379
- editorEl.style.top = `${rect.bottom - oY + 8}px`;
380
- editorEl.style.left = `${clampedLeft}px`;
381
- };
382
- requestAnimationFrame(positionEditor);
383
- requestAnimationFrame(() => {
384
- rubyInputRef.current?.focus();
385
- });
386
- }, [
387
- applyThemeVars,
388
- editor,
389
- rubyEdit
390
- ]);
391
- if (!visible && !rubyEdit) return null;
392
- const toolbarClassName = portalClassName ? `${toolbar} ${portalClassName}` : toolbar;
393
- const rubyEditorClassName = portalClassName ? `${rubyEditor} ${portalClassName}` : rubyEditor;
394
- return /* @__PURE__ */ jsxs(Fragment, { children: [visible && !rubyEdit && createPortal(/* @__PURE__ */ jsxs("div", {
395
- "aria-label": "Text formatting",
396
- className: toolbarClassName,
397
- "data-theme": portalClassName ? portalTheme : void 0,
398
- ref: toolbarRef,
399
- role: "toolbar",
400
- style: {
401
- position: "fixed",
402
- zIndex: 50
403
- },
404
- children: [
405
- /* @__PURE__ */ jsx(ToolbarButton, {
406
- active: state.isBold,
407
- ariaLabel: "Bold",
408
- onClick: () => handleFormat("bold"),
409
- children: /* @__PURE__ */ jsx(Bold, {
410
- size: ICON_SIZE,
411
- strokeWidth: ICON_STROKE
412
- })
413
- }),
414
- /* @__PURE__ */ jsx(ToolbarButton, {
415
- active: state.isItalic,
416
- ariaLabel: "Italic",
417
- onClick: () => handleFormat("italic"),
418
- children: /* @__PURE__ */ jsx(Italic, {
419
- size: ICON_SIZE,
420
- strokeWidth: ICON_STROKE
421
- })
422
- }),
423
- /* @__PURE__ */ jsx(ToolbarButton, {
424
- active: state.isUnderline,
425
- ariaLabel: "Underline",
426
- onClick: () => handleFormat("underline"),
427
- children: /* @__PURE__ */ jsx(Underline, {
428
- size: ICON_SIZE,
429
- strokeWidth: ICON_STROKE
430
- })
431
- }),
432
- /* @__PURE__ */ jsx(ToolbarButton, {
433
- active: state.isStrikethrough,
434
- ariaLabel: "Strikethrough",
435
- onClick: () => handleFormat("strikethrough"),
436
- children: /* @__PURE__ */ jsx(Strikethrough, {
437
- size: ICON_SIZE,
438
- strokeWidth: ICON_STROKE
439
- })
440
- }),
441
- /* @__PURE__ */ jsx(ToolbarButton, {
442
- active: state.isSuperscript,
443
- ariaLabel: "Superscript",
444
- onClick: () => handleFormat("superscript"),
445
- children: /* @__PURE__ */ jsx(Superscript, {
446
- size: ICON_SIZE,
447
- strokeWidth: ICON_STROKE
448
- })
449
- }),
450
- /* @__PURE__ */ jsx(ToolbarButton, {
451
- active: state.isSubscript,
452
- ariaLabel: "Subscript",
453
- onClick: () => handleFormat("subscript"),
454
- children: /* @__PURE__ */ jsx(Subscript, {
455
- size: ICON_SIZE,
456
- strokeWidth: ICON_STROKE
457
- })
458
- }),
459
- /* @__PURE__ */ jsx("span", { className: "_1m6axz7f" }),
460
- /* @__PURE__ */ jsx(ToolbarButton, {
461
- active: state.isCode,
462
- ariaLabel: "Code",
463
- onClick: () => handleFormat("code"),
464
- children: /* @__PURE__ */ jsx(Code, {
465
- size: ICON_SIZE,
466
- strokeWidth: ICON_STROKE
467
- })
468
- }),
469
- /* @__PURE__ */ jsx(ToolbarButton, {
470
- active: state.isHighlight,
471
- ariaLabel: "Highlight",
472
- onClick: () => handleFormat("highlight"),
473
- children: /* @__PURE__ */ jsx(Highlighter, {
474
- size: ICON_SIZE,
475
- strokeWidth: ICON_STROKE
476
- })
477
- }),
478
- /* @__PURE__ */ jsx(ToolbarButton, {
479
- active: state.isSpoiler,
480
- ariaLabel: "Spoiler",
481
- onClick: () => editor.update($toggleSpoilerSelection),
482
- children: /* @__PURE__ */ jsx(EyeOff, {
483
- size: ICON_SIZE,
484
- strokeWidth: ICON_STROKE
485
- })
486
- }),
487
- /* @__PURE__ */ jsx(ToolbarButton, {
488
- active: state.isLink,
489
- ariaLabel: "Link",
490
- onClick: handleLink,
491
- children: /* @__PURE__ */ jsx(Link, {
492
- size: ICON_SIZE,
493
- strokeWidth: ICON_STROKE
494
- })
495
- }),
496
- /* @__PURE__ */ jsx(ToolbarButton, {
497
- active: state.isRuby,
498
- ariaLabel: "Ruby annotation",
499
- onClick: handleRuby,
500
- children: /* @__PURE__ */ jsx(Languages, {
501
- size: ICON_SIZE,
502
- strokeWidth: ICON_STROKE
900
+ const color = value === "inherit" ? null : value;
901
+ patchSelection({ color }, color);
902
+ }, [patchSelection]);
903
+ const handleBackgroundColor = useCallback((value) => {
904
+ patchSelection({ "background-color": value === "inherit" ? null : value });
905
+ }, [patchSelection]);
906
+ if (!shown) return null;
907
+ const toolbarClassName = [
908
+ toolbar,
909
+ mode.kind === "link" && "_1m6axz72",
910
+ mode.kind === "ruby" && "_1m6axz73",
911
+ portalClassName
912
+ ].filter(Boolean).join(" ");
913
+ return /* @__PURE__ */ jsxs(TooltipProvider, {
914
+ delay: 400,
915
+ children: [/* @__PURE__ */ jsx(TooltipRoot, {
916
+ disableHoverablePopup: true,
917
+ handle,
918
+ children: ((props) => props.payload ? /* @__PURE__ */ jsx(TooltipContent, {
919
+ "data-slot": "toolbar-tooltip",
920
+ shortcut: props.payload.shortcut,
921
+ children: props.payload.label
922
+ }) : null)
923
+ }), createPortal(/* @__PURE__ */ jsxs("div", {
924
+ "aria-label": mode.kind === "format" ? "Text formatting" : void 0,
925
+ className: toolbarClassName,
926
+ "data-theme": portalClassName ? portalTheme : void 0,
927
+ ref: toolbarRef,
928
+ role: mode.kind === "format" ? "toolbar" : "group",
929
+ style: {
930
+ position: "fixed",
931
+ zIndex: 50
932
+ },
933
+ onFocus: (e) => syncRovingTabIndex(e.currentTarget, e.target),
934
+ onKeyDown: (e) => {
935
+ if (mode.kind !== "format") return;
936
+ if (moveToolbarFocus(e)) return;
937
+ if (e.key === "Escape" || e.key === "Tab") {
938
+ e.preventDefault();
939
+ editor.focus();
940
+ }
941
+ },
942
+ children: [
943
+ mode.kind === "format" && /* @__PURE__ */ jsx(FormatBar, {
944
+ actions,
945
+ handle,
946
+ state,
947
+ onBackgroundColor: handleBackgroundColor,
948
+ onClearFormatting: () => editor.update($clearFormatting),
949
+ onColor: handleColor,
950
+ onFormat: handleFormat,
951
+ onLink: () => openLink("toolbar"),
952
+ onRuby: () => openRuby("toolbar"),
953
+ onSpoiler: () => editor.update($toggleSpoilerSelection)
954
+ }),
955
+ mode.kind === "link" && /* @__PURE__ */ jsx(InlineInputBar, {
956
+ handle,
957
+ icon: /* @__PURE__ */ jsx(Link, { ...ICON }),
958
+ label: "URL",
959
+ placeholder: "Paste or type a link",
960
+ type: "url",
961
+ value: mode.draft,
962
+ secondary: mode.existing ? {
963
+ danger: true,
964
+ icon: /* @__PURE__ */ jsx(Unlink, { ...ICON }),
965
+ label: "Remove link",
966
+ onClick: modes.removeLink
967
+ } : {
968
+ icon: /* @__PURE__ */ jsx(X, { ...ICON }),
969
+ label: "Back to formatting",
970
+ shortcut: "Escape",
971
+ onClick: () => modes.cancel(true)
972
+ },
973
+ onCancel: () => modes.cancel(true),
974
+ onChange: modes.setLinkDraft,
975
+ onSubmit: () => modes.saveLink()
976
+ }),
977
+ mode.kind === "ruby" && /* @__PURE__ */ jsx(InlineInputBar, {
978
+ handle,
979
+ icon: /* @__PURE__ */ jsx(Languages, { ...ICON }),
980
+ label: "Reading",
981
+ placeholder: "Reading",
982
+ value: mode.draft.reading,
983
+ secondary: {
984
+ danger: true,
985
+ icon: /* @__PURE__ */ jsx(Trash2, { ...ICON }),
986
+ label: "Remove ruby",
987
+ onClick: modes.removeRuby
988
+ },
989
+ onCancel: modes.cancelRuby,
990
+ onChange: modes.setRubyDraft,
991
+ onSubmit: () => modes.saveRuby()
503
992
  })
504
- }),
505
- /* @__PURE__ */ jsx("span", { className: "_1m6axz7f" }),
506
- /* @__PURE__ */ jsx(ColorPicker, {
507
- currentColor: state.fontColor || "inherit",
508
- onSelect: handleColor
509
- }),
510
- actions && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", { className: "_1m6axz7f" }), actions] })
511
- ]
512
- }), portalContainer), rubyEdit && createPortal(/* @__PURE__ */ jsxs("div", {
513
- className: rubyEditorClassName,
514
- "data-theme": portalClassName ? portalTheme : void 0,
515
- ref: rubyEditorRef,
516
- style: {
517
- position: "fixed",
518
- zIndex: 51
519
- },
520
- children: [
521
- /* @__PURE__ */ jsxs("div", {
522
- className: "_1m6axz76",
523
- children: [/* @__PURE__ */ jsx("span", {
524
- className: "_1m6axz77",
525
- children: rubyEdit.reading || "\xA0"
526
- }), /* @__PURE__ */ jsx("span", {
527
- className: "_1m6axz78",
528
- children: rubyEdit.baseText
529
- })]
530
- }),
531
- /* @__PURE__ */ jsxs("div", {
532
- className: "_1m6axz79",
533
- children: [
534
- /* @__PURE__ */ jsx("input", {
535
- className: "_1m6axz7a",
536
- placeholder: "读音",
537
- ref: rubyInputRef,
538
- value: rubyEdit.reading,
539
- onChange: (e) => setRubyEdit((prev) => prev ? {
540
- ...prev,
541
- reading: e.target.value
542
- } : null),
543
- onKeyDown: (e) => {
544
- if (e.key === "Enter") {
545
- e.preventDefault();
546
- handleRubyConfirm();
547
- }
548
- if (e.key === "Escape") {
549
- e.preventDefault();
550
- handleRubyCancel();
551
- }
552
- }
553
- }),
554
- /* @__PURE__ */ jsx("button", {
555
- "aria-label": "Confirm",
556
- className: `_1m6axz7b _1m6axz7c`,
557
- type: "button",
558
- onMouseDown: (e) => {
559
- e.preventDefault();
560
- handleRubyConfirm();
561
- },
562
- children: /* @__PURE__ */ jsx(Check, {
563
- size: 14,
564
- strokeWidth: ICON_STROKE
565
- })
566
- }),
567
- /* @__PURE__ */ jsx("button", {
568
- "aria-label": "Cancel",
569
- className: "_1m6axz7b",
570
- type: "button",
571
- onMouseDown: (e) => {
572
- e.preventDefault();
573
- handleRubyCancel();
574
- },
575
- children: /* @__PURE__ */ jsx(X, {
576
- size: 14,
577
- strokeWidth: ICON_STROKE
578
- })
579
- }),
580
- /* @__PURE__ */ jsx("span", { className: "_1m6axz7f" }),
581
- /* @__PURE__ */ jsx("button", {
582
- "aria-label": "Delete ruby",
583
- className: `_1m6axz7b _1m6axz7d`,
584
- type: "button",
585
- onMouseDown: (e) => {
586
- e.preventDefault();
587
- handleRubyDelete();
588
- },
589
- children: /* @__PURE__ */ jsx(Trash2, {
590
- size: 14,
591
- strokeWidth: ICON_STROKE
592
- })
593
- })
594
- ]
595
- }),
596
- /* @__PURE__ */ jsx("span", {
597
- className: "_1m6axz7e",
598
- children: "Enter 保存 / Esc 取消"
599
- })
600
- ]
601
- }), portalContainer)] });
993
+ ]
994
+ }), portalContainer)]
995
+ });
602
996
  }
603
997
  //#endregion
604
998
  export { FloatingToolbarPlugin };