@tuturuuu/ui 0.37.1 → 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.
- package/CHANGELOG.md +40 -0
- package/package.json +10 -7
- package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +267 -0
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +24 -38
- 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/hidden-workspaces-settings.tsx +49 -0
- package/src/components/ui/custom/mobile-header-height.ts +16 -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/structure.tsx +37 -5
- package/src/components/ui/custom/user-filters.tsx +2 -1
- package/src/components/ui/custom/visible-workspace-filter.tsx +41 -0
- package/src/components/ui/custom/workspace-picker-content.tsx +356 -0
- package/src/components/ui/custom/workspace-select-helpers.ts +11 -0
- package/src/components/ui/custom/workspace-select-invitations.tsx +3 -0
- package/src/components/ui/custom/workspace-select.tsx +95 -359
- package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
- package/src/components/ui/legacy/calendar/event-modal.tsx +77 -137
- 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/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/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/settings/calendar-sync-dashboard.tsx +4 -23
- package/src/components/ui/legacy/calendar/settings/sync-logs-table.tsx +3 -6
- 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/popover.tsx +3 -1
- package/src/components/ui/resizable.tsx +2 -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 +8 -3
- package/src/components/ui/text-editor/extensions.ts +8 -2
- 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 +43 -44
- package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
- package/src/hooks/__tests__/supabase-provider.test.ts +9 -0
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +12 -2
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
- package/src/hooks/__tests__/use-visible-workspaces.test.tsx +76 -0
- package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
- package/src/hooks/__tests__/use-workspace-visibility.test.tsx +155 -0
- package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +108 -0
- package/src/hooks/__tests__/workspace-actor-lifetime.test.tsx +103 -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-write-payload.ts +24 -0
- package/src/hooks/notification-subscription.ts +5 -2
- package/src/hooks/supabase-provider.ts +38 -72
- package/src/hooks/use-calendar.tsx +21 -45
- package/src/hooks/use-exchange-rates.ts +4 -15
- package/src/hooks/use-visible-workspaces.ts +57 -0
- package/src/hooks/use-workspace-presence.ts +9 -9
- package/src/hooks/use-workspace-user.ts +15 -3
- package/src/hooks/use-workspace-visibility.ts +234 -0
- package/src/hooks/use-yjs-collaboration.ts +45 -27
|
@@ -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
|
-
|
|
47
|
+
const PopoverClose = PopoverPrimitive.Close;
|
|
48
|
+
|
|
49
|
+
export { Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger };
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type GroupProps as GroupPropsOriginal,
|
|
9
9
|
Panel,
|
|
10
10
|
Separator,
|
|
11
|
+
usePanelRef,
|
|
11
12
|
} from 'react-resizable-panels';
|
|
12
13
|
|
|
13
14
|
interface ResizablePanelGroupProps
|
|
@@ -96,4 +97,4 @@ function ResizableHandle({
|
|
|
96
97
|
);
|
|
97
98
|
}
|
|
98
99
|
|
|
99
|
-
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };
|
|
100
|
+
export { ResizableHandle, ResizablePanel, ResizablePanelGroup, usePanelRef };
|
|
@@ -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'
|
|
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;
|
|
@@ -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
|
|
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
|
: []),
|
|
@@ -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
|
+
}
|