@mieweb/ui 0.11.0-dev.23 → 0.11.0-dev.25

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.
@@ -6,7 +6,7 @@ import { X, Search, History, BookOpen, ChevronRight, ChevronDown, Plus } from '.
6
6
  import { Button } from './chunk-WFBUBT54.js';
7
7
  import { cn } from './chunk-TQONQSJO.js';
8
8
  import * as React2 from 'react';
9
- import { Bold, Italic, Underline, List, ListOrdered, AlignLeft, AlignCenter, AlignRight, Braces, Replace, MicOff, Mic, BookOpen as BookOpen$1 } from 'lucide-react';
9
+ import { Bold, Italic, Underline, List, ListOrdered, AlignLeft, AlignCenter, AlignRight, Braces, Replace, MicOff, Mic, BookOpen as BookOpen$1, Plus as Plus$1 } from 'lucide-react';
10
10
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
11
11
 
12
12
  var DEFAULT_LABELS = {
@@ -511,6 +511,26 @@ function getSpeechRecognition() {
511
511
  return w.SpeechRecognition || w.webkitSpeechRecognition;
512
512
  }
513
513
  var BLOCK_BOUNDARY = /^(BR|DIV|P|LI|UL|OL|TABLE|TR|TD|TH|H[1-6]|BLOCKQUOTE|PRE|HR)$/;
514
+ var MARKDOWN_BLOCK_RULES = {
515
+ "#": { command: "formatBlock", value: "h1" },
516
+ "##": { command: "formatBlock", value: "h2" },
517
+ "###": { command: "formatBlock", value: "h3" },
518
+ "-": { command: "insertUnorderedList" },
519
+ "*": { command: "insertUnorderedList" },
520
+ "1.": { command: "insertOrderedList" },
521
+ ">": { command: "formatBlock", value: "blockquote" }
522
+ };
523
+ function nodeRect(node) {
524
+ if (node.nodeType === Node.ELEMENT_NODE) {
525
+ return node.getBoundingClientRect();
526
+ }
527
+ if (node.nodeType === Node.TEXT_NODE && (node.textContent ?? "").length > 0) {
528
+ const range = document.createRange();
529
+ range.selectNodeContents(node);
530
+ return range.getBoundingClientRect();
531
+ }
532
+ return null;
533
+ }
514
534
  function lastTextWithin(node) {
515
535
  if (node.nodeType === Node.TEXT_NODE) {
516
536
  return (node.textContent ?? "").length > 0 ? node : null;
@@ -557,6 +577,12 @@ var RichTextEditor = React2.forwardRef(
557
577
  defaultMacrosOpen = false,
558
578
  macroLabels,
559
579
  onCreateMacro,
580
+ toolbar,
581
+ defaultToolbar = "visible",
582
+ onToolbarChange,
583
+ blockMenu = false,
584
+ blockMenuLabels,
585
+ markdownShortcuts = false,
560
586
  "aria-label": ariaLabel
561
587
  }, ref) => {
562
588
  const editorRef = React2.useRef(null);
@@ -571,6 +597,19 @@ var RichTextEditor = React2.forwardRef(
571
597
  const hasVariables = Boolean(variableGroups && variableGroups.length > 0);
572
598
  const hasMacros = Boolean(macros);
573
599
  const isEmpty = isHtmlEmpty(value);
600
+ const [uncontrolledToolbar, setUncontrolledToolbar] = React2.useState(defaultToolbar);
601
+ const toolbarVisible = (toolbar ?? uncontrolledToolbar) !== "hidden";
602
+ const setToolbarVisibility = (next) => {
603
+ if (toolbar === void 0) setUncontrolledToolbar(next);
604
+ onToolbarChange?.(next);
605
+ };
606
+ const toolbarVisibleRef = React2.useRef(toolbarVisible);
607
+ const [blockMenuOpen, setBlockMenuOpen] = React2.useState(false);
608
+ const [gutterTop, setGutterTop] = React2.useState(16);
609
+ const gutterWrapperRef = React2.useRef(null);
610
+ const hoverBlockNodeRef = React2.useRef(null);
611
+ const blockTargetRangeRef = React2.useRef(null);
612
+ const firstBlockItemRef = React2.useRef(null);
574
613
  React2.useImperativeHandle(ref, () => editorRef.current);
575
614
  React2.useEffect(() => {
576
615
  setSpeechSupported(Boolean(getSpeechRecognition()));
@@ -584,6 +623,16 @@ var RichTextEditor = React2.forwardRef(
584
623
  }
585
624
  };
586
625
  }, []);
626
+ React2.useEffect(() => {
627
+ toolbarVisibleRef.current = toolbarVisible;
628
+ if (toolbarVisible) return;
629
+ if (recognitionRef.current && isListeningRef.current) {
630
+ shouldRestartRef.current = false;
631
+ isListeningRef.current = false;
632
+ recognitionRef.current.stop();
633
+ setIsListening(false);
634
+ }
635
+ }, [toolbarVisible]);
587
636
  React2.useEffect(() => {
588
637
  if (editorRef.current && editorRef.current.innerHTML !== value) {
589
638
  editorRef.current.innerHTML = value;
@@ -722,22 +771,18 @@ var RichTextEditor = React2.forwardRef(
722
771
  saveSelection();
723
772
  updateContent();
724
773
  };
725
- const expandTypedShortcut = (event) => {
726
- if (disabled || event.key !== " " || // Space during IME composition selects/converts a candidate; never
727
- // hijack it for macro expansion.
728
- event.nativeEvent.isComposing || event.ctrlKey || event.metaKey || event.altKey || !macros)
729
- return;
774
+ const collectTokenBeforeCaret = () => {
730
775
  const selection = window.getSelection();
731
776
  if (!selection || selection.rangeCount === 0 || !selection.isCollapsed)
732
- return;
777
+ return null;
733
778
  const caret = selection.getRangeAt(0);
734
779
  const node = caret.startContainer;
735
780
  if (node.nodeType !== Node.TEXT_NODE || !editorRef.current?.contains(node))
736
- return;
781
+ return null;
737
782
  const typed = /(\S+)$/.exec(
738
783
  (node.textContent ?? "").slice(0, caret.startOffset)
739
784
  );
740
- if (!typed) return;
785
+ if (!typed) return null;
741
786
  let token = typed[1];
742
787
  let startNode = node;
743
788
  let startOffset = caret.startOffset - typed[1].length;
@@ -751,17 +796,164 @@ var RichTextEditor = React2.forwardRef(
751
796
  startNode = prev;
752
797
  startOffset = text.length - continued[1].length;
753
798
  }
754
- const lowered = token.toLowerCase();
799
+ const atLineStart = startOffset === 0 && !previousVisibleText(startNode, editorRef.current);
800
+ return {
801
+ token,
802
+ startNode,
803
+ startOffset,
804
+ caretNode: node,
805
+ caretOffset: caret.startOffset,
806
+ atLineStart,
807
+ selection
808
+ };
809
+ };
810
+ const selectCollectedToken = (collected) => {
811
+ const replaceRange = document.createRange();
812
+ replaceRange.setStart(collected.startNode, collected.startOffset);
813
+ replaceRange.setEnd(collected.caretNode, collected.caretOffset);
814
+ collected.selection.removeAllRanges();
815
+ collected.selection.addRange(replaceRange);
816
+ return replaceRange;
817
+ };
818
+ const expandTypedShortcut = (event) => {
819
+ if (!macros) return;
820
+ const collected = collectTokenBeforeCaret();
821
+ if (!collected) return;
822
+ const lowered = collected.token.toLowerCase();
755
823
  const macro = macros.flatMap((group) => group.macros).find((candidate) => candidate.shortcut?.toLowerCase() === lowered);
756
824
  if (!macro) return;
757
825
  event.preventDefault();
758
- const replaceRange = document.createRange();
759
- replaceRange.setStart(startNode, startOffset);
760
- replaceRange.setEnd(node, caret.startOffset);
761
- selection.removeAllRanges();
762
- selection.addRange(replaceRange);
826
+ selectCollectedToken(collected);
763
827
  insertTextAtSelection(macro.content);
764
828
  };
829
+ const applyMarkdownShortcut = (event) => {
830
+ const collected = collectTokenBeforeCaret();
831
+ if (!collected || !collected.atLineStart) return false;
832
+ if (!Object.hasOwn(MARKDOWN_BLOCK_RULES, collected.token)) return false;
833
+ const rule = MARKDOWN_BLOCK_RULES[collected.token];
834
+ event.preventDefault();
835
+ const replaceRange = selectCollectedToken(collected);
836
+ let deleted = false;
837
+ try {
838
+ deleted = document.execCommand("delete", false);
839
+ } catch {
840
+ deleted = false;
841
+ }
842
+ if (!deleted) {
843
+ replaceRange.deleteContents();
844
+ replaceRange.collapse(true);
845
+ collected.selection.removeAllRanges();
846
+ collected.selection.addRange(replaceRange);
847
+ }
848
+ try {
849
+ document.execCommand(rule.command, false, rule.value);
850
+ } catch {
851
+ }
852
+ editorRef.current?.focus();
853
+ saveSelection();
854
+ updateContent();
855
+ return true;
856
+ };
857
+ const handleEditorKeyDown = (event) => {
858
+ if (disabled) return;
859
+ if (blockMenu && event.key === "/" && (event.ctrlKey || event.metaKey) && !event.altKey) {
860
+ event.preventDefault();
861
+ openBlockMenuAtCaret();
862
+ return;
863
+ }
864
+ if (event.key !== " " || // Space during IME composition selects/converts a candidate; never
865
+ // hijack it for shortcut expansion.
866
+ event.nativeEvent.isComposing || event.ctrlKey || event.metaKey || event.altKey)
867
+ return;
868
+ if (markdownShortcuts && applyMarkdownShortcut(event)) return;
869
+ expandTypedShortcut(event);
870
+ };
871
+ const handleGutterMouseMove = (event) => {
872
+ if (blockMenuOpen || !editorRef.current || !gutterWrapperRef.current)
873
+ return;
874
+ let target = null;
875
+ let top = null;
876
+ for (const child of Array.from(editorRef.current.childNodes)) {
877
+ const rect = nodeRect(child);
878
+ if (!rect) continue;
879
+ if (rect.width === 0 && rect.height === 0) continue;
880
+ if (target === null || rect.top <= event.clientY) {
881
+ target = child;
882
+ top = rect.top;
883
+ }
884
+ }
885
+ if (!target || top === null) return;
886
+ hoverBlockNodeRef.current = target;
887
+ const wrapperTop = gutterWrapperRef.current.getBoundingClientRect().top;
888
+ const next = Math.max(Math.round(top - wrapperTop), 4);
889
+ setGutterTop((current) => current === next ? current : next);
890
+ };
891
+ const rangeAtStartOf = (node) => {
892
+ const range = document.createRange();
893
+ range.setStart(node, 0);
894
+ range.collapse(true);
895
+ return range;
896
+ };
897
+ const handleBlockMenuOpenChange = (open) => {
898
+ if (open) {
899
+ const block = hoverBlockNodeRef.current ?? editorRef.current?.firstChild ?? null;
900
+ blockTargetRangeRef.current = block ? rangeAtStartOf(block) : null;
901
+ } else if (editorRef.current && document.activeElement?.closest('[role="menu"]')) {
902
+ const editor = editorRef.current;
903
+ editor.focus();
904
+ const target = blockTargetRangeRef.current;
905
+ const selection = window.getSelection();
906
+ if (target && selection && editor.contains(target.startContainer)) {
907
+ selection.removeAllRanges();
908
+ selection.addRange(target);
909
+ saveSelection();
910
+ }
911
+ }
912
+ setBlockMenuOpen(open);
913
+ };
914
+ const openBlockMenuAtCaret = () => {
915
+ const selection = window.getSelection();
916
+ if (selection && selection.rangeCount > 0 && editorRef.current?.contains(selection.getRangeAt(0).startContainer)) {
917
+ const caret = selection.getRangeAt(0);
918
+ blockTargetRangeRef.current = caret.cloneRange();
919
+ let node = caret.startContainer;
920
+ while (node.parentNode && node.parentNode !== editorRef.current) {
921
+ node = node.parentNode;
922
+ }
923
+ const rect = nodeRect(node);
924
+ const wrapperRect = gutterWrapperRef.current?.getBoundingClientRect();
925
+ if (rect && wrapperRect) {
926
+ setGutterTop(Math.max(Math.round(rect.top - wrapperRect.top), 4));
927
+ }
928
+ } else {
929
+ blockTargetRangeRef.current = null;
930
+ }
931
+ setBlockMenuOpen(true);
932
+ };
933
+ const applyBlockFormat = (command, value2) => {
934
+ if (disabled || !editorRef.current) return;
935
+ setBlockMenuOpen(false);
936
+ editorRef.current.focus();
937
+ const target = blockTargetRangeRef.current;
938
+ const selection = window.getSelection();
939
+ if (target && selection && editorRef.current.contains(target.startContainer)) {
940
+ selection.removeAllRanges();
941
+ selection.addRange(target);
942
+ }
943
+ try {
944
+ document.execCommand(command, false, value2);
945
+ } catch {
946
+ }
947
+ saveSelection();
948
+ updateContent();
949
+ };
950
+ React2.useEffect(() => {
951
+ if (!blockMenuOpen) return;
952
+ const frame = requestAnimationFrame(
953
+ () => firstBlockItemRef.current?.focus()
954
+ );
955
+ return () => cancelAnimationFrame(frame);
956
+ }, [blockMenuOpen]);
765
957
  const toggleDictation = async () => {
766
958
  const SpeechRecognition = getSpeechRecognition();
767
959
  if (!SpeechRecognition) return;
@@ -783,6 +975,7 @@ var RichTextEditor = React2.forwardRef(
783
975
  );
784
976
  return;
785
977
  }
978
+ if (!toolbarVisibleRef.current) return;
786
979
  const recognition = new SpeechRecognition();
787
980
  recognition.continuous = true;
788
981
  recognition.interimResults = true;
@@ -859,6 +1052,43 @@ var RichTextEditor = React2.forwardRef(
859
1052
  }
860
1053
  };
861
1054
  const iconButtonClass = "h-8 w-8 p-0";
1055
+ const blockFormatItems = [
1056
+ { key: "text", label: "Text", command: "formatBlock", value: "p" },
1057
+ {
1058
+ key: "heading1",
1059
+ label: "Heading 1",
1060
+ command: "formatBlock",
1061
+ value: "h1"
1062
+ },
1063
+ {
1064
+ key: "heading2",
1065
+ label: "Heading 2",
1066
+ command: "formatBlock",
1067
+ value: "h2"
1068
+ },
1069
+ {
1070
+ key: "heading3",
1071
+ label: "Heading 3",
1072
+ command: "formatBlock",
1073
+ value: "h3"
1074
+ },
1075
+ {
1076
+ key: "bulletList",
1077
+ label: "Bullet list",
1078
+ command: "insertUnorderedList"
1079
+ },
1080
+ {
1081
+ key: "numberedList",
1082
+ label: "Numbered list",
1083
+ command: "insertOrderedList"
1084
+ },
1085
+ {
1086
+ key: "quote",
1087
+ label: "Quote",
1088
+ command: "formatBlock",
1089
+ value: "blockquote"
1090
+ }
1091
+ ];
862
1092
  return /* @__PURE__ */ jsxs(
863
1093
  "div",
864
1094
  {
@@ -868,7 +1098,7 @@ var RichTextEditor = React2.forwardRef(
868
1098
  className
869
1099
  ),
870
1100
  children: [
871
- /* @__PURE__ */ jsxs(
1101
+ toolbarVisible && /* @__PURE__ */ jsxs(
872
1102
  "div",
873
1103
  {
874
1104
  "data-slot": "rich-text-editor-toolbar",
@@ -1056,7 +1286,10 @@ var RichTextEditor = React2.forwardRef(
1056
1286
  size: "sm",
1057
1287
  onClick: toggleDictation,
1058
1288
  disabled: disabled || !speechSupported,
1059
- className: cn("h-8 gap-1 px-2", isListening && "animate-pulse"),
1289
+ className: cn(
1290
+ "h-8 gap-1 px-2",
1291
+ isListening && "animate-pulse"
1292
+ ),
1060
1293
  title: !speechSupported ? "Speech recognition not supported in this browser" : isListening ? "Stop dictation" : "Start dictation",
1061
1294
  children: [
1062
1295
  isListening ? /* @__PURE__ */ jsx(MicOff, { className: "h-4 w-4" }) : /* @__PURE__ */ jsx(Mic, { className: "h-4 w-4" }),
@@ -1093,16 +1326,98 @@ var RichTextEditor = React2.forwardRef(
1093
1326
  /* @__PURE__ */ jsxs(
1094
1327
  "div",
1095
1328
  {
1329
+ ref: gutterWrapperRef,
1096
1330
  className: cn(
1097
1331
  "relative flex-1",
1332
+ blockMenu && "group/rte-body",
1098
1333
  hasMacros && macrosOpen && "md:me-72"
1099
1334
  ),
1335
+ onMouseMove: blockMenu && !disabled ? handleGutterMouseMove : void 0,
1100
1336
  children: [
1337
+ blockMenu && !disabled && /* @__PURE__ */ jsx(
1338
+ "div",
1339
+ {
1340
+ "data-slot": "rich-text-editor-block-gutter",
1341
+ className: "absolute start-1 z-10",
1342
+ style: { top: gutterTop },
1343
+ children: /* @__PURE__ */ jsxs(
1344
+ Dropdown,
1345
+ {
1346
+ open: blockMenuOpen,
1347
+ onOpenChange: handleBlockMenuOpenChange,
1348
+ placement: "bottom-start",
1349
+ trigger: /* @__PURE__ */ jsx(
1350
+ Button,
1351
+ {
1352
+ type: "button",
1353
+ variant: "ghost",
1354
+ size: "sm",
1355
+ className: cn(
1356
+ "h-6 w-6 p-0",
1357
+ // Revealed on hover/focus; always visible on touch,
1358
+ // which has no hover to reveal it with.
1359
+ "opacity-0 transition-opacity",
1360
+ "group-hover/rte-body:opacity-100",
1361
+ "focus-visible:opacity-100",
1362
+ "[@media(hover:none)]:opacity-100",
1363
+ blockMenuOpen && "opacity-100"
1364
+ ),
1365
+ "aria-label": blockMenuLabels?.blockMenu ?? "Block menu",
1366
+ title: blockMenuLabels?.blockMenu ?? "Block menu",
1367
+ children: /* @__PURE__ */ jsx(Plus$1, { className: "h-4 w-4" })
1368
+ }
1369
+ ),
1370
+ children: [
1371
+ blockFormatItems.map((item, index) => /* @__PURE__ */ jsx(
1372
+ DropdownItem,
1373
+ {
1374
+ ref: index === 0 ? firstBlockItemRef : void 0,
1375
+ onClick: () => applyBlockFormat(item.command, item.value),
1376
+ children: blockMenuLabels?.[item.key] ?? item.label
1377
+ },
1378
+ item.key
1379
+ )),
1380
+ /* @__PURE__ */ jsx(DropdownSeparator, {}),
1381
+ hasMacros && /* @__PURE__ */ jsx(
1382
+ DropdownItem,
1383
+ {
1384
+ onClick: () => {
1385
+ setBlockMenuOpen(false);
1386
+ if (blockTargetRangeRef.current) {
1387
+ savedRangeRef.current = blockTargetRangeRef.current;
1388
+ }
1389
+ setMacrosOpen(true);
1390
+ focusRestoringSelection();
1391
+ },
1392
+ children: blockMenuLabels?.insertMacro ?? "Insert macro\u2026"
1393
+ }
1394
+ ),
1395
+ /* @__PURE__ */ jsx(
1396
+ DropdownItem,
1397
+ {
1398
+ onClick: () => {
1399
+ setBlockMenuOpen(false);
1400
+ setToolbarVisibility(
1401
+ toolbarVisible ? "hidden" : "visible"
1402
+ );
1403
+ editorRef.current?.focus();
1404
+ },
1405
+ children: toolbarVisible ? blockMenuLabels?.hideToolbar ?? "Hide toolbar" : blockMenuLabels?.showToolbar ?? "Show toolbar"
1406
+ }
1407
+ )
1408
+ ]
1409
+ }
1410
+ )
1411
+ }
1412
+ ),
1101
1413
  isEmpty && placeholder && /* @__PURE__ */ jsx(
1102
1414
  "div",
1103
1415
  {
1104
1416
  "aria-hidden": "true",
1105
- className: "text-muted-foreground pointer-events-none absolute start-0 top-0 p-4",
1417
+ className: cn(
1418
+ "text-muted-foreground pointer-events-none absolute start-0 top-0 p-4",
1419
+ blockMenu && "ps-10"
1420
+ ),
1106
1421
  children: placeholder
1107
1422
  }
1108
1423
  ),
@@ -1119,12 +1434,23 @@ var RichTextEditor = React2.forwardRef(
1119
1434
  onPaste: handlePaste,
1120
1435
  onDrop: handleDrop,
1121
1436
  onBlur: hasMacros ? saveSelection : void 0,
1122
- onKeyDown: hasMacros ? expandTypedShortcut : void 0,
1437
+ onKeyDown: hasMacros || markdownShortcuts || blockMenu ? handleEditorKeyDown : void 0,
1123
1438
  onKeyUp: hasMacros ? saveSelection : void 0,
1124
1439
  onMouseUp: hasMacros ? saveSelection : void 0,
1125
1440
  className: cn(
1126
1441
  "min-h-[250px] overflow-y-auto p-4 focus:outline-none",
1127
1442
  "prose prose-sm max-w-none",
1443
+ // Baseline block styles: Tailwind preflight strips heading,
1444
+ // list and quote styling, and the `prose` classes above only
1445
+ // work when the host installs @tailwindcss/typography —
1446
+ // without these, block formats would apply invisibly.
1447
+ "[&_h1]:my-3 [&_h1]:text-2xl [&_h1]:font-bold",
1448
+ "[&_h2]:my-3 [&_h2]:text-xl [&_h2]:font-bold",
1449
+ "[&_h3]:my-2 [&_h3]:text-lg [&_h3]:font-semibold",
1450
+ "[&_ul]:my-2 [&_ul]:list-disc [&_ul]:ps-6",
1451
+ "[&_ol]:my-2 [&_ol]:list-decimal [&_ol]:ps-6",
1452
+ "[&_blockquote]:border-border [&_blockquote]:text-muted-foreground [&_blockquote]:my-2 [&_blockquote]:border-s-4 [&_blockquote]:ps-3",
1453
+ blockMenu && "ps-10",
1128
1454
  hasMacros && macrosOpen && "md:min-h-[400px]",
1129
1455
  disabled && "bg-muted/20 cursor-not-allowed text-neutral-700 dark:text-neutral-300"
1130
1456
  ),
@@ -1159,5 +1485,5 @@ var RichTextEditor = React2.forwardRef(
1159
1485
  RichTextEditor.displayName = "RichTextEditor";
1160
1486
 
1161
1487
  export { MacroLibrary, RichTextEditor, convertAngleBracketsToMustache, processDictation };
1162
- //# sourceMappingURL=chunk-5EOW6AP3.js.map
1163
- //# sourceMappingURL=chunk-5EOW6AP3.js.map
1488
+ //# sourceMappingURL=chunk-UTSRPQ5B.js.map
1489
+ //# sourceMappingURL=chunk-UTSRPQ5B.js.map