@tuturuuu/ui 0.37.2 → 0.39.0

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 (126) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/package.json +10 -7
  3. package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +86 -0
  4. package/src/components/ui/custom/education-todo.test.tsx +131 -0
  5. package/src/components/ui/custom/education-todo.tsx +191 -0
  6. package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
  7. package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
  8. package/src/components/ui/custom/workspace-picker-content.tsx +93 -30
  9. package/src/components/ui/custom/workspace-select.tsx +12 -4
  10. package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
  11. package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
  12. package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
  13. package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
  14. package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
  15. package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
  16. package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
  17. package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
  18. package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
  19. package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
  20. package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
  21. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
  22. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
  23. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
  24. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  25. package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
  26. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
  27. package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
  28. package/src/components/ui/finance/invoices/hooks.ts +3 -100
  29. package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
  30. package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
  31. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
  32. package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
  33. package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
  34. package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
  35. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
  36. package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
  37. package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
  38. package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
  39. package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
  40. package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
  41. package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
  42. package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
  43. package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
  44. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
  45. package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
  46. package/src/components/ui/finance/invoices/types.ts +3 -3
  47. package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
  48. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
  49. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
  50. package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
  51. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
  52. package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
  53. package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
  54. package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
  55. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
  56. package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
  57. package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
  58. package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
  59. package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1614
  60. package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
  61. package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
  62. package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
  63. package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
  64. package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
  65. package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
  66. package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
  67. package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
  68. package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
  69. package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
  70. package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
  71. package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
  72. package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
  73. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
  74. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
  75. package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
  76. package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
  77. package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
  78. package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
  79. package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
  80. package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
  81. package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
  82. package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
  83. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
  84. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
  85. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
  86. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
  87. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
  88. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
  89. package/src/components/ui/popover.tsx +3 -1
  90. package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
  91. package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
  92. package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
  93. package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
  94. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
  95. package/src/components/ui/text-editor/editor.tsx +11 -3
  96. package/src/components/ui/text-editor/extensions.ts +8 -2
  97. package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
  98. package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
  99. package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
  100. package/src/components/ui/text-editor/markdown.ts +11 -0
  101. package/src/components/ui/text-editor/tool-bar.tsx +45 -102
  102. package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
  103. package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
  104. package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
  105. package/src/hooks/__tests__/supabase-provider.test.ts +110 -7
  106. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +39 -2
  107. package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
  108. package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
  109. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
  110. package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
  111. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +147 -0
  112. package/src/hooks/calendar-connection-types.ts +49 -0
  113. package/src/hooks/calendar-creation-request.test.ts +62 -0
  114. package/src/hooks/calendar-creation-request.ts +34 -0
  115. package/src/hooks/calendar-event-update-queue-types.ts +12 -0
  116. package/src/hooks/calendar-event-write-payload.ts +57 -0
  117. package/src/hooks/collaboration-document-hydration.ts +35 -0
  118. package/src/hooks/notification-subscription.ts +5 -2
  119. package/src/hooks/supabase-provider.ts +76 -112
  120. package/src/hooks/use-calendar-sync.tsx +41 -62
  121. package/src/hooks/use-calendar.tsx +44 -82
  122. package/src/hooks/use-exchange-rates.ts +4 -15
  123. package/src/hooks/use-local-storage.ts +9 -11
  124. package/src/hooks/use-native-calendar-occurrences.ts +85 -0
  125. package/src/hooks/use-workspace-presence.ts +9 -9
  126. package/src/hooks/use-yjs-collaboration.ts +68 -27
