@tuturuuu/ui 0.37.2 → 0.38.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 (47) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/package.json +9 -6
  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/legacy/calendar/calendar-draft-recovery.ts +34 -0
  11. package/src/components/ui/legacy/calendar/event-modal.tsx +77 -137
  12. package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
  13. package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
  14. package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
  15. package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
  16. package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
  17. package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
  18. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
  19. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
  20. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
  21. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
  22. package/src/components/ui/popover.tsx +3 -1
  23. package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
  24. package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
  25. package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
  26. package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
  27. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
  28. package/src/components/ui/text-editor/editor.tsx +8 -3
  29. package/src/components/ui/text-editor/extensions.ts +8 -2
  30. package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
  31. package/src/components/ui/text-editor/markdown.ts +11 -0
  32. package/src/components/ui/text-editor/tool-bar.tsx +43 -44
  33. package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
  34. package/src/hooks/__tests__/supabase-provider.test.ts +9 -0
  35. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +12 -2
  36. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
  37. package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
  38. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +108 -0
  39. package/src/hooks/calendar-creation-request.test.ts +62 -0
  40. package/src/hooks/calendar-creation-request.ts +34 -0
  41. package/src/hooks/calendar-event-write-payload.ts +24 -0
  42. package/src/hooks/notification-subscription.ts +5 -2
  43. package/src/hooks/supabase-provider.ts +38 -72
  44. package/src/hooks/use-calendar.tsx +21 -45
  45. package/src/hooks/use-exchange-rates.ts +4 -15
  46. package/src/hooks/use-workspace-presence.ts +9 -9
  47. package/src/hooks/use-yjs-collaboration.ts +45 -27
