@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.
@@ -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
- if (!text || !looksLikeMarkdown(text)) {
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
- // When plain text looks like markdown, convert it to editor nodes
601
- // even if HTML is also present on the clipboard. Many apps wrap
602
- // markdown in simple HTML tags that Tiptap would insert as plain
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 html = markdownToHtml(text);
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>${html}</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 { hotkeyLabel, TOOLBAR_GROUPS, TOOLBAR_LABELS } from './toolbar-config';
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-wrap items-center gap-1 rounded-t-md border-dynamic-border border-b bg-background/95 px-2 py-1.5 backdrop-blur-sm',
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
+ }