@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.
- package/CHANGELOG.md +55 -0
- package/package.json +10 -7
- package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +86 -0
- package/src/components/ui/custom/education-todo.test.tsx +131 -0
- package/src/components/ui/custom/education-todo.tsx +191 -0
- package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
- package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
- package/src/components/ui/custom/workspace-picker-content.tsx +93 -30
- package/src/components/ui/custom/workspace-select.tsx +12 -4
- package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
- package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
- package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
- package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
- package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
- package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
- package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
- package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
- package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
- package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
- package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
- package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
- package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
- package/src/components/ui/finance/invoices/hooks.ts +3 -100
- package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
- package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
- package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
- package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
- package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
- package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
- package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
- package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
- package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
- package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
- package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
- package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
- package/src/components/ui/finance/invoices/types.ts +3 -3
- package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
- package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
- package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
- package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
- package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
- package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
- package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
- package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
- package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
- package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
- package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1614
- package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
- package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
- package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
- package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
- package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
- package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
- package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
- package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
- package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
- package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
- package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
- package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
- package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
- package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
- package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
- package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
- package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
- package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
- package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
- package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
- package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
- package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
- package/src/components/ui/popover.tsx +3 -1
- package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
- package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
- package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
- package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
- package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
- package/src/components/ui/text-editor/editor.tsx +11 -3
- package/src/components/ui/text-editor/extensions.ts +8 -2
- package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
- package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
- package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
- package/src/components/ui/text-editor/markdown.ts +11 -0
- package/src/components/ui/text-editor/tool-bar.tsx +45 -102
- package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
- package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
- package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
- package/src/hooks/__tests__/supabase-provider.test.ts +110 -7
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +39 -2
- package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
- package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
- package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
- package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +147 -0
- package/src/hooks/calendar-connection-types.ts +49 -0
- package/src/hooks/calendar-creation-request.test.ts +62 -0
- package/src/hooks/calendar-creation-request.ts +34 -0
- package/src/hooks/calendar-event-update-queue-types.ts +12 -0
- package/src/hooks/calendar-event-write-payload.ts +57 -0
- package/src/hooks/collaboration-document-hydration.ts +35 -0
- package/src/hooks/notification-subscription.ts +5 -2
- package/src/hooks/supabase-provider.ts +76 -112
- package/src/hooks/use-calendar-sync.tsx +41 -62
- package/src/hooks/use-calendar.tsx +44 -82
- package/src/hooks/use-exchange-rates.ts +4 -15
- package/src/hooks/use-local-storage.ts +9 -11
- package/src/hooks/use-native-calendar-occurrences.ts +85 -0
- package/src/hooks/use-workspace-presence.ts +9 -9
- 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'
|
|
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
|
+
? ``
|
|
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?:
|
|
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
|
|
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?:
|
|
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
|
+
}
|