@@ -0,0 +1,119 @@
1
+ // @vitest-environment jsdom
2
+ import { act, renderHook } from '@testing-library/react';
3
+ import { expect, it, vi } from 'vitest';
4
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
5
+ import { useCalendarDraftSave } from './use-calendar-draft-save';
6
+
7
+ const query = vi.hoisted(() => ({
8
+ data: { connectionId: 'owned', providerColorWrites: true },
9
+ }));
10
+ vi.mock('@tanstack/react-query', () => ({ useQuery: () => query }));
11
+ vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
12
+ vi.mock('@tuturuuu/ui/button', () => ({
13
+ Button: (props: any) => <button {...props} />,
14
+ }));
15
+
16
+ function fixture() {
17
+ query.data = { connectionId: 'owned', providerColorWrites: true };
18
+ const draft = {
19
+ requestId: '01900000-0000-7000-8000-000000000001',
20
+ ws_id: 'workspace',
21
+ title: 'Original',
22
+ };
23
+ const args = {
24
+ draft,
25
+ wsId: 'workspace',
26
+ source: { provider: 'google' as const, connectionId: 'owned' } as any,
27
+ original: { id: 'new' } as CalendarEvent,
28
+ buildPayload: () => ({ title: args.draft.title }),
29
+ addEvent: vi.fn().mockRejectedValueOnce(new Error('lost response')),
30
+ updateEvent: vi.fn().mockResolvedValue(undefined),
31
+ closeModal: vi.fn(),
32
+ setIsSaving: vi.fn(),
33
+ onError: vi.fn(),
34
+ isSaving: false,
35
+ };
36
+ return args;
37
+ }
38
+
39
+ it('retries immutable uncertain contents and ID, preserves edits, then explicitly updates the confirmed ID', async () => {
40
+ const args = fixture();
41
+ const { result, rerender } = renderHook(() => useCalendarDraftSave(args));
42
+ await act(() => result.current.save());
43
+ expect(result.current.recoveryNotice).not.toBeNull();
44
+ args.draft = { ...args.draft, title: 'Edited after uncertainty' };
45
+ rerender();
46
+ await act(() => result.current.save());
47
+ expect(args.addEvent).toHaveBeenCalledTimes(1);
48
+ args.addEvent.mockResolvedValueOnce({ id: 'confirmed', title: 'Original' });
49
+ const retry = (result.current.recoveryNotice!.props.children[1] as any).props
50
+ .onClick;
51
+ await act(() => retry());
52
+ expect(args.addEvent).toHaveBeenLastCalledWith(
53
+ { title: 'Original' },
54
+ { requestId: args.draft.requestId }
55
+ );
56
+ expect(args.closeModal).not.toHaveBeenCalled();
57
+ expect(args.draft.title).toBe('Edited after uncertainty');
58
+ await act(() => result.current.save());
59
+ expect(args.addEvent).toHaveBeenCalledTimes(2);
60
+ expect(args.updateEvent).toHaveBeenCalledWith('confirmed', {
61
+ title: 'Edited after uncertainty',
62
+ });
63
+ expect(args.closeModal).toHaveBeenCalledTimes(1);
64
+ });
65
+
66
+ it('keeps failed recovery uncertain without dispatching edited intent', async () => {
67
+ const args = fixture();
68
+ const { result } = renderHook(() => useCalendarDraftSave(args));
69
+ await act(() => result.current.save());
70
+ args.addEvent.mockRejectedValueOnce(new Error('still uncertain'));
71
+ const retry = (result.current.recoveryNotice!.props.children[1] as any).props
72
+ .onClick;
73
+ await act(() => retry());
74
+ expect(result.current.recoveryNotice).not.toBeNull();
75
+ expect(args.updateEvent).not.toHaveBeenCalled();
76
+ expect(args.closeModal).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it('does not close or recover another draft after a stale retry response', async () => {
80
+ const args = fixture();
81
+ const { result, rerender } = renderHook(() => useCalendarDraftSave(args));
82
+ await act(() => result.current.save());
83
+ let resolve!: (event: unknown) => void;
84
+ args.addEvent.mockReturnValueOnce(
85
+ new Promise((r) => {
86
+ resolve = r;
87
+ })
88
+ );
89
+ const retry = (result.current.recoveryNotice!.props.children[1] as any).props
90
+ .onClick;
91
+ let pending!: Promise<void>;
92
+ act(() => {
93
+ pending = retry();
94
+ });
95
+ args.draft = {
96
+ ...args.draft,
97
+ requestId: 'another-draft',
98
+ title: 'New draft',
99
+ };
100
+ rerender();
101
+ await act(async () => {
102
+ resolve({ id: 'old-confirmed', title: 'Original' });
103
+ await pending;
104
+ });
105
+ expect(args.closeModal).not.toHaveBeenCalled();
106
+ expect(result.current.recoveryNotice).toBeNull();
107
+ expect(args.updateEvent).not.toHaveBeenCalled();
108
+ });
109
+
110
+ it('keeps legacy unconfirmed creates out of automatic retry', async () => {
111
+ const args = fixture();
112
+ query.data.providerColorWrites = false;
113
+ const { result } = renderHook(() => useCalendarDraftSave(args));
114
+ await act(() => result.current.save());
115
+ expect(result.current.recoveryNotice!.props.children[1]).toBe(false);
116
+ await act(() => result.current.save());
117
+ expect(args.addEvent).toHaveBeenCalledTimes(1);
118
+ expect(args.updateEvent).not.toHaveBeenCalled();
119
+ });
@@ -0,0 +1,141 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import {
3
+ getGoogleCalendarColorOptions,
4
+ type CalendarSourceOption,
5
+ } from '@tuturuuu/internal-api';
6
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
7
+ import { Button } from '@tuturuuu/ui/button';
8
+ import { useTranslations } from 'next-intl';
9
+ import { useRef, useState } from 'react';
10
+ import { createCalendarDraftRecovery } from './calendar-draft-recovery';
11
+ import { eventSavePayload } from './event-save-payload';
12
+
13
+ type Draft = Partial<CalendarEvent>;
14
+ type SaveArgs = {
15
+ draft: Draft;
16
+ original?: CalendarEvent;
17
+ buildPayload: () => Draft;
18
+ addEvent: (
19
+ event: Omit<CalendarEvent, 'id'>,
20
+ options?: { requestId?: string }
21
+ ) => Promise<CalendarEvent | undefined>;
22
+ updateEvent: (id: string, payload: Draft) => Promise<unknown>;
23
+ closeModal: () => void;
24
+ setIsSaving: (saving: boolean) => void;
25
+ onError: () => void;
26
+ isSaving: boolean;
27
+ source?: CalendarSourceOption;
28
+ wsId?: string;
29
+ };
30
+
31
+ export function useCalendarDraftSave(args: SaveArgs) {
32
+ const t = useTranslations('calendar');
33
+ const connectionId =
34
+ args.source?.provider === 'google' ? args.source.connectionId : null;
35
+ const { data: capabilities } = useQuery({
36
+ queryKey: ['google-calendar-color-options', args.wsId, connectionId],
37
+ enabled: !!args.wsId && !!connectionId,
38
+ queryFn: () => getGoogleCalendarColorOptions(args.wsId!, connectionId!),
39
+ });
40
+ const scope = `${args.draft.ws_id}:${args.draft.requestId ?? args.original?.id}`;
41
+ const state = useRef({ scope, recovery: createCalendarDraftRecovery() });
42
+ if (state.current.scope !== scope)
43
+ state.current = { scope, recovery: createCalendarDraftRecovery() };
44
+ const [uncertainScope, setUncertainScope] = useState<string>();
45
+ const current = state.current;
46
+ const recovery = current.recovery;
47
+ const uncertain = uncertainScope === scope && !recovery.original();
48
+
49
+ async function retryOriginal() {
50
+ const submission = recovery.submitted();
51
+ if (!submission?.recoverable) return;
52
+ args.setIsSaving(true);
53
+ try {
54
+ const saved = await args.addEvent(
55
+ submission.payload as Omit<CalendarEvent, 'id'>,
56
+ {
57
+ requestId: submission.requestId,
58
+ }
59
+ );
60
+ if (!saved) throw new Error('Unconfirmed creation');
61
+ if (state.current !== current) return;
62
+ recovery.complete(saved);
63
+ // The caller may have edited the draft during the uncertain response.
64
+ // Keep those edits open; their next save updates the confirmed event ID.
65
+ setUncertainScope(undefined);
66
+ } catch {
67
+ if (state.current === current) args.onError();
68
+ } finally {
69
+ if (state.current === current) args.setIsSaving(false);
70
+ }
71
+ }
72
+
73
+ async function save() {
74
+ if (uncertain) return;
75
+ args.setIsSaving(true);
76
+ try {
77
+ const payload = args.buildPayload();
78
+ const confirmed = recovery.original();
79
+ if (confirmed) {
80
+ const sourceChanged =
81
+ JSON.stringify(payload.source) !==
82
+ JSON.stringify(recovery.submitted()?.payload.source);
83
+ const updates = eventSavePayload(payload, confirmed, sourceChanged);
84
+ if (Object.keys(updates).length)
85
+ await args.updateEvent(confirmed.id, updates);
86
+ } else if (args.original?.id === 'new') {
87
+ if (!args.draft.requestId)
88
+ throw new Error('Missing creation request ID');
89
+ const submission = recovery.capture(
90
+ payload,
91
+ args.draft.requestId,
92
+ !!connectionId &&
93
+ capabilities?.connectionId === connectionId &&
94
+ capabilities.providerColorWrites === true
95
+ );
96
+ const saved = await args.addEvent(
97
+ submission.payload as Omit<CalendarEvent, 'id'>,
98
+ {
99
+ requestId: submission.requestId,
100
+ }
101
+ );
102
+ if (!saved) throw new Error('Unconfirmed creation');
103
+ recovery.complete(saved);
104
+ } else if (args.original?.id) {
105
+ if (Object.keys(payload).length)
106
+ await args.updateEvent(args.original.id, payload);
107
+ } else throw new Error('No event to save');
108
+ if (state.current === current) args.closeModal();
109
+ } catch {
110
+ if (recovery.submitted() && !recovery.original())
111
+ setUncertainScope(scope);
112
+ if (state.current === current) args.onError();
113
+ } finally {
114
+ if (state.current === current) args.setIsSaving(false);
115
+ }
116
+ }
117
+
118
+ return {
119
+ save,
120
+ recoveryNotice: uncertain ? (
121
+ <div role="status" className="mb-2 space-y-2">
122
+ <p className="text-muted-foreground text-sm">
123
+ {t(
124
+ recovery.submitted()?.recoverable
125
+ ? 'creation_recovery_notice'
126
+ : 'creation_recovery_unavailable'
127
+ )}
128
+ </p>
129
+ {recovery.submitted()?.recoverable && (
130
+ <Button
131
+ variant="outline"
132
+ onClick={retryOriginal}
133
+ disabled={args.isSaving}
134
+ >
135
+ {t('creation_recovery_retry')}
136
+ </Button>
137
+ )}
138
+ </div>
139
+ ) : null,
140
+ };
141
+ }
@@ -44,4 +44,6 @@ function PopoverAnchor({
44
44
  return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
45
45
  }
46
46
 
47
- export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger };
47
+ const PopoverClose = PopoverPrimitive.Close;
48
+
49
+ export { Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger };
@@ -0,0 +1,142 @@
1
+ import { waitFor } from '@testing-library/react';
2
+ import { Editor } from '@tiptap/core';
3
+ import Collaboration from '@tiptap/extension-collaboration';
4
+ import CollaborationCaret from '@tiptap/extension-collaboration-caret';
5
+ import StarterKit from '@tiptap/starter-kit';
6
+ import { describe, expect, it, vi } from 'vitest';
7
+ import {
8
+ Awareness,
9
+ applyAwarenessUpdate,
10
+ encodeAwarenessUpdate,
11
+ } from 'y-protocols/awareness';
12
+ import * as Y from 'yjs';
13
+ import {
14
+ renderCollaborationCaret,
15
+ renderCollaborationSelection,
16
+ scrollToCollaborationCaret,
17
+ } from '../collaboration-carets';
18
+
19
+ describe('remote cursors and selections', () => {
20
+ it('identifies duplicate names by account ID and safely renders untrusted names', () => {
21
+ const root = document.createElement('div');
22
+ const first = renderCollaborationCaret({
23
+ id: 'first',
24
+ name: 'Alex',
25
+ color: '#123456',
26
+ });
27
+ const second = renderCollaborationCaret({
28
+ id: 'second',
29
+ name: 'Alex',
30
+ color: '#654321',
31
+ });
32
+ const scrollFirst = vi.fn();
33
+ const scrollSecond = vi.fn();
34
+ first.scrollIntoView = scrollFirst;
35
+ second.scrollIntoView = scrollSecond;
36
+ root.append(first, second);
37
+ expect(scrollToCollaborationCaret(root, 'second')).toBe(true);
38
+ expect(scrollFirst).not.toHaveBeenCalled();
39
+ expect(scrollSecond).toHaveBeenCalledOnce();
40
+ const unsafe = renderCollaborationCaret({
41
+ id: 'third',
42
+ name: '<img src=x onerror=alert(1)>',
43
+ color: 'url(evil)',
44
+ });
45
+ expect(unsafe.querySelector('img')).toBeNull();
46
+ expect(unsafe.textContent).toBe('<img src=x onerror=alert(1)>');
47
+ expect(
48
+ renderCollaborationSelection({ color: 'url(evil)' }).style
49
+ ).not.toContain('evil');
50
+ });
51
+ it('renders a real Tiptap peer caret and highlights a remote range after Yjs sync', async () => {
52
+ const a = new Y.Doc();
53
+ const b = new Y.Doc();
54
+ const awarenessA = new Awareness(a);
55
+ const awarenessB = new Awareness(b);
56
+ const sync = 'wire';
57
+ a.on('update', (update, origin) => {
58
+ if (origin !== sync) Y.applyUpdate(b, update, sync);
59
+ });
60
+ b.on('update', (update, origin) => {
61
+ if (origin !== sync) Y.applyUpdate(a, update, sync);
62
+ });
63
+ for (const [source, target] of [
64
+ [awarenessA, awarenessB],
65
+ [awarenessB, awarenessA],
66
+ ] as const) {
67
+ source.on(
68
+ 'update',
69
+ (
70
+ {
71
+ added,
72
+ updated,
73
+ removed,
74
+ }: { added: number[]; updated: number[]; removed: number[] },
75
+ origin: unknown
76
+ ) => {
77
+ if (origin !== sync)
78
+ applyAwarenessUpdate(
79
+ target,
80
+ encodeAwarenessUpdate(source, [...added, ...updated, ...removed]),
81
+ sync
82
+ );
83
+ }
84
+ );
85
+ }
86
+ const elements = [
87
+ document.createElement('div'),
88
+ document.createElement('div'),
89
+ ];
90
+ for (const element of elements) document.body.append(element);
91
+ const editors = [
92
+ [a, awarenessA, 'first'],
93
+ [b, awarenessB, 'second'],
94
+ ].map(
95
+ ([doc, awareness, id], index) =>
96
+ new Editor({
97
+ element: elements[index],
98
+ extensions: [
99
+ StarterKit.configure({ undoRedo: false }),
100
+ Collaboration.configure({
101
+ document: doc as Y.Doc,
102
+ field: 'prosemirror',
103
+ }),
104
+ CollaborationCaret.configure({
105
+ provider: { awareness },
106
+ user: { id, name: 'Alex', color: '#123456' },
107
+ render: renderCollaborationCaret,
108
+ selectionRender: renderCollaborationSelection,
109
+ }),
110
+ ],
111
+ })
112
+ );
113
+ try {
114
+ editors[0]!.commands.setContent('<p>Hello shared document</p>');
115
+ await waitFor(() =>
116
+ expect(editors[1]!.getText()).toBe('Hello shared document')
117
+ );
118
+ editors[0]!.view.focus();
119
+ editors[0]!.commands.setTextSelection({ from: 2, to: 6 });
120
+ await waitFor(() =>
121
+ expect(
122
+ elements[1]!.querySelector('.collaboration-carets__selection')
123
+ ).not.toBeNull()
124
+ );
125
+ expect(
126
+ elements[1]!.querySelector<HTMLElement>('.collaboration-carets__caret')
127
+ ?.dataset.userId
128
+ ).toBe('first');
129
+ expect(
130
+ elements[1]!.querySelector('.collaboration-carets__selection')
131
+ ?.textContent
132
+ ).toBe('ello');
133
+ } finally {
134
+ for (const editor of editors) editor.destroy();
135
+ awarenessA.destroy();
136
+ awarenessB.destroy();
137
+ a.destroy();
138
+ b.destroy();
139
+ for (const element of elements) element.remove();
140
+ }
141
+ });
142
+ });
@@ -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;
@@ -158,6 +159,7 @@ export function RichTextEditor({
158
159
  yjsProvider,
159
160
  collaborationUser,
160
161
  allowCollaboration = false,
162
+ allowEmbeds = true,
161
163
  revealToolbarOnFocus = false,
162
164
  mentionTranslations,
163
165
  renderTaskMention,
@@ -453,7 +455,7 @@ export function RichTextEditor({
453
455
  attributes: { class: getEditorClasses },
454
456
  },
455
457
  });
456
- editor.setEditable(!readOnly);
458
+ editor.setEditable(!readOnly, false);
457
459
  }, [editor, readOnly, getEditorClasses]);
458
460
 
459
461
  // Update editor content when the content prop changes externally
@@ -657,6 +659,7 @@ export function RichTextEditor({
657
659
  <ToolBar
658
660
  editor={editor}
659
661
  fixedToolbarVisible={fixedToolbarVisible}
662
+ allowEmbeds={allowEmbeds}
660
663
  saveButtonLabel={saveButtonLabel}
661
664
  savedButtonLabel={savedButtonLabel}
662
665
  workspaceId={workspaceId}
@@ -671,3 +674,5 @@ export function RichTextEditor({
671
674
  </div>
672
675
  );
673
676
  }
677
+
678
+ 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
  : []),
@@ -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
+ }