@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.
- package/CHANGELOG.md +21 -0
- package/package.json +4 -3
- package/src/components/ui/button.tsx +12 -8
- package/src/components/ui/calendar.tsx +6 -6
- package/src/components/ui/color-picker.test.tsx +23 -0
- package/src/components/ui/color-picker.tsx +1 -1
- package/src/components/ui/custom/notification-popover-client.tsx +6 -19
- package/src/components/ui/custom/notification-popover-trigger.test.tsx +37 -0
- package/src/components/ui/custom/notification-popover-trigger.tsx +44 -0
- package/src/components/ui/date-time-picker-layout.ts +6 -0
- package/src/components/ui/date-time-picker.test.tsx +25 -0
- package/src/components/ui/date-time-picker.tsx +5 -5
- package/src/components/ui/text-editor/__tests__/editor-classes.test.ts +36 -0
- package/src/components/ui/text-editor/__tests__/image-extension-clipboard.test.ts +21 -0
- package/src/components/ui/text-editor/__tests__/inline-task-conversion.test.tsx +30 -0
- package/src/components/ui/text-editor/__tests__/keyboard.test.ts +173 -3
- package/src/components/ui/text-editor/__tests__/markdown-paste-extension.test.ts +29 -1
- package/src/components/ui/text-editor/__tests__/toggle-block-extension.test.ts +251 -0
- package/src/components/ui/text-editor/editor-classes.ts +100 -0
- package/src/components/ui/text-editor/editor.tsx +11 -153
- package/src/components/ui/text-editor/extensions.ts +19 -0
- package/src/components/ui/text-editor/image-extension.ts +10 -14
- package/src/components/ui/text-editor/keyboard.ts +47 -0
- package/src/components/ui/text-editor/markdown-paste-extension.ts +25 -2
- package/src/components/ui/text-editor/toggle-block-extension.ts +153 -0
- package/src/components/ui/text-editor/tool-bar.tsx +24 -73
- 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 =
|
|
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>${
|
|
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 =
|
|
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 =
|
|
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
|
+
}
|