@tuturuuu/ui 0.27.0 → 0.28.1

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.
Files changed (27) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +4 -3
  3. package/src/components/ui/button.tsx +12 -8
  4. package/src/components/ui/calendar.tsx +6 -6
  5. package/src/components/ui/color-picker.test.tsx +23 -0
  6. package/src/components/ui/color-picker.tsx +1 -1
  7. package/src/components/ui/custom/notification-popover-client.tsx +6 -19
  8. package/src/components/ui/custom/notification-popover-trigger.test.tsx +37 -0
  9. package/src/components/ui/custom/notification-popover-trigger.tsx +44 -0
  10. package/src/components/ui/date-time-picker-layout.ts +6 -0
  11. package/src/components/ui/date-time-picker.test.tsx +25 -0
  12. package/src/components/ui/date-time-picker.tsx +5 -5
  13. package/src/components/ui/text-editor/__tests__/editor-classes.test.ts +36 -0
  14. package/src/components/ui/text-editor/__tests__/image-extension-clipboard.test.ts +21 -0
  15. package/src/components/ui/text-editor/__tests__/inline-task-conversion.test.tsx +30 -0
  16. package/src/components/ui/text-editor/__tests__/keyboard.test.ts +173 -3
  17. package/src/components/ui/text-editor/__tests__/markdown-paste-extension.test.ts +29 -1
  18. package/src/components/ui/text-editor/__tests__/toggle-block-extension.test.ts +251 -0
  19. package/src/components/ui/text-editor/editor-classes.ts +100 -0
  20. package/src/components/ui/text-editor/editor.tsx +11 -153
  21. package/src/components/ui/text-editor/extensions.ts +19 -0
  22. package/src/components/ui/text-editor/image-extension.ts +10 -14
  23. package/src/components/ui/text-editor/keyboard.ts +47 -0
  24. package/src/components/ui/text-editor/markdown-paste-extension.ts +25 -2
  25. package/src/components/ui/text-editor/toggle-block-extension.ts +153 -0
  26. package/src/components/ui/text-editor/tool-bar.tsx +24 -73
  27. package/src/components/ui/text-editor/toolbar-config.ts +67 -0
