@tuturuuu/ui 0.28.1 → 0.29.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 +23 -0
- package/biome.json +1 -1
- package/package.json +53 -53
- package/src/components/ui/text-editor/__tests__/image-extension-clipboard.test.ts +66 -0
- package/src/components/ui/text-editor/__tests__/inline-task-conversion.test.tsx +116 -5
- package/src/components/ui/text-editor/__tests__/markdown-paste-extension.test.ts +392 -3
- package/src/components/ui/text-editor/clipboard-image-files.ts +41 -0
- package/src/components/ui/text-editor/clipboard-serialization.ts +249 -0
- package/src/components/ui/text-editor/color-controls.tsx +1 -1
- package/src/components/ui/text-editor/copy-menu.tsx +129 -0
- package/src/components/ui/text-editor/editor.tsx +7 -0
- package/src/components/ui/text-editor/image-extension.ts +1 -8
- package/src/components/ui/text-editor/markdown-paste-extension.ts +39 -14
- package/src/components/ui/text-editor/tool-bar.tsx +17 -69
- package/src/components/ui/text-editor/toolbar-controls.tsx +59 -0
|
@@ -182,7 +182,7 @@ function ColorControlPopover({
|
|
|
182
182
|
aria-label={label}
|
|
183
183
|
aria-pressed={active}
|
|
184
184
|
className={cn(
|
|
185
|
-
'relative h-8 w-8 rounded-md border border-transparent transition-colors hover:bg-dynamic-surface/80',
|
|
185
|
+
'relative h-8 w-8 shrink-0 rounded-md border border-transparent transition-colors hover:bg-dynamic-surface/80',
|
|
186
186
|
active &&
|
|
187
187
|
'border-foreground/10 bg-dynamic-surface/80 text-foreground'
|
|
188
188
|
)}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { Editor } from '@tiptap/react';
|
|
4
|
+
import { Code2, Copy, FileText } from '@tuturuuu/icons';
|
|
5
|
+
import {
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
DropdownMenuContent,
|
|
8
|
+
DropdownMenuItem,
|
|
9
|
+
DropdownMenuLabel,
|
|
10
|
+
DropdownMenuSeparator,
|
|
11
|
+
DropdownMenuTrigger,
|
|
12
|
+
} from '@tuturuuu/ui/dropdown-menu';
|
|
13
|
+
import { toast } from '@tuturuuu/ui/sonner';
|
|
14
|
+
import { Toggle } from '@tuturuuu/ui/toggle';
|
|
15
|
+
import { useCallback, useState } from 'react';
|
|
16
|
+
import {
|
|
17
|
+
serializeClipboardPlainText,
|
|
18
|
+
serializeClipboardText,
|
|
19
|
+
} from './clipboard-serialization';
|
|
20
|
+
import { TOOLBAR_BUTTON_CLASS_NAME } from './toolbar-controls';
|
|
21
|
+
|
|
22
|
+
export interface EditorCopyLabels {
|
|
23
|
+
copy?: string;
|
|
24
|
+
copyAsMarkdown?: string;
|
|
25
|
+
copyAsPlainText?: string;
|
|
26
|
+
markdownDescription?: string;
|
|
27
|
+
plainTextDescription?: string;
|
|
28
|
+
markdownCopied?: string;
|
|
29
|
+
plainTextCopied?: string;
|
|
30
|
+
copyFailed?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface EditorCopyMenuProps {
|
|
34
|
+
editor: Editor;
|
|
35
|
+
labels?: EditorCopyLabels;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DEFAULT_LABELS = {
|
|
39
|
+
copy: 'Copy content',
|
|
40
|
+
copyAsMarkdown: 'Copy as Markdown',
|
|
41
|
+
copyAsPlainText: 'Copy as plain text',
|
|
42
|
+
markdownDescription: 'Keep headings, lists, links, and formatting syntax',
|
|
43
|
+
plainTextDescription: 'Clean text for chat, email, or documents',
|
|
44
|
+
markdownCopied: 'Copied as Markdown',
|
|
45
|
+
plainTextCopied: 'Copied as plain text',
|
|
46
|
+
copyFailed: 'Could not copy content',
|
|
47
|
+
} satisfies Required<EditorCopyLabels>;
|
|
48
|
+
|
|
49
|
+
export function EditorCopyMenu({ editor, labels }: EditorCopyMenuProps) {
|
|
50
|
+
const [open, setOpen] = useState(false);
|
|
51
|
+
const copy = useCallback(
|
|
52
|
+
async (format: 'markdown' | 'text') => {
|
|
53
|
+
try {
|
|
54
|
+
if (!navigator.clipboard?.writeText) throw new Error('Unavailable');
|
|
55
|
+
const slice = editor.state.doc.slice(0, editor.state.doc.content.size);
|
|
56
|
+
const content =
|
|
57
|
+
format === 'markdown'
|
|
58
|
+
? serializeClipboardText(slice)
|
|
59
|
+
: serializeClipboardPlainText(slice);
|
|
60
|
+
await navigator.clipboard.writeText(content);
|
|
61
|
+
toast.success(
|
|
62
|
+
format === 'markdown'
|
|
63
|
+
? (labels?.markdownCopied ?? DEFAULT_LABELS.markdownCopied)
|
|
64
|
+
: (labels?.plainTextCopied ?? DEFAULT_LABELS.plainTextCopied)
|
|
65
|
+
);
|
|
66
|
+
} catch {
|
|
67
|
+
toast.error(labels?.copyFailed ?? DEFAULT_LABELS.copyFailed);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
[editor, labels]
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const copyLabel = labels?.copy ?? DEFAULT_LABELS.copy;
|
|
74
|
+
const copyAndClose = (format: 'markdown' | 'text') => {
|
|
75
|
+
setOpen(false);
|
|
76
|
+
void copy(format);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
81
|
+
<DropdownMenuTrigger asChild>
|
|
82
|
+
<Toggle
|
|
83
|
+
pressed={open}
|
|
84
|
+
disabled={editor.isEmpty}
|
|
85
|
+
aria-label={copyLabel}
|
|
86
|
+
className={`${TOOLBAR_BUTTON_CLASS_NAME} data-[state=open]:border-foreground/10 data-[state=open]:bg-dynamic-surface/80 data-[state=open]:text-foreground`}
|
|
87
|
+
>
|
|
88
|
+
<Copy className="size-4" />
|
|
89
|
+
</Toggle>
|
|
90
|
+
</DropdownMenuTrigger>
|
|
91
|
+
<DropdownMenuContent align="end" className="w-72">
|
|
92
|
+
<DropdownMenuLabel className="text-muted-foreground text-xs">
|
|
93
|
+
{copyLabel}
|
|
94
|
+
</DropdownMenuLabel>
|
|
95
|
+
<DropdownMenuSeparator />
|
|
96
|
+
<DropdownMenuItem
|
|
97
|
+
className="items-start gap-3 py-2.5"
|
|
98
|
+
onSelect={() => copyAndClose('markdown')}
|
|
99
|
+
>
|
|
100
|
+
<Code2 className="mt-0.5 size-4 shrink-0 text-dynamic-blue" />
|
|
101
|
+
<span className="min-w-0">
|
|
102
|
+
<span className="block font-medium">
|
|
103
|
+
{labels?.copyAsMarkdown ?? DEFAULT_LABELS.copyAsMarkdown}
|
|
104
|
+
</span>
|
|
105
|
+
<span className="block whitespace-normal text-muted-foreground text-xs leading-relaxed">
|
|
106
|
+
{labels?.markdownDescription ??
|
|
107
|
+
DEFAULT_LABELS.markdownDescription}
|
|
108
|
+
</span>
|
|
109
|
+
</span>
|
|
110
|
+
</DropdownMenuItem>
|
|
111
|
+
<DropdownMenuItem
|
|
112
|
+
className="items-start gap-3 py-2.5"
|
|
113
|
+
onSelect={() => copyAndClose('text')}
|
|
114
|
+
>
|
|
115
|
+
<FileText className="mt-0.5 size-4 shrink-0 text-dynamic-green" />
|
|
116
|
+
<span className="min-w-0">
|
|
117
|
+
<span className="block font-medium">
|
|
118
|
+
{labels?.copyAsPlainText ?? DEFAULT_LABELS.copyAsPlainText}
|
|
119
|
+
</span>
|
|
120
|
+
<span className="block whitespace-normal text-muted-foreground text-xs leading-relaxed">
|
|
121
|
+
{labels?.plainTextDescription ??
|
|
122
|
+
DEFAULT_LABELS.plainTextDescription}
|
|
123
|
+
</span>
|
|
124
|
+
</span>
|
|
125
|
+
</DropdownMenuItem>
|
|
126
|
+
</DropdownMenuContent>
|
|
127
|
+
</DropdownMenu>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -15,6 +15,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
15
15
|
import { flushSync } from 'react-dom';
|
|
16
16
|
import type * as Y from 'yjs';
|
|
17
17
|
import { migrateInlineImagesToBlock } from './content-migration';
|
|
18
|
+
import type { EditorCopyLabels } from './copy-menu';
|
|
18
19
|
import { getRichTextEditorClasses } from './editor-classes';
|
|
19
20
|
import { getEditorExtensions } from './extensions';
|
|
20
21
|
import { handleListIndentation, handlePlainEnterFallback } from './keyboard';
|
|
@@ -129,7 +130,9 @@ export interface RichTextEditorProps {
|
|
|
129
130
|
create_project?: string;
|
|
130
131
|
};
|
|
131
132
|
renderTaskMention?: TaskMentionNodeViewRenderer;
|
|
133
|
+
toolbarLeadingContent?: React.ReactNode;
|
|
132
134
|
toggleBlockLabel?: string;
|
|
135
|
+
copyLabels?: EditorCopyLabels;
|
|
133
136
|
}
|
|
134
137
|
|
|
135
138
|
export function RichTextEditor({
|
|
@@ -158,7 +161,9 @@ export function RichTextEditor({
|
|
|
158
161
|
revealToolbarOnFocus = false,
|
|
159
162
|
mentionTranslations,
|
|
160
163
|
renderTaskMention,
|
|
164
|
+
toolbarLeadingContent,
|
|
161
165
|
toggleBlockLabel,
|
|
166
|
+
copyLabels,
|
|
162
167
|
}: RichTextEditorProps) {
|
|
163
168
|
// Use refs to ensure we have stable references for handlers
|
|
164
169
|
const onImageUploadRef = useRef(onImageUpload);
|
|
@@ -635,7 +640,9 @@ export function RichTextEditor({
|
|
|
635
640
|
workspaceId={workspaceId}
|
|
636
641
|
onImageUpload={onImageUpload}
|
|
637
642
|
onConvertToTask={onConvertToTask}
|
|
643
|
+
leadingContent={toolbarLeadingContent}
|
|
638
644
|
toggleBlockLabel={toggleBlockLabel}
|
|
645
|
+
copyLabels={copyLabels}
|
|
639
646
|
/>
|
|
640
647
|
)}
|
|
641
648
|
{!readOnly && (
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view';
|
|
8
8
|
import { toast } from '@tuturuuu/ui/sonner';
|
|
9
9
|
import ImageResize from 'tiptap-extension-resize-image';
|
|
10
|
+
import { getClipboardImageFiles } from './clipboard-image-files';
|
|
10
11
|
import {
|
|
11
12
|
formatBytes,
|
|
12
13
|
getImageDimensions,
|
|
@@ -85,14 +86,6 @@ function resolveUploadHandler({
|
|
|
85
86
|
return configuredHandler;
|
|
86
87
|
}
|
|
87
88
|
|
|
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
|
-
|
|
96
89
|
export const __imageExtensionPrivate = {
|
|
97
90
|
clearImageResizeUIFromNodeDom,
|
|
98
91
|
getSelectedImagePos,
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model';
|
|
2
2
|
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
|
3
3
|
import { Extension } from '@tiptap/react';
|
|
4
|
+
import {
|
|
5
|
+
collapseExcessBlankLines,
|
|
6
|
+
serializeClipboardText,
|
|
7
|
+
} from './clipboard-serialization';
|
|
4
8
|
|
|
5
9
|
// ---------------------------------------------------------------------------
|
|
6
10
|
// Markdown -> HTML converter (toolbar-supported features only)
|
|
@@ -31,8 +35,7 @@ const VISUAL_BULLET_PATTERN = /^(\s*)[•◦▪‣●]\s+/u;
|
|
|
31
35
|
const VISUAL_CHECKBOX_PATTERN = /^(\s*)[☐☑☒]\s+/u;
|
|
32
36
|
|
|
33
37
|
function normalizePastedPlainText(text: string): string {
|
|
34
|
-
return text
|
|
35
|
-
.replace(/\r\n?/g, '\n')
|
|
38
|
+
return collapseExcessBlankLines(text)
|
|
36
39
|
.split('\n')
|
|
37
40
|
.map((line) => {
|
|
38
41
|
const checkboxMatch = line.match(VISUAL_CHECKBOX_PATTERN);
|
|
@@ -214,10 +217,7 @@ function parseListTree(
|
|
|
214
217
|
|
|
215
218
|
while (i < lines.length) {
|
|
216
219
|
const line = lines[i];
|
|
217
|
-
if (line === undefined || line.trim() === '')
|
|
218
|
-
i++;
|
|
219
|
-
continue;
|
|
220
|
-
}
|
|
220
|
+
if (line === undefined || line.trim() === '') break;
|
|
221
221
|
|
|
222
222
|
const match = line.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/);
|
|
223
223
|
if (!match) break;
|
|
@@ -329,8 +329,15 @@ function renderListTree(nodes: ListNode[], ordered: boolean): string {
|
|
|
329
329
|
const tag = ordered ? 'ol' : 'ul';
|
|
330
330
|
const isTaskList = nodes.some((n) => n.checked !== null);
|
|
331
331
|
const dataType = isTaskList ? ' data-type="taskList"' : '';
|
|
332
|
+
const orderedStart = ordered
|
|
333
|
+
? Number.parseInt(nodes[0]?.marker.replace('.', '') ?? '1', 10)
|
|
334
|
+
: 1;
|
|
335
|
+
const startAttribute =
|
|
336
|
+
ordered && Number.isFinite(orderedStart) && orderedStart !== 1
|
|
337
|
+
? ` start="${orderedStart}"`
|
|
338
|
+
: '';
|
|
332
339
|
|
|
333
|
-
let html = `<${tag}${dataType}>`;
|
|
340
|
+
let html = `<${tag}${startAttribute}${dataType}>`;
|
|
334
341
|
|
|
335
342
|
for (const node of nodes) {
|
|
336
343
|
if (isTaskList) {
|
|
@@ -552,6 +559,19 @@ function looksLikeMarkdown(text: string): boolean {
|
|
|
552
559
|
return MARKDOWN_SIGNATURES.some((pattern) => pattern.test(text));
|
|
553
560
|
}
|
|
554
561
|
|
|
562
|
+
const SEMANTIC_RICH_TEXT_TAG_PATTERN =
|
|
563
|
+
/<(?:a|b|blockquote|code|details|em|h[1-6]|i|img|li|mark|ol|pre|s|strong|summary|table|tbody|td|th|thead|tr|ul)\b/i;
|
|
564
|
+
|
|
565
|
+
function shouldConvertPastedText({
|
|
566
|
+
html,
|
|
567
|
+
text,
|
|
568
|
+
}: {
|
|
569
|
+
html: string;
|
|
570
|
+
text: string;
|
|
571
|
+
}): boolean {
|
|
572
|
+
return looksLikeMarkdown(text) && !SEMANTIC_RICH_TEXT_TAG_PATTERN.test(html);
|
|
573
|
+
}
|
|
574
|
+
|
|
555
575
|
// ---------------------------------------------------------------------------
|
|
556
576
|
// Test-only exports (not part of the public API contract)
|
|
557
577
|
// ---------------------------------------------------------------------------
|
|
@@ -560,6 +580,7 @@ export const __markdownPastePrivate = {
|
|
|
560
580
|
markdownToHtml,
|
|
561
581
|
looksLikeMarkdown,
|
|
562
582
|
normalizePastedPlainText,
|
|
583
|
+
shouldConvertPastedText,
|
|
563
584
|
};
|
|
564
585
|
|
|
565
586
|
const markdownPastePluginKey = new PluginKey('markdownPastePlugin');
|
|
@@ -577,6 +598,7 @@ export const MarkdownPaste = Extension.create({
|
|
|
577
598
|
new Plugin({
|
|
578
599
|
key: markdownPastePluginKey,
|
|
579
600
|
props: {
|
|
601
|
+
clipboardTextSerializer: serializeClipboardText,
|
|
580
602
|
handleDOMEvents: {
|
|
581
603
|
paste: (
|
|
582
604
|
view: import('@tiptap/pm/view').EditorView,
|
|
@@ -593,24 +615,27 @@ export const MarkdownPaste = Extension.create({
|
|
|
593
615
|
const text = normalizePastedPlainText(
|
|
594
616
|
clipboardData.getData('text/plain')
|
|
595
617
|
);
|
|
596
|
-
|
|
618
|
+
const clipboardHtml = clipboardData.getData('text/html');
|
|
619
|
+
if (
|
|
620
|
+
!text ||
|
|
621
|
+
!shouldConvertPastedText({ html: clipboardHtml, text })
|
|
622
|
+
) {
|
|
597
623
|
return false;
|
|
598
624
|
}
|
|
599
625
|
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
// text paragraphs; we prefer structured conversion instead.
|
|
626
|
+
// Convert Markdown from plain text when HTML is absent or only a
|
|
627
|
+
// visual wrapper. Genuine rich HTML keeps its headings, lists,
|
|
628
|
+
// inline emphasis, links, media, and paragraph boundaries.
|
|
604
629
|
event.preventDefault();
|
|
605
630
|
|
|
606
|
-
const
|
|
631
|
+
const generatedHtml = markdownToHtml(text);
|
|
607
632
|
const { state } = view;
|
|
608
633
|
const { from, to } = state.selection;
|
|
609
634
|
|
|
610
635
|
// Parse the generated HTML into a ProseMirror slice
|
|
611
636
|
const browserParser = new DOMParser();
|
|
612
637
|
const dom = browserParser.parseFromString(
|
|
613
|
-
`<div>${
|
|
638
|
+
`<div>${generatedHtml}</div>`,
|
|
614
639
|
'text/html'
|
|
615
640
|
);
|
|
616
641
|
const firstChild = dom.body.firstChild;
|
|
@@ -32,76 +32,20 @@ import {
|
|
|
32
32
|
import { Button } from '@tuturuuu/ui/button';
|
|
33
33
|
import { Input } from '@tuturuuu/ui/input';
|
|
34
34
|
import { toast } from '@tuturuuu/ui/sonner';
|
|
35
|
-
import { Toggle } from '@tuturuuu/ui/toggle';
|
|
36
|
-
import { Tooltip, TooltipContent, TooltipTrigger } from '@tuturuuu/ui/tooltip';
|
|
37
35
|
import { cn } from '@tuturuuu/utils/format';
|
|
38
36
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
39
37
|
import { TextEditorColorControls } from './color-controls';
|
|
38
|
+
import { type EditorCopyLabels, EditorCopyMenu } from './copy-menu';
|
|
40
39
|
import {
|
|
41
40
|
MAX_IMAGE_SIZE,
|
|
42
41
|
MAX_VIDEO_SIZE,
|
|
43
42
|
StorageQuotaError,
|
|
44
43
|
} from './media-utils';
|
|
45
|
-
import {
|
|
44
|
+
import { TOOLBAR_GROUPS } from './toolbar-config';
|
|
45
|
+
import { ToolbarButton, ToolbarSeparator } from './toolbar-controls';
|
|
46
46
|
|
|
47
47
|
type LinkEditorContext = 'bubble' | 'popover' | null;
|
|
48
48
|
|
|
49
|
-
// ---------------------------------------------------------------------------
|
|
50
|
-
// Shared sub-components
|
|
51
|
-
// ---------------------------------------------------------------------------
|
|
52
|
-
|
|
53
|
-
interface ToolbarButtonProps {
|
|
54
|
-
id: string;
|
|
55
|
-
label?: string;
|
|
56
|
-
icon: React.ReactNode;
|
|
57
|
-
pressed: boolean;
|
|
58
|
-
onClick: () => void;
|
|
59
|
-
disabled?: boolean;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** A single toolbar toggle button wrapped with a tooltip showing name + hotkey. */
|
|
63
|
-
function ToolbarButton({
|
|
64
|
-
id,
|
|
65
|
-
label: labelOverride,
|
|
66
|
-
icon,
|
|
67
|
-
pressed,
|
|
68
|
-
onClick,
|
|
69
|
-
disabled,
|
|
70
|
-
}: ToolbarButtonProps) {
|
|
71
|
-
const label = labelOverride ?? TOOLBAR_LABELS[id] ?? id;
|
|
72
|
-
const shortcut = hotkeyLabel(id);
|
|
73
|
-
|
|
74
|
-
return (
|
|
75
|
-
<Tooltip>
|
|
76
|
-
<TooltipTrigger asChild>
|
|
77
|
-
<Toggle
|
|
78
|
-
pressed={pressed}
|
|
79
|
-
onPressedChange={() => onClick()}
|
|
80
|
-
onMouseDown={(e) => e.preventDefault()}
|
|
81
|
-
disabled={disabled}
|
|
82
|
-
className="h-8 w-8 rounded-md border border-transparent transition-colors data-[state=on]:border-foreground/10 data-[state=on]:bg-dynamic-surface/80 data-[state=on]:text-foreground"
|
|
83
|
-
aria-label={label}
|
|
84
|
-
>
|
|
85
|
-
{icon}
|
|
86
|
-
</Toggle>
|
|
87
|
-
</TooltipTrigger>
|
|
88
|
-
<TooltipContent side="bottom" className="flex items-center gap-1.5">
|
|
89
|
-
<span>{label}</span>
|
|
90
|
-
{shortcut && (
|
|
91
|
-
<kbd className="rounded bg-foreground/10 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
|
92
|
-
{shortcut}
|
|
93
|
-
</kbd>
|
|
94
|
-
)}
|
|
95
|
-
</TooltipContent>
|
|
96
|
-
</Tooltip>
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** Vertical divider between toolbar groups */
|
|
101
|
-
function ToolbarSeparator() {
|
|
102
|
-
return <div className="mx-0.5 h-5 w-px shrink-0 bg-dynamic-border/60" />;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
49
|
// ---------------------------------------------------------------------------
|
|
106
50
|
// Main ToolBar (BubbleMenu)
|
|
107
51
|
// ---------------------------------------------------------------------------
|
|
@@ -898,35 +842,32 @@ export function ToolBar({
|
|
|
898
842
|
);
|
|
899
843
|
}
|
|
900
844
|
|
|
901
|
-
// ---------------------------------------------------------------------------
|
|
902
|
-
// Fixed Toolbar (always-visible, rendered above the editor)
|
|
903
|
-
// ---------------------------------------------------------------------------
|
|
904
|
-
|
|
905
845
|
interface FixedToolbarProps {
|
|
906
846
|
editor: Editor | null;
|
|
847
|
+
leadingContent?: React.ReactNode;
|
|
907
848
|
workspaceId?: string;
|
|
908
849
|
onImageUpload?: (file: File) => Promise<string>;
|
|
909
850
|
onConvertToTask?: () => void | Promise<void>;
|
|
910
851
|
className?: string;
|
|
911
852
|
ref?: React.Ref<HTMLDivElement>;
|
|
912
853
|
toggleBlockLabel?: string;
|
|
854
|
+
copyLabels?: EditorCopyLabels;
|
|
913
855
|
}
|
|
914
|
-
|
|
915
856
|
export function FixedToolbar({
|
|
916
857
|
editor,
|
|
858
|
+
leadingContent,
|
|
917
859
|
workspaceId,
|
|
918
860
|
onImageUpload,
|
|
919
861
|
onConvertToTask,
|
|
920
862
|
className,
|
|
921
863
|
ref,
|
|
922
864
|
toggleBlockLabel,
|
|
865
|
+
copyLabels,
|
|
923
866
|
}: FixedToolbarProps) {
|
|
924
867
|
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
|
925
868
|
const [isUploadingVideo, setIsUploadingVideo] = useState(false);
|
|
926
869
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
927
870
|
const videoInputRef = useRef<HTMLInputElement>(null);
|
|
928
|
-
|
|
929
|
-
// Build the formatting option map
|
|
930
871
|
const formattingOptions = useMemo(() => {
|
|
931
872
|
if (!editor)
|
|
932
873
|
return new Map<
|
|
@@ -1140,12 +1081,11 @@ export function FixedToolbar({
|
|
|
1140
1081
|
);
|
|
1141
1082
|
|
|
1142
1083
|
if (!editor) return null;
|
|
1143
|
-
|
|
1144
1084
|
return (
|
|
1145
1085
|
<div
|
|
1146
1086
|
ref={ref}
|
|
1147
1087
|
className={cn(
|
|
1148
|
-
'sticky top-0 z-40 flex flex-
|
|
1088
|
+
'@container scrollbar-hide sticky top-0 z-40 flex w-full min-w-0 max-w-full flex-nowrap items-center gap-1 overflow-x-auto overflow-y-hidden overscroll-x-contain whitespace-nowrap rounded-t-md border-dynamic-border border-b bg-background/95 px-2 py-1.5 backdrop-blur-sm',
|
|
1149
1089
|
className
|
|
1150
1090
|
)}
|
|
1151
1091
|
>
|
|
@@ -1163,7 +1103,12 @@ export function FixedToolbar({
|
|
|
1163
1103
|
onChange={handleVideoUpload}
|
|
1164
1104
|
className="hidden"
|
|
1165
1105
|
/>
|
|
1166
|
-
|
|
1106
|
+
{leadingContent ? (
|
|
1107
|
+
<>
|
|
1108
|
+
<div className="shrink-0">{leadingContent}</div>
|
|
1109
|
+
<ToolbarSeparator />
|
|
1110
|
+
</>
|
|
1111
|
+
) : null}
|
|
1167
1112
|
{/* Grouped formatting options with separators */}
|
|
1168
1113
|
{TOOLBAR_GROUPS.map((group, gi) => (
|
|
1169
1114
|
<div key={gi} className="contents">
|
|
@@ -1233,6 +1178,9 @@ export function FixedToolbar({
|
|
|
1233
1178
|
/>
|
|
1234
1179
|
</>
|
|
1235
1180
|
)}
|
|
1181
|
+
|
|
1182
|
+
<ToolbarSeparator />
|
|
1183
|
+
<EditorCopyMenu editor={editor} labels={copyLabels} />
|
|
1236
1184
|
</div>
|
|
1237
1185
|
);
|
|
1238
1186
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Toggle } from '@tuturuuu/ui/toggle';
|
|
2
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from '@tuturuuu/ui/tooltip';
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { hotkeyLabel, TOOLBAR_LABELS } from './toolbar-config';
|
|
5
|
+
|
|
6
|
+
interface ToolbarButtonProps {
|
|
7
|
+
id: string;
|
|
8
|
+
label?: string;
|
|
9
|
+
icon: ReactNode;
|
|
10
|
+
pressed: boolean;
|
|
11
|
+
onClick: () => void;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const TOOLBAR_BUTTON_CLASS_NAME =
|
|
16
|
+
'h-8 w-8 shrink-0 rounded-md border border-transparent transition-colors data-[state=on]:border-foreground/10 data-[state=on]:bg-dynamic-surface/80 data-[state=on]:text-foreground';
|
|
17
|
+
|
|
18
|
+
/** A toolbar toggle with an accessible label and optional hotkey hint. */
|
|
19
|
+
export function ToolbarButton({
|
|
20
|
+
id,
|
|
21
|
+
label: labelOverride,
|
|
22
|
+
icon,
|
|
23
|
+
pressed,
|
|
24
|
+
onClick,
|
|
25
|
+
disabled,
|
|
26
|
+
}: ToolbarButtonProps) {
|
|
27
|
+
const label = labelOverride ?? TOOLBAR_LABELS[id] ?? id;
|
|
28
|
+
const shortcut = hotkeyLabel(id);
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<Tooltip>
|
|
32
|
+
<TooltipTrigger asChild>
|
|
33
|
+
<Toggle
|
|
34
|
+
pressed={pressed}
|
|
35
|
+
onPressedChange={() => onClick()}
|
|
36
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
37
|
+
disabled={disabled}
|
|
38
|
+
className={TOOLBAR_BUTTON_CLASS_NAME}
|
|
39
|
+
aria-label={label}
|
|
40
|
+
>
|
|
41
|
+
{icon}
|
|
42
|
+
</Toggle>
|
|
43
|
+
</TooltipTrigger>
|
|
44
|
+
<TooltipContent side="bottom" className="flex items-center gap-1.5">
|
|
45
|
+
<span>{label}</span>
|
|
46
|
+
{shortcut ? (
|
|
47
|
+
<kbd className="rounded bg-foreground/10 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
|
48
|
+
{shortcut}
|
|
49
|
+
</kbd>
|
|
50
|
+
) : null}
|
|
51
|
+
</TooltipContent>
|
|
52
|
+
</Tooltip>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Vertical divider between toolbar groups. */
|
|
57
|
+
export function ToolbarSeparator() {
|
|
58
|
+
return <div className="mx-0.5 h-5 w-px shrink-0 bg-dynamic-border/60" />;
|
|
59
|
+
}
|