@tuturuuu/ui 0.27.0 → 0.28.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.
@@ -0,0 +1,251 @@
1
+ import { Editor, generateHTML, generateJSON } from '@tiptap/core';
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { getEditorExtensions } from '../extensions';
4
+
5
+ const editors: Editor[] = [];
6
+
7
+ function createEditor(content: Record<string, unknown>) {
8
+ const editor = new Editor({
9
+ content,
10
+ extensions: getEditorExtensions(),
11
+ });
12
+ editors.push(editor);
13
+ return editor;
14
+ }
15
+
16
+ afterEach(() => {
17
+ for (const editor of editors.splice(0)) editor.destroy();
18
+ });
19
+
20
+ describe('toggle blocks', () => {
21
+ it('registers the native details schema and toggle command extension', () => {
22
+ const names = getEditorExtensions().map((extension) => extension.name);
23
+
24
+ expect(names).toEqual(
25
+ expect.arrayContaining([
26
+ 'details',
27
+ 'detailsSummary',
28
+ 'detailsContent',
29
+ 'toggleBlock',
30
+ ])
31
+ );
32
+ });
33
+
34
+ it('turns a paragraph into an open toggle list and restores it', () => {
35
+ const editor = createEditor({
36
+ type: 'doc',
37
+ content: [
38
+ {
39
+ type: 'paragraph',
40
+ content: [{ type: 'text', text: 'Deployment notes' }],
41
+ },
42
+ ],
43
+ });
44
+ editor.commands.setTextSelection(3);
45
+
46
+ expect(editor.commands.toggleDetailsBlock()).toBe(true);
47
+ expect(editor.getJSON()).toEqual({
48
+ type: 'doc',
49
+ content: [
50
+ {
51
+ type: 'details',
52
+ attrs: { open: true },
53
+ content: [
54
+ {
55
+ type: 'detailsSummary',
56
+ attrs: { level: null },
57
+ content: [{ type: 'text', text: 'Deployment notes' }],
58
+ },
59
+ {
60
+ type: 'detailsContent',
61
+ content: [{ type: 'paragraph', attrs: { textAlign: null } }],
62
+ },
63
+ ],
64
+ },
65
+ { type: 'paragraph', attrs: { textAlign: null } },
66
+ ],
67
+ });
68
+
69
+ expect(editor.commands.toggleDetailsBlock()).toBe(true);
70
+ expect(editor.getJSON()).toEqual({
71
+ type: 'doc',
72
+ content: [
73
+ {
74
+ type: 'paragraph',
75
+ attrs: { textAlign: null },
76
+ content: [{ type: 'text', text: 'Deployment notes' }],
77
+ },
78
+ { type: 'paragraph', attrs: { textAlign: null } },
79
+ { type: 'paragraph', attrs: { textAlign: null } },
80
+ ],
81
+ });
82
+ });
83
+
84
+ it.each([1, 2, 3] as const)(
85
+ 'preserves heading %i semantics through toggle and untoggle',
86
+ (level) => {
87
+ const editor = createEditor({
88
+ type: 'doc',
89
+ content: [
90
+ {
91
+ type: 'heading',
92
+ attrs: { level, textAlign: null },
93
+ content: [{ type: 'text', text: `Heading ${level}` }],
94
+ },
95
+ ],
96
+ });
97
+ editor.commands.setTextSelection(3);
98
+
99
+ expect(editor.commands.toggleDetailsBlock()).toBe(true);
100
+ const details = editor.getJSON().content?.[0];
101
+ expect(details).toMatchObject({ type: 'details', attrs: { open: true } });
102
+ expect(details?.content?.[0]).toEqual({
103
+ type: 'detailsSummary',
104
+ attrs: { level },
105
+ content: [{ type: 'text', text: `Heading ${level}` }],
106
+ });
107
+
108
+ expect(editor.commands.toggleDetailsBlock()).toBe(true);
109
+ expect(editor.getJSON().content?.[0]).toMatchObject({
110
+ type: 'heading',
111
+ attrs: { level },
112
+ content: [{ type: 'text', text: `Heading ${level}` }],
113
+ });
114
+ }
115
+ );
116
+
117
+ it('keeps selected trailing blocks inside the toggle body', () => {
118
+ const editor = createEditor({
119
+ type: 'doc',
120
+ content: [
121
+ {
122
+ type: 'heading',
123
+ attrs: { level: 2, textAlign: null },
124
+ content: [{ type: 'text', text: 'Summary' }],
125
+ },
126
+ {
127
+ type: 'paragraph',
128
+ content: [{ type: 'text', text: 'First detail' }],
129
+ },
130
+ {
131
+ type: 'bulletList',
132
+ content: [
133
+ {
134
+ type: 'listItem',
135
+ content: [
136
+ {
137
+ type: 'paragraph',
138
+ content: [{ type: 'text', text: 'Nested detail' }],
139
+ },
140
+ ],
141
+ },
142
+ ],
143
+ },
144
+ ],
145
+ });
146
+ editor.commands.selectAll();
147
+
148
+ expect(editor.commands.toggleDetailsBlock()).toBe(true);
149
+ const details = editor.getJSON().content?.[0];
150
+ expect(details?.content?.[1]?.type).toBe('detailsContent');
151
+ expect(details?.content?.[1]).toMatchObject({
152
+ content: [
153
+ { type: 'paragraph' },
154
+ { type: 'bulletList' },
155
+ { type: 'paragraph' },
156
+ ],
157
+ });
158
+ });
159
+
160
+ it('round-trips persisted open state and toggle heading level through HTML', () => {
161
+ const json = {
162
+ type: 'doc',
163
+ content: [
164
+ {
165
+ type: 'details',
166
+ attrs: { open: true },
167
+ content: [
168
+ {
169
+ type: 'detailsSummary',
170
+ attrs: { level: 2 },
171
+ content: [{ type: 'text', text: 'Expandable heading' }],
172
+ },
173
+ {
174
+ type: 'detailsContent',
175
+ content: [
176
+ {
177
+ type: 'paragraph',
178
+ attrs: { textAlign: null },
179
+ content: [{ type: 'text', text: 'Hidden details' }],
180
+ },
181
+ ],
182
+ },
183
+ ],
184
+ },
185
+ ],
186
+ };
187
+ const extensions = getEditorExtensions();
188
+ const html = generateHTML(json, extensions);
189
+
190
+ expect(html).toContain('<details');
191
+ expect(html).toContain('open=""');
192
+ expect(html).toContain('data-heading-level="2"');
193
+ expect(generateJSON(html, extensions)).toEqual(json);
194
+ });
195
+
196
+ it('persists collapse and expand interactions from the rendered toggle button', () => {
197
+ const element = document.createElement('div');
198
+ document.body.append(element);
199
+ const editor = new Editor({
200
+ element,
201
+ extensions: getEditorExtensions(),
202
+ content: {
203
+ type: 'doc',
204
+ content: [
205
+ {
206
+ type: 'details',
207
+ attrs: { open: false },
208
+ content: [
209
+ {
210
+ type: 'detailsSummary',
211
+ attrs: { level: null },
212
+ content: [{ type: 'text', text: 'More information' }],
213
+ },
214
+ {
215
+ type: 'detailsContent',
216
+ content: [
217
+ {
218
+ type: 'paragraph',
219
+ content: [{ type: 'text', text: 'Hidden initially' }],
220
+ },
221
+ ],
222
+ },
223
+ ],
224
+ },
225
+ ],
226
+ },
227
+ });
228
+ editors.push(editor);
229
+
230
+ const button = element.querySelector<HTMLButtonElement>(
231
+ '.toggle-details-button'
232
+ );
233
+ const body = element.querySelector<HTMLElement>(
234
+ '[data-type="detailsContent"]'
235
+ );
236
+ expect(button?.getAttribute('aria-expanded')).toBe('false');
237
+ expect(body).toHaveAttribute('hidden');
238
+
239
+ button?.click();
240
+ expect(editor.getJSON().content?.[0]?.attrs?.open).toBe(true);
241
+ expect(button?.getAttribute('aria-expanded')).toBe('true');
242
+ expect(body).not.toHaveAttribute('hidden');
243
+
244
+ button?.click();
245
+ expect(editor.getJSON().content?.[0]?.attrs?.open).toBe(false);
246
+ expect(button?.getAttribute('aria-expanded')).toBe('false');
247
+ expect(body).toHaveAttribute('hidden');
248
+
249
+ element.remove();
250
+ });
251
+ });
@@ -0,0 +1,100 @@
1
+ export function getRichTextEditorClasses({
2
+ className,
3
+ readOnly,
4
+ }: {
5
+ className?: string;
6
+ readOnly: boolean;
7
+ }) {
8
+ return [
9
+ 'border border-dynamic-border rounded-md bg-transparent',
10
+ 'max-w-none overflow-y-auto',
11
+ readOnly ? 'pt-0' : 'pt-4',
12
+ 'text-foreground leading-relaxed',
13
+ '[&_:first-child]:mt-0',
14
+ '[&_h1]:text-4xl [&_h1]:font-bold [&_h1]:text-foreground [&_h1]:mt-6 [&_h1]:mb-4',
15
+ '[&_h2]:text-3xl [&_h2]:font-semibold [&_h2]:text-foreground [&_h2]:mt-5 [&_h2]:mb-3',
16
+ '[&_h3]:text-2xl [&_h3]:font-semibold [&_h3]:text-foreground [&_h3]:mt-4 [&_h3]:mb-2',
17
+ '[&_h4]:text-xl [&_h4]:font-semibold [&_h4]:text-foreground [&_h4]:mt-3 [&_h4]:mb-2',
18
+ '[&_h5]:text-lg [&_h5]:font-semibold [&_h5]:text-foreground [&_h5]:mt-3 [&_h5]:mb-2',
19
+ '[&_h6]:text-base [&_h6]:font-semibold [&_h6]:text-foreground [&_h6]:mt-3 [&_h6]:mb-2',
20
+ '[&_p]:my-3 [&_p]:leading-7',
21
+ '[&_ul]:my-3 [&_ul]:ml-6 [&_ul]:px-0 [&_ul]:mr-0',
22
+ '[&_ol]:my-3 [&_ol]:ml-6 [&_ol]:px-0 [&_ol]:mr-0',
23
+ '[&_li]:my-1 [&_li]:leading-7',
24
+ '[&_ul_li_p]:my-1',
25
+ '[&_ol_li_p]:my-1',
26
+ '[&_li_h1]:text-4xl [&_li_h2]:text-3xl [&_li_h3]:text-2xl',
27
+ '[&_ul[data-type="taskList"]]:list-none [&_ul[data-type="taskList"]]:ml-6 [&_ul[data-type="taskList"]]:pl-0 [&_ul[data-type="taskList"]]:pr-0 [&_ul[data-type="taskList"]]:mr-0 [&_ul[data-type="taskList"]]:my-3',
28
+ '[&_ul[data-type="taskList"]_li]:flex [&_ul[data-type="taskList"]_li]:items-start [&_ul[data-type="taskList"]_li]:my-1',
29
+ '[&_ul[data-type="taskList"]_.task-list-checkbox-label]:flex-[0_0_auto]',
30
+ '[&_ul[data-type="taskList"]_li>div:not(.task-list-checkbox-label)]:flex-1 [&_ul[data-type="taskList"]_li>div:not(.task-list-checkbox-label)]:min-w-0',
31
+ '[&_ul[data-type="taskList"]_li_p]:my-1',
32
+ '[&_ul[data-type="taskList"]_input[type="checkbox"]]:appearance-none [&_ul[data-type="taskList"]_input[type="checkbox"]]:h-[18px] [&_ul[data-type="taskList"]_input[type="checkbox"]]:w-[18px]',
33
+ '[&_ul[data-type="taskList"]_input[type="checkbox"]]:cursor-pointer [&_ul[data-type="taskList"]_input[type="checkbox"]]:rounded-[4px] [&_ul[data-type="taskList"]_input[type="checkbox"]]:border-2 [&_ul[data-type="taskList"]_input[type="checkbox"]]:border-input',
34
+ '[&_ul[data-type="taskList"]_input[type="checkbox"]]:bg-background [&_ul[data-type="taskList"]_input[type="checkbox"]]:transition-all [&_ul[data-type="taskList"]_input[type="checkbox"]]:duration-150',
35
+ '[&_ul[data-type="taskList"]_input[type="checkbox"]]:shrink-0',
36
+ '[&_ul[data-type="taskList"]_input[type="checkbox"]:hover]:border-dynamic-gray [&_ul[data-type="taskList"]_input[type="checkbox"]:hover]:bg-dynamic-gray/10 [&_ul[data-type="taskList"]_input[type="checkbox"]:hover]:scale-105',
37
+ '[&_ul[data-type="taskList"]_input[type="checkbox"]:focus]:outline-none [&_ul[data-type="taskList"]_input[type="checkbox"]:focus]:ring-2 [&_ul[data-type="taskList"]_input[type="checkbox"]:focus]:ring-dynamic-gray/30 [&_ul[data-type="taskList"]_input[type="checkbox"]:focus]:ring-offset-2 [&_ul[data-type="taskList"]_input[type="checkbox"]:focus]:border-dynamic-gray',
38
+ '[&_ul[data-type="taskList"]_input[type="checkbox"]:checked]:bg-dynamic-green/20 [&_ul[data-type="taskList"]_input[type="checkbox"]:checked]:border-dynamic-green',
39
+ '[&_ul[data-type="taskList"]_input[type="checkbox"]:checked:hover]:bg-dynamic-green/10 [&_ul[data-type="taskList"]_input[type="checkbox"]:checked:hover]:border-dynamic-green',
40
+ `[&_ul[data-type="taskList"]_input[type="checkbox"]:checked]:bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cpath%20fill%3D%22none%22%20stroke%3D%22%2309090b%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%222%22%20d%3D%22M4%208l2.5%202.5L12%205%22%2F%3E%3C%2Fsvg%3E')]`,
41
+ `dark:[&_ul[data-type="taskList"]_input[type="checkbox"]:checked]:bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cpath%20fill%3D%22none%22%20stroke%3D%22white%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%222%22%20d%3D%22M4%208l2.5%202.5L12%205%22%2F%3E%3C%2Fsvg%3E')]`,
42
+ '[&_ul[data-type="taskList"]_input[type="checkbox"]:checked:bg-[length:14px_14px]',
43
+ '[&_ul[data-type="taskList"]_input[type="checkbox"]:indeterminate]:bg-dynamic-orange/20 [&_ul[data-type="taskList"]_input[type="checkbox"]:indeterminate]:border-dynamic-orange',
44
+ '[&_li_ul]:my-1 [&_li_ul]:ml-6 [&_li_ol]:my-1 [&_li_ol]:ml-6',
45
+ '[&_ul[data-type="taskList"]_ul[data-type="taskList"]]:my-1 [&_ul[data-type="taskList"]_ul[data-type="taskList"]]:ml-6',
46
+ '[&_div[data-type="details"]]:relative [&_div[data-type="details"]]:my-3 [&_div[data-type="details"]]:pl-7',
47
+ '[&_.toggle-details-button]:absolute [&_.toggle-details-button]:top-1 [&_.toggle-details-button]:left-0 [&_.toggle-details-button]:size-6 [&_.toggle-details-button]:rounded-md [&_.toggle-details-button]:text-muted-foreground [&_.toggle-details-button]:transition-transform [&_.toggle-details-button:hover]:bg-dynamic-surface [&_.toggle-details-button:hover]:text-foreground',
48
+ '[&_.toggle-details-button]:before:content-["›"] [&_.toggle-details-button]:before:text-xl [&_.toggle-details-button]:before:leading-none',
49
+ '[&_.toggle-details.is-open>.toggle-details-button]:rotate-90',
50
+ '[&_summary]:cursor-text [&_summary]:list-none [&_summary::-webkit-details-marker]:hidden',
51
+ '[&_summary[data-heading-level="1"]]:text-4xl [&_summary[data-heading-level="1"]]:font-bold',
52
+ '[&_summary[data-heading-level="2"]]:text-3xl [&_summary[data-heading-level="2"]]:font-semibold',
53
+ '[&_summary[data-heading-level="3"]]:text-2xl [&_summary[data-heading-level="3"]]:font-semibold',
54
+ '[&_div[data-type="detailsContent"]]:mt-1 [&_div[data-type="detailsContent"]]:border-dynamic-border/60 [&_div[data-type="detailsContent"]]:border-l [&_div[data-type="detailsContent"]]:pl-4',
55
+ '[&_blockquote]:border-l-4 [&_blockquote]:border-dynamic-border [&_blockquote]:pl-4 [&_blockquote]:my-4',
56
+ '[&_blockquote]:text-muted-foreground [&_blockquote]:italic',
57
+ '[&_code]:text-dynamic-pink [&_code]:bg-dynamic-pink/10 [&_code]:px-1.5 [&_code]:py-0.5',
58
+ '[&_code]:rounded [&_code]:text-sm [&_code]:font-mono',
59
+ '[&_pre]:bg-dynamic-border/50 [&_pre]:p-4 [&_pre]:rounded-md [&_pre]:my-4 [&_pre]:overflow-x-auto',
60
+ '[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-foreground',
61
+ '[&_strong]:font-bold [&_strong]:text-foreground',
62
+ '[&_b]:font-bold [&_b]:text-foreground',
63
+ '[&_em]:italic [&_em]:text-foreground',
64
+ '[&_i]:italic [&_i]:text-foreground',
65
+ '[&_a]:text-dynamic-cyan [&_a]:underline [&_a]:cursor-pointer',
66
+ '[&_a:hover]:text-dynamic-cyan/80',
67
+ '[&_a_strong]:text-dynamic-cyan [&_a_b]:text-dynamic-cyan',
68
+ '[&_strong_a]:text-dynamic-cyan [&_b_a]:text-dynamic-cyan',
69
+ '[&_hr]:border-dynamic-border [&_hr]:my-8',
70
+ '[&_table]:my-6 [&_table]:w-full [&_table]:overflow-hidden [&_table]:rounded-lg [&_table]:border [&_table]:border-collapse [&_table]:border-dynamic-border',
71
+ '[&_table]:shadow-sm',
72
+ '[&_th]:relative [&_th]:border-r [&_th]:border-dynamic-border [&_th]:bg-dynamic-border/30',
73
+ '[&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-semibold [&_th]:text-foreground',
74
+ '[&_th]:last:border-r-0 [&_th_p]:my-0',
75
+ '[&_tr]:border-b [&_tr]:border-dynamic-border [&_tr]:last:border-0',
76
+ '[&_tr]:transition-colors [&_tr:hover]:bg-dynamic-surface/30',
77
+ '[&_td]:relative [&_td]:border-r [&_td]:border-dynamic-border [&_td]:px-4 [&_td]:py-3',
78
+ '[&_td]:last:border-r-0 [&_td_p]:my-0',
79
+ '[&_td]:transition-colors [&_td:hover]:bg-dynamic-surface/50',
80
+ '[&_td:focus]:bg-dynamic-surface/70 [&_td:focus]:outline-none',
81
+ '[&_td:focus]:ring-2 [&_td:focus]:ring-dynamic-blue/50 [&_td:focus]:ring-inset',
82
+ '[&_.selectedCell]:bg-dynamic-blue/10 [&_.selectedCell]:ring-2 [&_.selectedCell]:ring-dynamic-blue/30',
83
+ '[&_.column-resize-handle]:absolute [&_.column-resize-handle]:right-[-2px] [&_.column-resize-handle]:top-0',
84
+ '[&_.column-resize-handle]:bottom-0 [&_.column-resize-handle]:w-1 [&_.column-resize-handle]:cursor-col-resize',
85
+ '[&_.column-resize-handle]:bg-dynamic-blue/50 [&_.column-resize-handle]:opacity-0',
86
+ '[&_.column-resize-handle:hover]:opacity-100 [&_.column-resize-handle]:transition-opacity',
87
+ '[&_*:is(p,h1,h2,h3).is-empty::before]:content-[attr(data-placeholder)]',
88
+ '[&_*:is(p,h1,h2,h3).is-empty::before]:text-muted-foreground',
89
+ '[&_*:is(p,h1,h2,h3).is-empty::before]:float-left',
90
+ '[&_*:is(p,h1,h2,h3).is-empty::before]:h-0',
91
+ '[&_*:is(p,h1,h2,h3).is-empty::before]:pointer-events-none',
92
+ readOnly
93
+ ? '[&_ul[data-type="taskList"]_input[type="checkbox"]]:!pointer-events-none [&_ul[data-type="taskList"]_input[type="checkbox"]]:!opacity-70 [&_ul[data-type="taskList"]_li>label]:!pointer-events-none [&_input[type="checkbox"]]:!pointer-events-none [&_.task-list-checkbox]:!pointer-events-none [&_.task-list-checkbox-label]:!pointer-events-none [&_li_div[draggable="true"]]:hidden [&_ul[data-type="taskList"]_li]:![-webkit-user-drag:none] [&_ul[data-type="taskList"]_li]:!user-drag-none'
94
+ : '',
95
+ readOnly ? '[&_.drag-handle]:hidden' : '',
96
+ className,
97
+ ]
98
+ .filter(Boolean)
99
+ .join(' ');
100
+ }
@@ -15,8 +15,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
15
  import { flushSync } from 'react-dom';