@@ -0,0 +1,50 @@
1
+ import { Editor } from '@tiptap/core';
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { getEditorExtensions } from '../extensions';
4
+ import { parseEditorMarkdown, serializeEditorMarkdown } from '../markdown';
5
+
6
+ const editors: Editor[] = [];
7
+ function editor(markdown: string) {
8
+ const value = new Editor({
9
+ extensions: getEditorExtensions(),
10
+ content: parseEditorMarkdown(markdown),
11
+ });
12
+ editors.push(value);
13
+ return value;
14
+ }
15
+ afterEach(() => {
16
+ for (const value of editors.splice(0)) value.destroy();
17
+ });
18
+ describe('Full document Markdown editing', () => {
19
+ it('round-trips headings, bold, links, code, and lists through the editor schema', () => {
20
+ const source =
21
+ '# Synthetic chapter\n\n**A hero** meets [a friend](https://example.test).\n\n- First scene\n- Second scene\n\n```ts\nconst year = -10;\n```';
22
+ const first = editor(source);
23
+ const markdown = serializeEditorMarkdown(first);
24
+ const second = editor(markdown);
25
+ expect(second.getJSON()).toEqual(first.getJSON());
26
+ expect(markdown).toContain('**A hero**');
27
+ expect(markdown).toContain('https://example.test');
28
+ expect(markdown).toContain('const year = -10;');
29
+ });
30
+ it('preserves tables, task states, and artwork through source editing', () => {
31
+ const first = editor(
32
+ '| Name | Role |\n| --- | --- |\n| Hero | Guide |\n\n- [x] Cross the river\n- [ ] Return home\n\n![Synthetic artwork](https://example.test/art.png)'
33
+ );
34
+ const second = editor(serializeEditorMarkdown(first));
35
+ const json = second.getJSON();
36
+ expect(JSON.stringify(json)).toContain('table');
37
+ expect(JSON.stringify(json)).toContain('"checked":true');
38
+ expect(JSON.stringify(json)).toContain('"checked":false');
39
+ expect(JSON.stringify(json)).toContain('https://example.test/art.png');
40
+ expect(second.getText()).toContain('Guide');
41
+ });
42
+ it('keeps executable HTML and unsafe links out of the actual editor document', () => {
43
+ const value = editor(
44
+ '<script>alert(1)</script>\n\n[Unsafe](javascript:alert)'
45
+ );
46
+ const html = value.getHTML();
47
+ expect(html).not.toContain('<script>');
48
+ expect(html).not.toContain('href="javascript:');
49
+ });
50
+ });
@@ -187,6 +187,16 @@ function serializeBlock(
187
187
  return '---';
188
188
  case 'table':
189
189
  return serializeTable(node, format);
190
+ case 'image':
191
+ case 'imageResize': {
192
+ const src = typeof node.attrs.src === 'string' ? node.attrs.src : '';
193
+ const alt = typeof node.attrs.alt === 'string' ? node.attrs.alt : '';
194
+ return format === 'markdown'
195
+ ? src
196
+ ? `![${alt}](${src})`
197
+ : alt
198
+ : [alt || 'Image', src].filter(Boolean).join(': ');
199
+ }
190
200
  case 'video':
191
201
  return node.attrs.src
192
202
  ? format === 'markdown'
@@ -0,0 +1,56 @@
1
+ type CaretUser = { id?: unknown; name?: unknown; color?: unknown };
2
+ function identity(user: CaretUser) {
3
+ return typeof user.id === 'string' ? user.id.slice(0, 100) : '';
4
+ }
5
+ function color(user: CaretUser) {
6
+ return typeof user.color === 'string' && /^#[0-9a-f]{6}$/i.test(user.color)
7
+ ? user.color
8
+ : 'var(--primary)';
9
+ }
10
+ /** Self-contained caret styling works in every editor surface, including Meet. */
11
+ export function renderCollaborationCaret(user: CaretUser) {
12
+ const caret = document.createElement('span');
13
+ caret.className = 'collaboration-carets__caret';
14
+ caret.dataset.userId = identity(user);
15
+ Object.assign(caret.style, {
16
+ borderLeft: `2px solid ${color(user)}`,
17
+ position: 'relative',
18
+ marginLeft: '-1px',
19
+ pointerEvents: 'none',
20
+ });
21
+ const label = document.createElement('span');
22
+ label.className = 'collaboration-carets__label';
23
+ label.dataset.userId = identity(user);
24
+ label.textContent =
25
+ typeof user.name === 'string' ? user.name.slice(0, 100) : '';
26
+ Object.assign(label.style, {
27
+ position: 'absolute',
28
+ top: '-1.4em',
29
+ left: '-2px',
30
+ padding: '1px 4px',
31
+ borderRadius: '3px 3px 3px 0',
32
+ backgroundColor: color(user),
33
+ color: 'var(--primary-foreground)',
34
+ fontSize: '11px',
35
+ lineHeight: '1.2',
36
+ whiteSpace: 'nowrap',
37
+ userSelect: 'none',
38
+ });
39
+ caret.append(label);
40
+ return caret;
41
+ }
42
+ export function renderCollaborationSelection(user: CaretUser) {
43
+ return {
44
+ nodeName: 'span',
45
+ class: 'collaboration-carets__selection',
46
+ 'data-user-id': identity(user),
47
+ style: `background-color: color-mix(in srgb, ${color(user)} 24%, transparent);`,
48
+ };
49
+ }
50
+ export function scrollToCollaborationCaret(root: HTMLElement, userId: string) {
51
+ const caret = [
52
+ ...root.querySelectorAll<HTMLElement>('.collaboration-carets__caret'),
53
+ ].find((element) => element.dataset.userId === userId);
54
+ caret?.scrollIntoView({ behavior: 'smooth', block: 'center' });
55
+ return !!caret;
56
+ }
@@ -53,6 +53,7 @@ it('removes click-blocking classes after sync without recreating the editor', as
53
53
  await act(async () => {});
54
54
  await waitFor(() => expect(editorRef.current).not.toBeNull());
55
55
  const original = editorRef.current!;
56
+ expect(onImmediateChange).not.toHaveBeenCalled();
56
57
  expect(original.view.dom.className).toContain(
57
58
  '[&_.task-list-checkbox]:!pointer-events-none'
58
59
  );
@@ -67,6 +68,7 @@ it('removes click-blocking classes after sync without recreating the editor', as
67
68
  await act(async () => {});
68
69
  expect(editorRef.current).toBe(original);
69
70
  expect(original.isEditable).toBe(true);
71
+ expect(onImmediateChange).not.toHaveBeenCalled();
70
72
  expect(original.view.dom.className).not.toContain(
71
73
  '[&_.task-list-checkbox]:!pointer-events-none'
72
74
  );
@@ -9,10 +9,10 @@ import {
9
9
  useEditor,
10
10
  } from '@tiptap/react';
11
11
  import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
12
- import type SupabaseProvider from '@tuturuuu/ui/hooks/supabase-provider';
13
12
  import { debounce } from 'lodash';
14
13
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
14
  import { flushSync } from 'react-dom';
15
+ import type { Awareness } from 'y-protocols/awareness';
16
16
  import type * as Y from 'yjs';
17
17
  import { migrateInlineImagesToBlock } from './content-migration';
18
18
  import type { EditorCopyLabels } from './copy-menu';
@@ -84,6 +84,7 @@ export interface RichTextEditorProps {
84
84
  savedButtonLabel?: string;
85
85
  className?: string;
86
86
  workspaceId?: string;
87
+ allowEmbeds?: boolean;
87
88
  onImageUpload?: (file: File) => Promise<string>;
88
89
  flushPendingRef?: { current: (() => JSONContent | null) | undefined };
89
90
  onArrowUp?: (cursorOffset?: number) => void;
@@ -92,7 +93,7 @@ export interface RichTextEditorProps {
92
93
  initialCursorOffset?: number | null;
93
94
  onEditorReady?: (editor: Editor) => void;
94
95
  yjsDoc?: Y.Doc;
95
- yjsProvider?: SupabaseProvider;
96
+ yjsProvider?: { awareness: Awareness };
96
97
  /** User info for collaboration cursor labels. */
97
98
  collaborationUser?: { id?: string; name: string; color: string };
98
99
  boardId?: string;
@@ -131,6 +132,7 @@ export interface RichTextEditorProps {
131
132
  };
132
133
  renderTaskMention?: TaskMentionNodeViewRenderer;
133
134
  toolbarLeadingContent?: React.ReactNode;
135
+ toolbarToolsLabel?: string;
134
136
  toggleBlockLabel?: string;
135
137
  copyLabels?: EditorCopyLabels;
136
138
  }
@@ -158,10 +160,12 @@ export function RichTextEditor({
158
160
  yjsProvider,
159
161
  collaborationUser,
160
162
  allowCollaboration = false,
163
+ allowEmbeds = true,
161
164
  revealToolbarOnFocus = false,
162
165
  mentionTranslations,
163
166
  renderTaskMention,
164
167
  toolbarLeadingContent,
168
+ toolbarToolsLabel,
165
169
  toggleBlockLabel,
166
170
  copyLabels,
167
171
  }: RichTextEditorProps) {
@@ -453,7 +457,7 @@ export function RichTextEditor({
453
457
  attributes: { class: getEditorClasses },
454
458
  },
455
459
  });
456
- editor.setEditable(!readOnly);
460
+ editor.setEditable(!readOnly, false);
457
461
  }, [editor, readOnly, getEditorClasses]);
458
462
 
459
463
  // Update editor content when the content prop changes externally
@@ -649,6 +653,7 @@ export function RichTextEditor({
649
653
  onImageUpload={onImageUpload}
650
654
  onConvertToTask={onConvertToTask}
651
655
  leadingContent={toolbarLeadingContent}
656
+ toolsLabel={toolbarToolsLabel}
652
657
  toggleBlockLabel={toggleBlockLabel}
653
658
  copyLabels={copyLabels}
654
659
  />
@@ -657,6 +662,7 @@ export function RichTextEditor({
657
662
  <ToolBar
658
663
  editor={editor}
659
664
  fixedToolbarVisible={fixedToolbarVisible}
665
+ allowEmbeds={allowEmbeds}
660
666
  saveButtonLabel={saveButtonLabel}
661
667
  savedButtonLabel={savedButtonLabel}
662
668
  workspaceId={workspaceId}
@@ -671,3 +677,5 @@ export function RichTextEditor({
671
677
  </div>
672
678
  );
673
679
  }
680
+
681
+ export { parseEditorMarkdown, serializeEditorMarkdown } from './markdown';
@@ -19,9 +19,13 @@ import { TextStyle } from '@tiptap/extension-text-style';
19
19
  import Youtube from '@tiptap/extension-youtube';
20
20
  import type { Extensions } from '@tiptap/react';
21
21
  import StarterKit from '@tiptap/starter-kit';
22
- import type SupabaseProvider from '@tuturuuu/ui/hooks/supabase-provider';
22
+ import type { Awareness } from 'y-protocols/awareness';
23
23
  import type * as Y from 'yjs';
24
24
  import { BackgroundColor } from './background-color-extension';
25
+ import {
26
+ renderCollaborationCaret,
27
+ renderCollaborationSelection,
28
+ } from './collaboration-carets';
25
29
  import { ThemeAwareHighlight } from './highlight-extension';
26
30
  import { CustomImage } from './image-extension';
27
31
  import { ListConverter } from './list-converter-extension';
@@ -38,7 +42,7 @@ interface EditorExtensionsOptions {
38
42
  titlePlaceholder?: string;
39
43
  writePlaceholder?: string;
40
44
  doc?: Y.Doc;
41
- provider?: SupabaseProvider;
45
+ provider?: { awareness: Awareness };
42
46
  /** User info for CollaborationCaret labels (name shown on remote cursors). */
43
47
  collaborationUser?: { id?: string; name: string; color: string };
44
48
  onImageUpload?: (file: File) => Promise<string>;
@@ -98,6 +102,8 @@ export function getEditorExtensions({
98
102
  CollaborationCaret.configure({
99
103
  provider,
100
104
  user: collaborationUser,
105
+ render: renderCollaborationCaret,
106
+ selectionRender: renderCollaborationSelection,
101
107
  }),
102
108
  ]
103
109
  : []),
@@ -0,0 +1,59 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import type { Editor } from '@tiptap/react';
3
+ import { TooltipProvider } from '@tuturuuu/ui/tooltip';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { FixedToolbarContents } from './fixed-toolbar-contents';
6
+
7
+ vi.mock('./color-controls', () => ({
8
+ TextEditorColorControls: () => <button type="button">Colors</button>,
9
+ }));
10
+ vi.mock('./copy-menu', () => ({
11
+ EditorCopyMenu: () => <button type="button">Copy tools</button>,
12
+ }));
13
+
14
+ function mount(toolsLabel?: string) {
15
+ const heading = vi.fn();
16
+ const bold = vi.fn();
17
+ const options = new Map([
18
+ ['bold', { icon: <span>B</span>, pressed: false, onClick: bold }],
19
+ ['heading-1', { icon: <span>H1</span>, pressed: false, onClick: heading }],
20
+ ]);
21
+ render(
22
+ <TooltipProvider>
23
+ <FixedToolbarContents
24
+ editor={{} as Editor}
25
+ options={options}
26
+ toolsLabel={toolsLabel}
27
+ imageUpload={false}
28
+ videoUpload={false}
29
+ uploadingImage={false}
30
+ uploadingVideo={false}
31
+ onImage={vi.fn()}
32
+ onVideo={vi.fn()}
33
+ />
34
+ </TooltipProvider>
35
+ );
36
+ return { heading, bold };
37
+ }
38
+
39
+ describe('optional compact document toolbar', () => {
40
+ it('retains the existing full toolbar by default', () => {
41
+ const { heading } = mount();
42
+ expect(screen.queryByRole('button', { name: 'Tools' })).toBeNull();
43
+ fireEvent.click(screen.getByRole('button', { name: 'Heading 1' }));
44
+ expect(heading).toHaveBeenCalledOnce();
45
+ expect(screen.getByRole('button', { name: 'Colors' })).toBeTruthy();
46
+ });
47
+ it('keeps primary formatting reachable and places secondary controls in Tools', () => {
48
+ const { heading, bold } = mount('Tools');
49
+ expect(screen.queryByRole('button', { name: 'Heading 1' })).toBeNull();
50
+ expect(screen.queryByRole('button', { name: 'Colors' })).toBeNull();
51
+ fireEvent.click(screen.getByRole('button', { name: 'Bold' }));
52
+ expect(bold).toHaveBeenCalledOnce();
53
+ fireEvent.click(screen.getByRole('button', { name: 'Tools' }));
54
+ fireEvent.click(screen.getByRole('button', { name: 'Heading 1' }));
55
+ expect(heading).toHaveBeenCalledOnce();
56
+ expect(screen.getByRole('button', { name: 'Colors' })).toBeTruthy();
57
+ expect(screen.getByRole('button', { name: 'Copy tools' })).toBeTruthy();
58
+ });
59
+ });
@@ -0,0 +1,156 @@
1
+ import type { Editor } from '@tiptap/react';
2
+ import {
3
+ CirclePlus,
4
+ FileVideo,
5
+ ImageIcon,
6
+ Loader2,
7
+ Settings2,
8
+ } from '@tuturuuu/icons';
9
+ import { Button } from '@tuturuuu/ui/button';
10
+ import { Popover, PopoverContent, PopoverTrigger } from '@tuturuuu/ui/popover';
11
+ import type { ReactNode } from 'react';
12
+ import { TextEditorColorControls } from './color-controls';
13
+ import { type EditorCopyLabels, EditorCopyMenu } from './copy-menu';
14
+ import { TOOLBAR_GROUPS } from './toolbar-config';
15
+ import { ToolbarButton, ToolbarSeparator } from './toolbar-controls';
16
+
17
+ interface Props {
18
+ editor: Editor;
19
+ options: Map<
20
+ string,
21
+ { onClick: () => void; pressed: boolean; icon: ReactNode }
22
+ >;
23
+ toolsLabel?: string;
24
+ toggleBlockLabel?: string;
25
+ copyLabels?: EditorCopyLabels;
26
+ imageUpload: boolean;
27
+ videoUpload: boolean;
28
+ uploadingImage: boolean;
29
+ uploadingVideo: boolean;
30
+ onImage: () => void;
31
+ onVideo: () => void;
32
+ onConvertToTask?: () => void | Promise<void>;
33
+ }
34
+
35
+ /** Optional compact presentation; existing editors keep the full toolbar. */
36
+ export function FixedToolbarContents({
37
+ editor,
38
+ options,
39
+ toolsLabel,
40
+ toggleBlockLabel,
41
+ copyLabels,
42
+ imageUpload,
43
+ videoUpload,
44
+ uploadingImage,
45
+ uploadingVideo,
46
+ onImage,
47
+ onVideo,
48
+ onConvertToTask,
49
+ }: Props) {
50
+ function control(key: string) {
51
+ const option = options.get(key);
52
+ if (!option) return null;
53
+ return (
54
+ <ToolbarButton
55
+ key={key}
56
+ id={key}
57
+ label={key === 'toggle-block' ? toggleBlockLabel : undefined}
58
+ icon={option.icon}
59
+ pressed={option.pressed}
60
+ onClick={option.onClick}
61
+ />
62
+ );
63
+ }
64
+ const primary = new Set(['bold', 'italic', 'strike']);
65
+ const groups = TOOLBAR_GROUPS.map((group, index) => {
66
+ const keys = toolsLabel ? group.filter((key) => !primary.has(key)) : group;
67
+ if (!keys.length) return null;
68
+ return (
69
+ <div
70
+ key={index}
71
+ className={toolsLabel ? 'flex items-center gap-1' : 'contents'}
72
+ >
73
+ {!toolsLabel && index > 0 && <ToolbarSeparator />}
74
+ {keys.map(control)}
75
+ </div>
76
+ );
77
+ });
78
+ const secondary = (
79
+ <>
80
+ {groups}
81
+ <ToolbarSeparator />
82
+ <TextEditorColorControls editor={editor} />
83
+ {imageUpload && (
84
+ <>
85
+ <ToolbarSeparator />
86
+ <ToolbarButton
87
+ id="image"
88
+ icon={
89
+ uploadingImage ? (
90
+ <Loader2 className="size-4 animate-spin" />
91
+ ) : (
92
+ <ImageIcon className="size-4" />
93
+ )
94
+ }
95
+ pressed={false}
96
+ onClick={onImage}
97
+ disabled={uploadingImage}
98
+ />
99
+ {videoUpload && (
100
+ <ToolbarButton
101
+ id="video"
102
+ icon={
103
+ uploadingVideo ? (
104
+ <Loader2 className="size-4 animate-spin" />
105
+ ) : (
106
+ <FileVideo className="size-4" />
107
+ )
108
+ }
109
+ pressed={false}
110
+ onClick={onVideo}
111
+ disabled={uploadingVideo}
112
+ />
113
+ )}
114
+ </>
115
+ )}
116
+ {onConvertToTask && (
117
+ <>
118
+ <ToolbarSeparator />
119
+ <ToolbarButton
120
+ id="convert-to-task"
121
+ icon={<CirclePlus className="size-4" />}
122
+ pressed={false}
123
+ onClick={onConvertToTask}
124
+ />
125
+ </>
126
+ )}
127
+ <ToolbarSeparator />
128
+ <EditorCopyMenu editor={editor} labels={copyLabels} />
129
+ </>
130
+ );
131
+ if (!toolsLabel) return secondary;
132
+ return (
133
+ <>
134
+ {[...primary].map(control)}
135
+ <Popover>
136
+ <PopoverTrigger asChild>
137
+ <Button
138
+ type="button"
139
+ variant="ghost"
140
+ size="sm"
141
+ className="h-8 shrink-0"
142
+ >
143
+ <Settings2 className="size-4" aria-hidden="true" />
144
+ {toolsLabel}
145
+ </Button>
146
+ </PopoverTrigger>
147
+ <PopoverContent
148
+ align="start"
149
+ className="flex w-80 max-w-[calc(100vw-2rem)] flex-wrap items-center gap-1"
150
+ >
151
+ {secondary}
152
+ </PopoverContent>
153
+ </Popover>
154
+ </>
155
+ );
156
+ }
@@ -421,7 +421,7 @@ function parseTable(
421
421
  return { html, endIndex: i };
422
422
  }
423
423
 
424
- function markdownToHtml(markdown: string): string {
424
+ export function markdownToHtml(markdown: string): string {
425
425
  const lines = markdown.split('\n');
426
426
  const result: string[] = [];
427
427
  let i = 0;
@@ -0,0 +1,11 @@
1
+ import { Slice } from '@tiptap/pm/model';
2
+ import type { Editor } from '@tiptap/react';
3
+ import { serializeClipboardText } from './clipboard-serialization';
4
+ import { markdownToHtml } from './markdown-paste-extension';
5
+
6
+ /** The same sanitized Markdown parser used by editor paste. */
7
+ export const parseEditorMarkdown = markdownToHtml;
8
+ /** Export a complete document using the in-house clipboard serializer. */
9
+ export function serializeEditorMarkdown(editor: Editor) {
10
+ return serializeClipboardText(new Slice(editor.state.doc.content, 0, 0));
11
+ }