@@ -85,9 +85,18 @@ function resolveUploadHandler({
85
85
  return configuredHandler;
86
86
  }
87
87
 
88
+ function getClipboardImageFiles(
89
+ items: DataTransferItemList | DataTransferItem[]
90
+ ) {
91
+ return Array.from(items)
92
+ .map((item) => (item.type.startsWith('image/') ? item.getAsFile() : null))
93
+ .filter((file): file is File => file !== null);
94
+ }
95
+
88
96
  export const __imageExtensionPrivate = {
89
97
  clearImageResizeUIFromNodeDom,
90
98
  getSelectedImagePos,
99
+ getClipboardImageFiles,
91
100
  resolveUploadHandler,
92
101
  };
93
102
 
@@ -437,23 +446,10 @@ export const CustomImage = (options: ImageOptions = {}) => {
437
446
  if (!items) return false;
438
447
 
439
448
  // Filter and collect image files
440
- const images = Array.from(items)
441
- .map((item) =>
442
- item.type.startsWith('image/') ? item.getAsFile() : null
443
- )
444
- .filter((file): file is File => file !== null);
449
+ const images = getClipboardImageFiles(items);
445
450
 
446
451
  if (images.length === 0) return false;
447
452
 
448
- const hasTextOrHtml = Array.from(items).some(
449
- (item) =>
450
- item.type === 'text/plain' || item.type === 'text/html'
451
- );
452
-
453
- if (hasTextOrHtml) {
454
- return false;
455
- }
456
-
457
453
  const onImageUpload = getOnImageUpload();
458
454
  if (!onImageUpload) {
459
455
  event.preventDefault();
@@ -1,9 +1,25 @@
1
1
  import { splitBlock } from '@tiptap/pm/commands';
2
+ import { liftListItem, sinkListItem } from '@tiptap/pm/schema-list';
2
3
  import type { EditorState } from '@tiptap/pm/state';
3
4
  import type { EditorView } from '@tiptap/pm/view';
4
5
 
5
6
  const EXTENSION_OWNED_ENTER_NODE_NAMES = new Set(['listItem', 'taskItem']);
6
7
  const PLAIN_ENTER_FALLBACK_TEXTBLOCK_NAMES = new Set(['paragraph', 'heading']);
8
+ const INDENTABLE_LIST_ITEM_NAMES = new Set(['listItem', 'taskItem']);
9
+
10
+ function findAncestorNodeName(
11
+ state: EditorState,
12
+ nodeNames: ReadonlySet<string>
13
+ ): string | null {
14
+ const { $from } = state.selection;
15
+
16
+ for (let depth = $from.depth; depth > 0; depth -= 1) {
17
+ const name = $from.node(depth).type.name;
18
+ if (nodeNames.has(name)) return name;
19
+ }
20
+
21
+ return null;
22
+ }
7
23
 
8
24
  export function isSelectionInsideNode(
9
25
  state: EditorState,
@@ -56,3 +72,34 @@ export function handlePlainEnterFallback(
56
72
 
57
73
  return didSplit;
58
74
  }
75
+
76
+ /**
77
+ * Indent list items without reaching through a React closure for the editor.
78
+ * The DOM view is the source of truth at keydown time, including for editors
79
+ * that were just mounted or rebound to a collaboration document.
80
+ */
81
+ export function handleListIndentation(
82
+ view: EditorView,
83
+ event: KeyboardEvent
84
+ ): boolean {
85
+ if (event.key !== 'Tab' || event.altKey || event.ctrlKey || event.metaKey) {
86
+ return false;
87
+ }
88
+
89
+ const itemName = findAncestorNodeName(view.state, INDENTABLE_LIST_ITEM_NAMES);
90
+ if (!itemName) return false;
91
+
92
+ const itemType = view.state.schema.nodes[itemName];
93
+ if (!itemType) return false;
94
+
95
+ const command = event.shiftKey
96
+ ? liftListItem(itemType)
97
+ : sinkListItem(itemType);
98
+ const handled = command(view.state, view.dispatch.bind(view), view);
99
+
100
+ if (!handled) return false;
101
+
102
+ event.preventDefault();
103
+ event.stopPropagation();
104
+ return true;
105
+ }
@@ -27,6 +27,26 @@ const BLOCK_START_PATTERNS = [
27
27
  /^\s*\|/,
28
28
  ];
29
29
 
30
+ const VISUAL_BULLET_PATTERN = /^(\s*)[•◦▪‣●]\s+/u;
31
+ const VISUAL_CHECKBOX_PATTERN = /^(\s*)[☐☑☒]\s+/u;
32
+
33
+ function normalizePastedPlainText(text: string): string {
34
+ return text
35
+ .replace(/\r\n?/g, '\n')
36
+ .split('\n')
37
+ .map((line) => {
38
+ const checkboxMatch = line.match(VISUAL_CHECKBOX_PATTERN);
39
+ if (checkboxMatch) {
40
+ const marker = line.trimStart().charAt(0);
41
+ const content = line.replace(VISUAL_CHECKBOX_PATTERN, '');
42
+ return `${checkboxMatch[1] ?? ''}- [${marker === '☐' ? ' ' : 'x'}] ${content}`;
43
+ }
44
+
45
+ return line.replace(VISUAL_BULLET_PATTERN, '$1- ');
46
+ })
47
+ .join('\n');
48
+ }
49
+
30
50
  function isBlockStart(line: string): boolean {
31
51
  return BLOCK_START_PATTERNS.some((p) => p.test(line));
32
52
  }
@@ -496,7 +516,7 @@ function markdownToHtml(markdown: string): string {
496
516
  paraLines.push(lines[i]!);
497
517
  i++;
498
518
  }
499
- result.push(`<p>${parseInline(paraLines.join(' '))}</p>`);
519
+ result.push(`<p>${paraLines.map(parseInline).join('<br>')}</p>`);
500
520
  }
501
521
 
502
522
  return result.join('\n');
@@ -539,6 +559,7 @@ function looksLikeMarkdown(text: string): boolean {
539
559
  export const __markdownPastePrivate = {
540
560
  markdownToHtml,
541
561
  looksLikeMarkdown,
562
+ normalizePastedPlainText,
542
563
  };
543
564
 
544
565
  const markdownPastePluginKey = new PluginKey('markdownPastePlugin');
@@ -569,7 +590,9 @@ export const MarkdownPaste = Extension.create({
569
590
  return false;
570
591
  }
571
592
 
572
- const text = clipboardData.getData('text/plain');
593
+ const text = normalizePastedPlainText(
594
+ clipboardData.getData('text/plain')
595
+ );
573
596
  if (!text || !looksLikeMarkdown(text)) {
574
597
  return false;
575
598
  }
@@ -0,0 +1,153 @@
1
+ import {
2
+ Extension,
3
+ findParentNode,
4
+ type JSONContent,
5
+ mergeAttributes,
6
+ } from '@tiptap/core';
7
+ import {
8
+ DetailsSummary,
9
+ type DetailsSummaryOptions,
10
+ } from '@tiptap/extension-details';
11
+ import { Fragment } from '@tiptap/pm/model';
12
+ import { TextSelection } from '@tiptap/pm/state';
13
+
14
+ export type ToggleHeadingLevel = 1 | 2 | 3 | null;
15
+
16
+ declare module '@tiptap/core' {
17
+ interface Commands<ReturnType> {
18
+ toggleBlock: {
19
+ /** Convert the selected blocks to a Notion-style toggle, or unwrap it. */
20
+ toggleDetailsBlock: () => ReturnType;
21
+ };
22
+ }
23
+ }
24
+
25
+ function parseHeadingLevel(element: HTMLElement): ToggleHeadingLevel {
26
+ const level = Number(element.dataset.headingLevel);
27
+ return level === 1 || level === 2 || level === 3 ? level : null;
28
+ }
29
+
30
+ export const ToggleSummary = DetailsSummary.extend<DetailsSummaryOptions>({
31
+ addAttributes() {
32
+ return {
33
+ level: {
34
+ default: null,
35
+ parseHTML: parseHeadingLevel,
36
+ renderHTML: ({ level }: { level: ToggleHeadingLevel }) =>
37
+ level ? { 'data-heading-level': String(level) } : {},
38
+ },
39
+ };
40
+ },
41
+
42
+ renderHTML({ HTMLAttributes }) {
43
+ return [
44
+ 'summary',
45
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
46
+ 0,
47
+ ];
48
+ },
49
+ });
50
+
51
+ function summaryFromBlock(block: JSONContent | undefined) {
52
+ const isTextBlock = block?.type === 'paragraph' || block?.type === 'heading';
53
+ const level =
54
+ block?.type === 'heading' && [1, 2, 3].includes(block.attrs?.level)
55
+ ? (block.attrs?.level as 1 | 2 | 3)
56
+ : null;
57
+
58
+ return {
59
+ summary: {
60
+ type: 'detailsSummary',
61
+ attrs: { level },
62
+ content: isTextBlock ? block.content : undefined,
63
+ } satisfies JSONContent,
64
+ consumedFirstBlock: isTextBlock,
65
+ };
66
+ }
67
+
68
+ export const ToggleBlock = Extension.create({
69
+ name: 'toggleBlock',
70
+
71
+ addCommands() {
72
+ return {
73
+ toggleDetailsBlock:
74
+ () =>
75
+ ({ state, dispatch }) => {
76
+ const detailsType = state.schema.nodes.details;
77
+ if (!detailsType) return false;
78
+
79
+ const existing = findParentNode((node) => node.type === detailsType)(
80
+ state.selection
81
+ );
82
+
83
+ if (existing) {
84
+ const summary = existing.node.child(0);
85
+ const detailsContent = existing.node.child(1);
86
+ const level = summary.attrs.level as ToggleHeadingLevel;
87
+ const summaryType = level
88
+ ? state.schema.nodes.heading
89
+ : state.schema.nodes.paragraph;
90
+ if (!summaryType) return false;
91
+
92
+ if (dispatch) {
93
+ const restoredSummary = summaryType.create(
94
+ level ? { level } : undefined,
95
+ summary.content
96
+ );
97
+ const restoredNodes = [
98
+ restoredSummary,
99
+ ...detailsContent.content.content,
100
+ ];
101
+ const transaction = state.tr.replaceWith(
102
+ existing.pos,
103
+ existing.pos + existing.node.nodeSize,
104
+ Fragment.fromArray(restoredNodes)
105
+ );
106
+ transaction.setSelection(
107
+ TextSelection.create(transaction.doc, existing.pos + 1)
108
+ );
109
+ dispatch(transaction.scrollIntoView());
110
+ }
111
+
112
+ return true;
113
+ }
114
+
115
+ const { $from, $to } = state.selection;
116
+ const range = $from.blockRange($to);
117
+ if (!range) return false;
118
+
119
+ const selected = state.doc.slice(range.start, range.end).toJSON()
120
+ .content as JSONContent[] | undefined;
121
+ if (!selected?.length) return false;
122
+
123
+ const { summary, consumedFirstBlock } = summaryFromBlock(selected[0]);
124
+ const body = consumedFirstBlock ? selected.slice(1) : selected;
125
+
126
+ if (dispatch) {
127
+ const details = state.schema.nodeFromJSON({
128
+ type: 'details',
129
+ attrs: { open: true },
130
+ content: [
131
+ summary,
132
+ {
133
+ type: 'detailsContent',
134
+ content: body.length ? body : [{ type: 'paragraph' }],
135
+ },
136
+ ],
137
+ });
138
+ const transaction = state.tr.replaceWith(
139
+ range.start,
140
+ range.end,
141
+ details
142
+ );
143
+ transaction.setSelection(
144
+ TextSelection.create(transaction.doc, range.start + 2)
145
+ );
146
+ dispatch(transaction.scrollIntoView());
147
+ }
148
+
149
+ return true;
150
+ },
151
+ };
152
+ },
153
+ });
@@ -1,4 +1,3 @@
1
- import { formatForDisplay } from '@tanstack/react-hotkeys';
2
1
  import type { Editor } from '@tiptap/react';
3
2
  import { BubbleMenu } from '@tiptap/react/menus';
4
3
  import {
@@ -17,6 +16,7 @@ import {
17
16
  Italic,
18
17
  Link,
19
18
  List,
19
+ ListCollapse,
20
20
  ListOrdered,
21
21
  ListTodo,
22
22
  Loader2,
@@ -42,86 +42,17 @@ import {
42
42
  MAX_VIDEO_SIZE,
43
43
  StorageQuotaError,
44
44
  } from './media-utils';
45
+ import { hotkeyLabel, TOOLBAR_GROUPS, TOOLBAR_LABELS } from './toolbar-config';
45
46
 
46
47
  type LinkEditorContext = 'bubble' | 'popover' | null;
47
48
 
48
- // ---------------------------------------------------------------------------
49
- // Hotkey definitions – use 'Mod' for cross-platform (⌘ on Mac, Ctrl on Win)
50
- // These match Tiptap's built-in shortcuts where applicable.
51
- // ---------------------------------------------------------------------------
52
- const HOTKEYS = {
53
- 'heading-1': 'Mod+Alt+1',
54
- 'heading-2': 'Mod+Alt+2',
55
- 'heading-3': 'Mod+Alt+3',
56
- bold: 'Mod+B',
57
- italic: 'Mod+I',
58
- strike: 'Mod+Shift+S',
59
- subscript: 'Mod+,',
60
- superscript: 'Mod+.',
61
- 'align-left': 'Mod+Shift+L',
62
- 'align-center': 'Mod+Shift+E',
63
- 'align-right': 'Mod+Shift+R',
64
- 'bullet-list': 'Mod+Shift+8',
65
- 'ordered-list': 'Mod+Shift+7',
66
- 'task-list': 'Mod+Shift+9',
67
- table: '',
68
- link: 'Mod+K',
69
- image: '',
70
- video: '',
71
- youtube: '',
72
- 'convert-to-task': '',
73
- } as const;
74
-
75
- // Labels for each formatting option
76
- const LABELS: Record<string, string> = {
77
- 'heading-1': 'Heading 1',
78
- 'heading-2': 'Heading 2',
79
- 'heading-3': 'Heading 3',
80
- bold: 'Bold',
81
- italic: 'Italic',
82
- strike: 'Strikethrough',
83
- subscript: 'Subscript',
84
- superscript: 'Superscript',
85
- 'align-left': 'Align Left',
86
- 'align-center': 'Align Center',
87
- 'align-right': 'Align Right',
88
- 'bullet-list': 'Bullet List',
89
- 'ordered-list': 'Ordered List',
90
- 'task-list': 'Task List',
91
- table: 'Insert Table',
92
- link: 'Link',
93
- image: 'Upload Image',
94
- video: 'Upload Video',
95
- youtube: 'YouTube Video',
96
- 'convert-to-task': 'Convert to Task',
97
- };
98
-
99
- // Semantic groups for the fixed toolbar
100
- const TOOLBAR_GROUPS = [
101
- ['heading-1', 'heading-2', 'heading-3'],
102
- ['bold', 'italic', 'strike', 'subscript', 'superscript'],
103
- ['align-left', 'align-center', 'align-right'],
104
- ['bullet-list', 'ordered-list', 'task-list'],
105
- ['table', 'link'],
106
- ] as const;
107
-
108
- /** Format a hotkey combo string for display (platform-aware). */
109
- function hotkeyLabel(key: string): string {
110
- const combo = HOTKEYS[key as keyof typeof HOTKEYS];
111
- if (!combo) return '';
112
- try {
113
- return formatForDisplay(combo);
114
- } catch {
115
- return combo;
116
- }
117
- }
118
-
119
49
  // ---------------------------------------------------------------------------
120
50
  // Shared sub-components
121
51
  // ---------------------------------------------------------------------------
122
52
 
123
53
  interface ToolbarButtonProps {
124
54
  id: string;
55
+ label?: string;
125
56
  icon: React.ReactNode;
126
57
  pressed: boolean;
127
58
  onClick: () => void;
@@ -131,12 +62,13 @@ interface ToolbarButtonProps {
131
62
  /** A single toolbar toggle button wrapped with a tooltip showing name + hotkey. */
132
63
  function ToolbarButton({
133
64
  id,
65
+ label: labelOverride,
134
66
  icon,
135
67
  pressed,
136
68
  onClick,
137
69
  disabled,
138
70
  }: ToolbarButtonProps) {
139
- const label = LABELS[id] ?? id;
71
+ const label = labelOverride ?? TOOLBAR_LABELS[id] ?? id;
140
72
  const shortcut = hotkeyLabel(id);
141
73
 
142
74
  return (
@@ -183,6 +115,7 @@ interface ToolBarProps {
183
115
  onConvertToTask?: () => void | Promise<void>;
184
116
  /** When true, the BubbleMenu is hidden because the fixed toolbar is visible */
185
117
  fixedToolbarVisible?: boolean;
118
+ toggleBlockLabel?: string;
186
119
  }
187
120
 
188
121
  export function ToolBar({
@@ -191,6 +124,7 @@ export function ToolBar({
191
124
  workspaceId,
192
125
  onImageUpload,
193
126
  onConvertToTask,
127
+ toggleBlockLabel,
194
128
  }: ToolBarProps) {
195
129
  const [linkEditorContext, setLinkEditorContext] =
196
130
  useState<LinkEditorContext>(null);
@@ -358,6 +292,12 @@ export function ToolBar({
358
292
  onClick: () => editor?.chain().focus().toggleTaskListSmart().run(),
359
293
  pressed: editor?.isActive('taskList'),
360
294
  },
295
+ {
296
+ key: 'toggle-block',
297
+ icon: <ListCollapse className="size-4" />,
298
+ onClick: () => editor?.chain().focus().toggleDetailsBlock().run(),
299
+ pressed: editor?.isActive('details'),
300
+ },
361
301
  {
362
302
  key: 'table',
363
303
  icon: <Table className="size-4" />,
@@ -587,6 +527,7 @@ export function ToolBar({
587
527
  <ToolbarButton
588
528
  key={`${option.key}-${source}`}
589
529
  id={option.key}
530
+ label={option.key === 'toggle-block' ? toggleBlockLabel : undefined}
590
531
  icon={option.icon}
591
532
  pressed={option.pressed as boolean}
592
533
  onClick={option.onClick}
@@ -673,6 +614,7 @@ export function ToolBar({
673
614
  isUploadingVideo,
674
615
  showYoutubeInput,
675
616
  onConvertToTask,
617
+ toggleBlockLabel,
676
618
  ]
677
619
  );
678
620
 
@@ -967,6 +909,7 @@ interface FixedToolbarProps {
967
909
  onConvertToTask?: () => void | Promise<void>;
968
910
  className?: string;
969
911
  ref?: React.Ref<HTMLDivElement>;
912
+ toggleBlockLabel?: string;
970
913
  }
971
914
 
972
915
  export function FixedToolbar({
@@ -976,6 +919,7 @@ export function FixedToolbar({
976
919
  onConvertToTask,
977
920
  className,
978
921
  ref,
922
+ toggleBlockLabel,
979
923
  }: FixedToolbarProps) {
980
924
  const [isUploadingImage, setIsUploadingImage] = useState(false);
981
925
  const [isUploadingVideo, setIsUploadingVideo] = useState(false);
@@ -1083,6 +1027,12 @@ export function FixedToolbar({
1083
1027
  onClick: () => editor.chain().focus().toggleTaskListSmart().run(),
1084
1028
  pressed: editor.isActive('taskList'),
1085
1029
  },
1030
+ {
1031
+ key: 'toggle-block',
1032
+ icon: <ListCollapse className="size-4" />,
1033
+ onClick: () => editor.chain().focus().toggleDetailsBlock().run(),
1034
+ pressed: editor.isActive('details'),
1035
+ },
1086
1036
  {
1087
1037
  key: 'table',
1088
1038
  icon: <Table className="size-4" />,
@@ -1225,6 +1175,7 @@ export function FixedToolbar({
1225
1175
  <ToolbarButton
1226
1176
  key={key}
1227
1177
  id={key}
1178
+ label={key === 'toggle-block' ? toggleBlockLabel : undefined}
1228
1179
  icon={opt.icon}
1229
1180
  pressed={opt.pressed}
1230
1181
  onClick={opt.onClick}
@@ -0,0 +1,67 @@
1
+ import { formatForDisplay } from '@tanstack/react-hotkeys';
2
+
3
+ const HOTKEYS = {
4
+ 'heading-1': 'Mod+Alt+1',
5
+ 'heading-2': 'Mod+Alt+2',
6
+ 'heading-3': 'Mod+Alt+3',
7
+ bold: 'Mod+B',
8
+ italic: 'Mod+I',
9
+ strike: 'Mod+Shift+S',
10
+ subscript: 'Mod+,',
11
+ superscript: 'Mod+.',
12
+ 'align-left': 'Mod+Shift+L',
13
+ 'align-center': 'Mod+Shift+E',
14
+ 'align-right': 'Mod+Shift+R',
15
+ 'bullet-list': 'Mod+Shift+8',
16
+ 'ordered-list': 'Mod+Shift+7',
17
+ 'task-list': 'Mod+Shift+9',
18
+ 'toggle-block': '',
19
+ table: '',
20
+ link: 'Mod+K',
21
+ image: '',
22
+ video: '',
23
+ youtube: '',
24
+ 'convert-to-task': '',
25
+ } as const;
26
+
27
+ export const TOOLBAR_LABELS: Record<string, string> = {
28
+ 'heading-1': 'Heading 1',
29
+ 'heading-2': 'Heading 2',
30
+ 'heading-3': 'Heading 3',
31
+ bold: 'Bold',
32
+ italic: 'Italic',
33
+ strike: 'Strikethrough',
34
+ subscript: 'Subscript',
35
+ superscript: 'Superscript',
36
+ 'align-left': 'Align Left',
37
+ 'align-center': 'Align Center',
38
+ 'align-right': 'Align Right',
39
+ 'bullet-list': 'Bullet List',
40
+ 'ordered-list': 'Ordered List',
41
+ 'task-list': 'Task List',
42
+ 'toggle-block': 'Toggle List or Heading',
43
+ table: 'Insert Table',
44
+ link: 'Link',
45
+ image: 'Upload Image',
46
+ video: 'Upload Video',
47
+ youtube: 'YouTube Video',
48
+ 'convert-to-task': 'Convert to Task',
49
+ };
50
+
51
+ export const TOOLBAR_GROUPS = [
52
+ ['heading-1', 'heading-2', 'heading-3'],
53
+ ['bold', 'italic', 'strike', 'subscript', 'superscript'],
54
+ ['align-left', 'align-center', 'align-right'],
55
+ ['bullet-list', 'ordered-list', 'task-list', 'toggle-block'],
56
+ ['table', 'link'],
57
+ ] as const;
58
+
59
+ export function hotkeyLabel(key: string): string {
60
+ const combo = HOTKEYS[key as keyof typeof HOTKEYS];
61
+ if (!combo) return '';
62
+ try {
63
+ return formatForDisplay(combo);
64
+ } catch {
65
+ return combo;
66
+ }
67
+ }