16
16
  import type * as Y from 'yjs';
17
17
  import { migrateInlineImagesToBlock } from './content-migration';
18
+ import { getRichTextEditorClasses } from './editor-classes';
18
19
  import { getEditorExtensions } from './extensions';
19
- import { handlePlainEnterFallback } from './keyboard';
20
+ import { handleListIndentation, handlePlainEnterFallback } from './keyboard';
20
21
  import type { TaskMentionNodeViewRenderer } from './mention-extension';
21
22
  import { FixedToolbar, ToolBar } from './tool-bar';
22
23
 
@@ -128,6 +129,7 @@ export interface RichTextEditorProps {
128
129
  create_project?: string;
129
130
  };
130
131
  renderTaskMention?: TaskMentionNodeViewRenderer;
132
+ toggleBlockLabel?: string;
131
133
  }
132
134
 
133
135
  export function RichTextEditor({
@@ -156,6 +158,7 @@ export function RichTextEditor({
156
158
  revealToolbarOnFocus = false,
157
159
  mentionTranslations,
158
160
  renderTaskMention,
161
+ toggleBlockLabel,
159
162
  }: RichTextEditorProps) {
160
163
  // Use refs to ensure we have stable references for handlers
161
164
  const onImageUploadRef = useRef(onImageUpload);
@@ -230,115 +233,10 @@ export function RichTextEditor({
230
233
  };
231
234
  }, [debouncedOnChange]);
232
235
 
233
- const getEditorClasses = useMemo(() => {
234
- const baseClasses = [
235
- 'border border-dynamic-border rounded-md bg-transparent',
236
- 'max-w-none overflow-y-auto',
237
- readOnly ? 'pt-0' : 'pt-4',
238
- // Typography base
239
- 'text-foreground leading-relaxed',
240
- // First child margin reset
241
- '[&_:first-child]:mt-0',
242
- // Headings
243
- '[&_h1]:text-4xl [&_h1]:font-bold [&_h1]:text-foreground [&_h1]:mt-6 [&_h1]:mb-4',
244
- '[&_h2]:text-3xl [&_h2]:font-semibold [&_h2]:text-foreground [&_h2]:mt-5 [&_h2]:mb-3',
245
- '[&_h3]:text-2xl [&_h3]:font-semibold [&_h3]:text-foreground [&_h3]:mt-4 [&_h3]:mb-2',
246
- '[&_h4]:text-xl [&_h4]:font-semibold [&_h4]:text-foreground [&_h4]:mt-3 [&_h4]:mb-2',
247
- '[&_h5]:text-lg [&_h5]:font-semibold [&_h5]:text-foreground [&_h5]:mt-3 [&_h5]:mb-2',
248
- '[&_h6]:text-base [&_h6]:font-semibold [&_h6]:text-foreground [&_h6]:mt-3 [&_h6]:mb-2',
249
- // Paragraphs
250
- '[&_p]:my-3 [&_p]:leading-7',
251
- // Lists - general styling with consistent spacing
252
- '[&_ul]:my-3 [&_ul]:ml-6 [&_ul]:px-0 [&_ul]:mr-0',
253
- '[&_ol]:my-3 [&_ol]:ml-6 [&_ol]:px-0 [&_ol]:mr-0',
254
- '[&_li]:my-1 [&_li]:leading-7',
255
- '[&_ul_li_p]:my-1',
256
- '[&_ol_li_p]:my-1',
257
- '[&_li_h1]:text-4xl [&_li_h2]:text-3xl [&_li_h3]:text-2xl',
258
- // Task list specific styles - match regular list spacing
259
- '[&_ul[data-type="taskList"]]:list-none [&_ul[data-type="taskList"]]:ml-6 [&_ul[data-type="taskList"]]:pl-0 [&_ul[data-type="taskList"]]:pr-0 [&_ul[data-type="taskList"]]:mr-0 [&_ul[data-type="taskList"]]:my-3',
260
- '[&_ul[data-type="taskList"]_li]:flex [&_ul[data-type="taskList"]_li]:items-start [&_ul[data-type="taskList"]_li]:my-1',
261
- '[&_ul[data-type="taskList"]_.task-list-checkbox-label]:flex-[0_0_auto]',
262
- '[&_ul[data-type="taskList"]_li>div:not(.task-list-checkbox-label)]:flex-1 [&_ul[data-type="taskList"]_li>div:not(.task-list-checkbox-label)]:min-w-0',
263
- '[&_ul[data-type="taskList"]_li_p]:my-1',
264
- // Checkbox styling
265
- '[&_ul[data-type="taskList"]_input[type="checkbox"]]:appearance-none [&_ul[data-type="taskList"]_input[type="checkbox"]]:h-[18px] [&_ul[data-type="taskList"]_input[type="checkbox"]]:w-[18px]',
266
- '[&_ul[data-type="taskList"]_input[type="checkbox"]]:cursor-pointer [&_ul[data-type="taskList"]_input[type="checkbox"]]:rounded-[4px] [&_ul[data-type="taskList"]_input[type="checkbox"]]:border-2 [&_ul[data-type="taskList"]_input[type="checkbox"]]:border-input',
267
- '[&_ul[data-type="taskList"]_input[type="checkbox"]]:bg-background [&_ul[data-type="taskList"]_input[type="checkbox"]]:transition-all [&_ul[data-type="taskList"]_input[type="checkbox"]]:duration-150',
268
- '[&_ul[data-type="taskList"]_input[type="checkbox"]]:shrink-0',
269
- '[&_ul[data-type="taskList"]_input[type="checkbox"]:hover]:border-dynamic-gray [&_ul[data-type="taskList"]_input[type="checkbox"]:hover]:bg-dynamic-gray/10 [&_ul[data-type="taskList"]_input[type="checkbox"]:hover]:scale-105',
270
- '[&_ul[data-type="taskList"]_input[type="checkbox"]:focus]:outline-none [&_ul[data-type="taskList"]_input[type="checkbox"]:focus]:ring-2 [&_ul[data-type="taskList"]_input[type="checkbox"]:focus]:ring-dynamic-gray/30 [&_ul[data-type="taskList"]_input[type="checkbox"]:focus]:ring-offset-2 [&_ul[data-type="taskList"]_input[type="checkbox"]:focus]:border-dynamic-gray',
271
- '[&_ul[data-type="taskList"]_input[type="checkbox"]:checked]:bg-dynamic-green/20 [&_ul[data-type="taskList"]_input[type="checkbox"]:checked]:border-dynamic-green',
272
- '[&_ul[data-type="taskList"]_input[type="checkbox"]:checked:hover]:bg-dynamic-green/10 [&_ul[data-type="taskList"]_input[type="checkbox"]:checked:hover]:border-dynamic-green',
273
- // Light mode checkmark (dark/black)
274
- `[&_ul[data-type="taskList"]_input[type="checkbox"]:checked]:bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cpath%20fill%3D%22none%22%20stroke%3D%22%2309090b%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%222%22%20d%3D%22M4%208l2.5%202.5L12%205%22%2F%3E%3C%2Fsvg%3E')]`,
275
- // Dark mode checkmark (white)
276
- `dark:[&_ul[data-type="taskList"]_input[type="checkbox"]:checked]:bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cpath%20fill%3D%22none%22%20stroke%3D%22white%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%222%22%20d%3D%22M4%208l2.5%202.5L12%205%22%2F%3E%3C%2Fsvg%3E')]`,
277
- '[&_ul[data-type="taskList"]_input[type="checkbox"]:checked:bg-[length:14px_14px]',
278
- // Indeterminate checkbox styling (fallback)
279
- '[&_ul[data-type="taskList"]_input[type="checkbox"]:indeterminate]:bg-dynamic-orange/20 [&_ul[data-type="taskList"]_input[type="checkbox"]:indeterminate]:border-dynamic-orange',
280
- // Nested task lists
281
- '[&_ul[data-type="taskList"]_ul[data-type="taskList"]]:my-0 [&_ul[data-type="taskList"]_ul[data-type="taskList"]]:ml-0',
282
- // Blockquotes
283
- '[&_blockquote]:border-l-4 [&_blockquote]:border-dynamic-border [&_blockquote]:pl-4 [&_blockquote]:my-4',
284
- '[&_blockquote]:text-muted-foreground [&_blockquote]:italic',
285
- // Code
286
- '[&_code]:text-dynamic-pink [&_code]:bg-dynamic-pink/10 [&_code]:px-1.5 [&_code]:py-0.5',
287
- '[&_code]:rounded [&_code]:text-sm [&_code]:font-mono',
288
- '[&_pre]:bg-dynamic-border/50 [&_pre]:p-4 [&_pre]:rounded-md [&_pre]:my-4 [&_pre]:overflow-x-auto',
289
- '[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-foreground',
290
- // Strong/Bold
291
- '[&_strong]:font-bold [&_strong]:text-foreground',
292
- '[&_b]:font-bold [&_b]:text-foreground',
293
- // Emphasis/Italic
294
- '[&_em]:italic [&_em]:text-foreground',
295
- '[&_i]:italic [&_i]:text-foreground',
296
- // Links (ensure they maintain cyan color even when bold)
297
- '[&_a]:text-dynamic-cyan [&_a]:underline [&_a]:cursor-pointer',
298
- '[&_a:hover]:text-dynamic-cyan/80',
299
- '[&_a_strong]:text-dynamic-cyan [&_a_b]:text-dynamic-cyan',
300
- '[&_strong_a]:text-dynamic-cyan [&_b_a]:text-dynamic-cyan',
301
- // Horizontal rule
302
- '[&_hr]:border-dynamic-border [&_hr]:my-8',
303
- // Tables - Enhanced styling for better UX
304
- '[&_table]:my-6 [&_table]:w-full [&_table]:overflow-hidden [&_table]:rounded-lg [&_table]:border [&_table]:border-collapse [&_table]:border-dynamic-border',
305
- '[&_table]:shadow-sm',
306
- // Table headers
307
- '[&_th]:relative [&_th]:border-r [&_th]:border-dynamic-border [&_th]:bg-dynamic-border/30',
308
- '[&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-semibold [&_th]:text-foreground',
309
- '[&_th]:last:border-r-0 [&_th_p]:my-0',
310
- // Table rows
311
- '[&_tr]:border-b [&_tr]:border-dynamic-border [&_tr]:last:border-0',
312
- '[&_tr]:transition-colors [&_tr:hover]:bg-dynamic-surface/30',
313
- // Table cells
314
- '[&_td]:relative [&_td]:border-r [&_td]:border-dynamic-border [&_td]:px-4 [&_td]:py-3',
315
- '[&_td]:last:border-r-0 [&_td_p]:my-0',
316
- '[&_td]:transition-colors [&_td:hover]:bg-dynamic-surface/50',
317
- '[&_td:focus]:bg-dynamic-surface/70 [&_td:focus]:outline-none',
318
- '[&_td:focus]:ring-2 [&_td:focus]:ring-dynamic-blue/50 [&_td:focus]:ring-inset',
319
- // Selected cells/rows
320
- '[&_.selectedCell]:bg-dynamic-blue/10 [&_.selectedCell]:ring-2 [&_.selectedCell]:ring-dynamic-blue/30',
321
- // Column resize handle
322
- '[&_.column-resize-handle]:absolute [&_.column-resize-handle]:right-[-2px] [&_.column-resize-handle]:top-0',
323
- '[&_.column-resize-handle]:bottom-0 [&_.column-resize-handle]:w-1 [&_.column-resize-handle]:cursor-col-resize',
324
- '[&_.column-resize-handle]:bg-dynamic-blue/50 [&_.column-resize-handle]:opacity-0',
325
- '[&_.column-resize-handle:hover]:opacity-100 [&_.column-resize-handle]:transition-opacity',
326
- // Placeholder styles
327
- '[&_*:is(p,h1,h2,h3).is-empty::before]:content-[attr(data-placeholder)]',
328
- '[&_*:is(p,h1,h2,h3).is-empty::before]:text-muted-foreground',
329
- '[&_*:is(p,h1,h2,h3).is-empty::before]:float-left',
330
- '[&_*:is(p,h1,h2,h3).is-empty::before]:h-0',
331
- '[&_*:is(p,h1,h2,h3).is-empty::before]:pointer-events-none',
332
- // Read-only specific styles
333
- readOnly
334
- ? '[&_ul[data-type="taskList"]_input[type="checkbox"]]:!pointer-events-none [&_ul[data-type="taskList"]_input[type="checkbox"]]:!opacity-70 [&_ul[data-type="taskList"]_li>label]:!pointer-events-none [&_input[type="checkbox"]]:!pointer-events-none [&_.task-list-checkbox]:!pointer-events-none [&_.task-list-checkbox-label]:!pointer-events-none [&_li_div[draggable="true"]]:hidden [&_ul[data-type="taskList"]_li]:![-webkit-user-drag:none] [&_ul[data-type="taskList"]_li]:!user-drag-none'
335
- : '',
336
- // Hide drag handle in read-only mode (double safety, though conditional render should handle it)
337
- readOnly ? '[&_.drag-handle]:hidden' : '',
338
- className,
339
- ].filter(Boolean);
340
- return baseClasses.join(' ');
341
- }, [className, readOnly]);
236
+ const getEditorClasses = useMemo(
237
+ () => getRichTextEditorClasses({ className, readOnly }),
238
+ [className, readOnly]
239
+ );
342
240
 
343
241
  const editor = useEditor(
344
242
  {
@@ -495,49 +393,7 @@ export function RichTextEditor({
495
393
  }
496
394
  }
497
395
 
498
- // Handle Tab for list indentation while maintaining focus
499
- if (event.key === 'Tab') {
500
- // Always prevent default Tab behavior to avoid focus loss
501
- event.preventDefault();
502
- event.stopPropagation();
503
-
504
- const { $from } = selection;
505
-
506
- // Check if we're in a regular list item (bullet or ordered)
507
- const isInListItem =
508
- $from.node(-1)?.type.name === 'listItem' ||
509
- $from.node(-2)?.type.name === 'listItem';
510
-
511
- // Check if we're in a task item (checkbox)
512
- const isInTaskItem =
513
- $from.node(-1)?.type.name === 'taskItem' ||
514
- $from.node(-2)?.type.name === 'taskItem';
515
-
516
- if (isInListItem) {
517
- if (event.shiftKey) {
518
- // Shift+Tab: Outdent (lift) the list item
519
- editor?.chain().focus().liftListItem('listItem').run();
520
- } else {
521
- // Tab: Indent (sink) the list item
522
- editor?.chain().focus().sinkListItem('listItem').run();
523
- }
524
- } else if (isInTaskItem) {
525
- if (event.shiftKey) {
526
- // Shift+Tab: Outdent (lift) the task item
527
- editor?.chain().focus().liftListItem('taskItem').run();
528
- } else {
529
- // Tab: Indent (sink) the task item
530
- editor?.chain().focus().sinkListItem('taskItem').run();
531
- }
532
- }
533
-
534
- // Ensure focus stays in the editor
535
- setTimeout(() => {
536
- view.focus();
537
- }, 0);
538
-
539
- return true; // We handled the event
540
- }
396
+ if (handleListIndentation(view, event)) return true;
541
397
 
542
398
  return false;
543
399
  },
@@ -779,6 +635,7 @@ export function RichTextEditor({
779
635
  workspaceId={workspaceId}
780
636
  onImageUpload={onImageUpload}
781
637
  onConvertToTask={onConvertToTask}
638
+ toggleBlockLabel={toggleBlockLabel}
782
639
  />
783
640
  )}
784
641
  {!readOnly && (
@@ -790,6 +647,7 @@ export function RichTextEditor({
790
647
  workspaceId={workspaceId}
791
648
  onImageUpload={onImageUpload}
792
649
  onConvertToTask={onConvertToTask}
650
+ toggleBlockLabel={toggleBlockLabel}
793
651
  />
794
652
  )}
795
653
  {/* Temporarily hide drag handle to resolve 'removeChild' error until finding a more robust solution
@@ -2,6 +2,7 @@ import { InputRule } from '@tiptap/core';
2
2
  import Collaboration from '@tiptap/extension-collaboration';
3
3
  import CollaborationCaret from '@tiptap/extension-collaboration-caret';
4
4
  import { Color } from '@tiptap/extension-color';
5
+ import { Details, DetailsContent } from '@tiptap/extension-details';
5
6
  import HorizontalRule from '@tiptap/extension-horizontal-rule';
6
7
  import Link from '@tiptap/extension-link';
7
8
  import { TaskList } from '@tiptap/extension-list';
@@ -30,6 +31,7 @@ import { Mention, type TaskMentionNodeViewRenderer } from './mention-extension';
30
31
  import { NodeDrag } from './node-drag-extension';
31
32
  import { TaskItemCheckbox } from './task-item-checkbox-extension';
32
33
  import { TextShortcuts } from './text-shortcuts-extension';
34
+ import { ToggleBlock, ToggleSummary } from './toggle-block-extension';
33
35
  import { Video } from './video-extension';
34
36
 
35
37
  interface EditorExtensionsOptions {
@@ -181,6 +183,23 @@ export function getEditorExtensions({
181
183
  Strike,
182
184
  Subscript,
183
185
  Superscript,
186
+ Details.configure({
187
+ persist: true,
188
+ HTMLAttributes: {
189
+ class: 'toggle-details',
190
+ },
191
+ renderToggleButton: ({ element, isOpen }) => {
192
+ element.className = 'toggle-details-button';
193
+ element.setAttribute(
194
+ 'aria-label',
195
+ isOpen ? 'Collapse toggle' : 'Expand toggle'
196
+ );
197
+ element.setAttribute('aria-expanded', String(isOpen));
198
+ },
199
+ }),
200
+ ToggleSummary,
201
+ DetailsContent,
202
+ ToggleBlock,
184
203
  Mention.configure({
185
204
  renderTaskMention,
186
205
  translations: mentionTranslations,