@tuturuuu/ui 0.19.1 → 0.21.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 +113 -0
- package/biome.json +1 -1
- package/package.json +60 -48
- package/src/components/ui/badge.tsx +2 -0
- package/src/components/ui/button.tsx +2 -0
- package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +48 -4
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +33 -0
- package/src/components/ui/custom/__tests__/workspace-select-reveal.test.tsx +88 -0
- package/src/components/ui/custom/animated-slot-text.tsx +20 -0
- package/src/components/ui/custom/common-footer.tsx +262 -237
- package/src/components/ui/custom/language-dropdown-item.tsx +3 -10
- package/src/components/ui/custom/language-toggle.test.tsx +30 -0
- package/src/components/ui/custom/language-toggle.tsx +11 -10
- package/src/components/ui/custom/locale-preference.ts +32 -0
- package/src/components/ui/custom/settings/appearance-settings.tsx +5 -13
- package/src/components/ui/custom/settings-dialog-shell.tsx +40 -9
- package/src/components/ui/custom/structure.tsx +12 -0
- package/src/components/ui/custom/system-language-dropdown-item.tsx +3 -6
- package/src/components/ui/custom/workspace-access/adapters.test.ts +10 -1
- package/src/components/ui/custom/workspace-access/adapters.ts +36 -4
- package/src/components/ui/custom/workspace-access/member-filter-utils.test.ts +14 -0
- package/src/components/ui/custom/workspace-access/member-filter-utils.ts +8 -0
- package/src/components/ui/custom/workspace-access/types.ts +20 -0
- package/src/components/ui/custom/workspace-access/workspace-access-context.test.tsx +111 -0
- package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +15 -5
- package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.tsx +155 -48
- package/src/components/ui/custom/workspace-access/workspace-access-member-profile-dialog.tsx +92 -0
- package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +171 -76
- package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +11 -2
- package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +11 -11
- package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +180 -22
- package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +29 -15
- package/src/components/ui/custom/workspace-access/workspace-access-permission-checklist.tsx +56 -10
- package/src/components/ui/custom/workspace-access/workspace-access-permission-preview.test.ts +33 -0
- package/src/components/ui/custom/workspace-access/workspace-access-permission-preview.tsx +24 -1
- package/src/components/ui/custom/workspace-access/workspace-access-responsive.test.ts +64 -0
- package/src/components/ui/custom/workspace-access/workspace-access-role-editor-dialog.tsx +28 -16
- package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +35 -11
- package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +23 -11
- package/src/components/ui/custom/workspace-select-helpers.ts +5 -3
- package/src/components/ui/custom/workspace-select-reveal.tsx +46 -0
- package/src/components/ui/custom/workspace-select.tsx +13 -5
- package/src/components/ui/finance/shared/charts/monthly-total-chart-client.tsx +1 -1
- package/src/components/ui/finance/shared/charts/monthly-total-chart.tsx +1 -1
- package/src/components/ui/storefront/cart-summary.tsx +12 -2
- package/src/components/ui/storefront/storefront-surface.test.tsx +21 -0
- package/src/components/ui/storefront/storefront-surface.tsx +6 -0
- package/src/components/ui/text-editor/__tests__/collaboration-binding.test.tsx +161 -0
- package/src/components/ui/text-editor/editor.tsx +267 -235
- package/src/globals.css +166 -0
- package/src/hooks/__tests__/use-workspace-identity-mutation.test.tsx +181 -0
- package/src/hooks/use-workspace-identity-mutation.ts +136 -0
|
@@ -59,6 +59,14 @@ function syncEditorContent(editor: Editor, nextContent: JSONContent | null) {
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Hides the formatting toolbar until focus lands inside the editor. Scoped to the
|
|
64
|
+
* `group` wrapper below so focus on a toolbar button keeps it visible — otherwise
|
|
65
|
+
* the toolbar would disappear on mousedown, before the click could land.
|
|
66
|
+
*/
|
|
67
|
+
export const REVEAL_TOOLBAR_ON_FOCUS_CLASS_NAME =
|
|
68
|
+
'pointer-events-none opacity-0 transition-opacity duration-200 group-focus-within:pointer-events-auto group-focus-within:opacity-100';
|
|
69
|
+
|
|
62
70
|
function serializeEditorContent(content: JSONContent | null) {
|
|
63
71
|
return JSON.stringify(content ?? { type: 'doc', content: [] });
|
|
64
72
|
}
|
|
@@ -90,6 +98,14 @@ export interface RichTextEditorProps {
|
|
|
90
98
|
queryClient?: QueryClient;
|
|
91
99
|
onConvertToTask?: () => void | Promise<void>;
|
|
92
100
|
allowCollaboration?: boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Keep the formatting toolbar transparent until focus is somewhere inside the
|
|
103
|
+
* editor, so a read-first surface (like the task dialog) is not fronted by a
|
|
104
|
+
* row of controls. Focus-within — not editor focus — because clicking a
|
|
105
|
+
* toolbar button moves focus out of the text and would otherwise hide the
|
|
106
|
+
* toolbar mid-click.
|
|
107
|
+
*/
|
|
108
|
+
revealToolbarOnFocus?: boolean;
|
|
93
109
|
/** Translations for mention chip dialogs */
|
|
94
110
|
mentionTranslations?: {
|
|
95
111
|
delete_task?: string;
|
|
@@ -137,6 +153,7 @@ export function RichTextEditor({
|
|
|
137
153
|
yjsProvider,
|
|
138
154
|
collaborationUser,
|
|
139
155
|
allowCollaboration = false,
|
|
156
|
+
revealToolbarOnFocus = false,
|
|
140
157
|
mentionTranslations,
|
|
141
158
|
renderTaskMention,
|
|
142
159
|
}: RichTextEditorProps) {
|
|
@@ -158,6 +175,26 @@ export function RichTextEditor({
|
|
|
158
175
|
[]
|
|
159
176
|
);
|
|
160
177
|
|
|
178
|
+
// Collaboration bindings decide which extensions the editor is built with, and
|
|
179
|
+
// `Editor.setOptions({ extensions })` does NOT rebuild the extension manager —
|
|
180
|
+
// it only refreshes editor props. So a binding change has to recreate the
|
|
181
|
+
// editor through `useEditor`'s dependency array. Without this, an editor first
|
|
182
|
+
// created while collaboration was still off (e.g. a task dialog opened from a
|
|
183
|
+
// deep link, which mounts before the task has hydrated) never gains the
|
|
184
|
+
// Collaboration extension, and content that lives only in the Yjs document can
|
|
185
|
+
// never render.
|
|
186
|
+
//
|
|
187
|
+
// ONLY the document binding belongs in that dependency array. The Yjs document
|
|
188
|
+
// is what the content lives in, so getting it wrong is a correctness bug worth
|
|
189
|
+
// a rebuild. The provider only feeds CollaborationCaret (remote cursor labels),
|
|
190
|
+
// which is cosmetic and normally already attached because the provider exists
|
|
191
|
+
// before this subtree mounts — rebuilding a live editor for it would tear down
|
|
192
|
+
// the ProseMirror view (losing selection and scroll position) and re-run the
|
|
193
|
+
// Yjs binding over the whole document mid-session, which on a large document is
|
|
194
|
+
// an expensive, user-visible hiccup for no content benefit.
|
|
195
|
+
const collaborationDoc = allowCollaboration ? yjsDoc : undefined;
|
|
196
|
+
const collaborationProvider = allowCollaboration ? yjsProvider : undefined;
|
|
197
|
+
|
|
161
198
|
useEffect(() => {
|
|
162
199
|
onImageUploadRef.current = onImageUpload;
|
|
163
200
|
workspaceIdRef.current = workspaceId;
|
|
@@ -303,271 +340,234 @@ export function RichTextEditor({
|
|
|
303
340
|
return baseClasses.join(' ');
|
|
304
341
|
}, [className, readOnly]);
|
|
305
342
|
|
|
306
|
-
const editor = useEditor(
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
event.
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
if (handlePlainEnterFallback(view, event)) {
|
|
340
|
-
return true;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
const { state, dispatch } = view;
|
|
344
|
-
const { selection } = state;
|
|
345
|
-
const { $from } = selection;
|
|
343
|
+
const editor = useEditor(
|
|
344
|
+
{
|
|
345
|
+
extensions: getEditorExtensions({
|
|
346
|
+
titlePlaceholder,
|
|
347
|
+
writePlaceholder,
|
|
348
|
+
doc: collaborationDoc,
|
|
349
|
+
provider: collaborationProvider,
|
|
350
|
+
collaborationUser: allowCollaboration ? collaborationUser : undefined,
|
|
351
|
+
onImageUpload,
|
|
352
|
+
onVideoUpload: onImageUpload,
|
|
353
|
+
getOnImageUpload: getDelegatedImageUpload,
|
|
354
|
+
getOnVideoUpload: getDelegatedImageUpload,
|
|
355
|
+
mentionTranslations,
|
|
356
|
+
renderTaskMention,
|
|
357
|
+
readOnly,
|
|
358
|
+
}),
|
|
359
|
+
// Migrate inline images to block-level for backward compatibility
|
|
360
|
+
content: allowCollaboration
|
|
361
|
+
? undefined
|
|
362
|
+
: migrateInlineImagesToBlock(content),
|
|
363
|
+
editable: !readOnly,
|
|
364
|
+
immediatelyRender: false,
|
|
365
|
+
editorProps: {
|
|
366
|
+
attributes: {
|
|
367
|
+
class: getEditorClasses,
|
|
368
|
+
},
|
|
369
|
+
handleKeyDown: (view, event) => {
|
|
370
|
+
// Prevent Ctrl+Enter / Cmd+Enter from creating a new line
|
|
371
|
+
// Let the parent component handle the save action
|
|
372
|
+
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
|
373
|
+
event.preventDefault();
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
346
376
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
// If there's a text selection, let default behavior handle deletion
|
|
350
|
-
if (!selection.empty) {
|
|
351
|
-
return false;
|
|
377
|
+
if (handlePlainEnterFallback(view, event)) {
|
|
378
|
+
return true;
|
|
352
379
|
}
|
|
353
380
|
|
|
354
|
-
|
|
381
|
+
const { state, dispatch } = view;
|
|
382
|
+
const { selection } = state;
|
|
383
|
+
const { $from } = selection;
|
|
355
384
|
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
Math.abs(coordsAtCursor.top - coordsAtStart.top) < 5;
|
|
385
|
+
// Handle Backspace
|
|
386
|
+
if (event.key === 'Backspace' && onArrowUpRef.current) {
|
|
387
|
+
// If there's a text selection, let default behavior handle deletion
|
|
388
|
+
if (!selection.empty) {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
363
391
|
|
|
364
|
-
|
|
392
|
+
console.log('Backspace at pos:', $from.pos);
|
|
365
393
|
|
|
366
|
-
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
});
|
|
394
|
+
// Check if we're on the first line
|
|
395
|
+
const coordsAtCursor = view.coordsAtPos($from.pos);
|
|
396
|
+
const coordsAtStart = view.coordsAtPos(1);
|
|
397
|
+
const isOnFirstLine =
|
|
398
|
+
coordsAtCursor &&
|
|
399
|
+
coordsAtStart &&
|
|
400
|
+
Math.abs(coordsAtCursor.top - coordsAtStart.top) < 5;
|
|
374
401
|
|
|
375
|
-
|
|
376
|
-
if ($from.pos === 1) {
|
|
377
|
-
const firstChild = state.doc.firstChild;
|
|
402
|
+
console.log('Is on first line:', isOnFirstLine);
|
|
378
403
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
404
|
+
if (isOnFirstLine) {
|
|
405
|
+
const firstChild = state.doc.firstChild;
|
|
406
|
+
console.log('First child:', {
|
|
407
|
+
type: firstChild?.type.name,
|
|
408
|
+
text: firstChild?.textContent,
|
|
409
|
+
isEmpty: firstChild?.textContent.trim() === '',
|
|
410
|
+
nodeSize: firstChild?.nodeSize,
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// If cursor is at the absolute start (position 1)
|
|
414
|
+
if ($from.pos === 1) {
|
|
415
|
+
const firstChild = state.doc.firstChild;
|
|
416
|
+
|
|
417
|
+
// If first line is empty and there's a second line, delete the empty line
|
|
418
|
+
if (firstChild && firstChild.textContent.trim() === '') {
|
|
419
|
+
const secondChild = state.doc.maybeChild(1);
|
|
420
|
+
if (secondChild) {
|
|
421
|
+
console.log(
|
|
422
|
+
'Empty first line - manually deleting via commands'
|
|
423
|
+
);
|
|
424
|
+
event.preventDefault();
|
|
425
|
+
|
|
426
|
+
// Use commands to delete the node
|
|
427
|
+
const tr = state.tr;
|
|
428
|
+
const nodeSize = firstChild.nodeSize;
|
|
429
|
+
|
|
430
|
+
// Delete from position 0 to end of first child (including the node itself)
|
|
431
|
+
tr.delete(0, nodeSize);
|
|
432
|
+
|
|
433
|
+
// Dispatch and trigger onChange manually
|
|
434
|
+
dispatch(tr);
|
|
435
|
+
|
|
436
|
+
// Manually trigger onChange since we're in handleKeyDown
|
|
437
|
+
if (!readOnly && onChangeRef.current) {
|
|
438
|
+
const newJson = tr.doc.toJSON();
|
|
439
|
+
onChangeRef.current(hasContent(newJson) ? newJson : null);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return true;
|
|
402
443
|
}
|
|
403
|
-
|
|
404
|
-
return true;
|
|
405
444
|
}
|
|
445
|
+
|
|
446
|
+
// If first line is NOT empty, go to title
|
|
447
|
+
console.log('Non-empty first line - going to title');
|
|
448
|
+
event.preventDefault();
|
|
449
|
+
onArrowUpRef.current();
|
|
450
|
+
return true;
|
|
406
451
|
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
407
454
|
|
|
408
|
-
|
|
409
|
-
|
|
455
|
+
// Handle ArrowUp when on the first line
|
|
456
|
+
if (event.key === 'ArrowUp' && onArrowUpRef.current) {
|
|
457
|
+
// Try to resolve a position one line up by checking textBetween
|
|
458
|
+
// If we're at the very start of the document (pos 1), go to title
|
|
459
|
+
if ($from.pos === 1) {
|
|
410
460
|
event.preventDefault();
|
|
411
|
-
onArrowUpRef.current();
|
|
461
|
+
onArrowUpRef.current(0); // At the start, offset is 0
|
|
412
462
|
return true;
|
|
413
463
|
}
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// Handle ArrowUp when on the first line
|
|
418
|
-
if (event.key === 'ArrowUp' && onArrowUpRef.current) {
|
|
419
|
-
// Try to resolve a position one line up by checking textBetween
|
|
420
|
-
// If we're at the very start of the document (pos 1), go to title
|
|
421
|
-
if ($from.pos === 1) {
|
|
422
|
-
event.preventDefault();
|
|
423
|
-
onArrowUpRef.current(0); // At the start, offset is 0
|
|
424
|
-
return true;
|
|
425
|
-
}
|
|
426
464
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
465
|
+
// Check if we're in a position where up arrow won't move us
|
|
466
|
+
// This happens when we're on the first line of the first block
|
|
467
|
+
const coordsAtCursor = view.coordsAtPos($from.pos);
|
|
468
|
+
const coordsAtStart = view.coordsAtPos(1);
|
|
469
|
+
|
|
470
|
+
// If cursor is on the same line as the start, go to title
|
|
471
|
+
if (
|
|
472
|
+
coordsAtCursor &&
|
|
473
|
+
coordsAtStart &&
|
|
474
|
+
Math.abs(coordsAtCursor.top - coordsAtStart.top) < 5
|
|
475
|
+
) {
|
|
476
|
+
event.preventDefault();
|
|
439
477
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
478
|
+
// Calculate character offset from start of the first line
|
|
479
|
+
// Position 1 is the start of the document, $from.pos is current cursor
|
|
480
|
+
// Since we're on the first line, the offset is simply the distance from position 1
|
|
481
|
+
const offset = $from.pos - 1;
|
|
444
482
|
|
|
445
|
-
|
|
446
|
-
|
|
483
|
+
onArrowUpRef.current(offset);
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
447
486
|
}
|
|
448
|
-
}
|
|
449
487
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
488
|
+
// Handle ArrowLeft when at the very start of the document
|
|
489
|
+
if (event.key === 'ArrowLeft' && onArrowLeftRef.current) {
|
|
490
|
+
// If we're at position 1 (start of document), go back to title
|
|
491
|
+
if ($from.pos === 1) {
|
|
492
|
+
event.preventDefault();
|
|
493
|
+
onArrowLeftRef.current();
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
457
496
|
}
|
|
458
|
-
}
|
|
459
497
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
+
}
|
|
465
533
|
|
|
466
|
-
|
|
534
|
+
// Ensure focus stays in the editor
|
|
535
|
+
setTimeout(() => {
|
|
536
|
+
view.focus();
|
|
537
|
+
}, 0);
|
|
467
538
|
|
|
468
|
-
|
|
469
|
-
const isInListItem =
|
|
470
|
-
$from.node(-1)?.type.name === 'listItem' ||
|
|
471
|
-
$from.node(-2)?.type.name === 'listItem';
|
|
472
|
-
|
|
473
|
-
// Check if we're in a task item (checkbox)
|
|
474
|
-
const isInTaskItem =
|
|
475
|
-
$from.node(-1)?.type.name === 'taskItem' ||
|
|
476
|
-
$from.node(-2)?.type.name === 'taskItem';
|
|
477
|
-
|
|
478
|
-
if (isInListItem) {
|
|
479
|
-
if (event.shiftKey) {
|
|
480
|
-
// Shift+Tab: Outdent (lift) the list item
|
|
481
|
-
editor?.chain().focus().liftListItem('listItem').run();
|
|
482
|
-
} else {
|
|
483
|
-
// Tab: Indent (sink) the list item
|
|
484
|
-
editor?.chain().focus().sinkListItem('listItem').run();
|
|
485
|
-
}
|
|
486
|
-
} else if (isInTaskItem) {
|
|
487
|
-
if (event.shiftKey) {
|
|
488
|
-
// Shift+Tab: Outdent (lift) the task item
|
|
489
|
-
editor?.chain().focus().liftListItem('taskItem').run();
|
|
490
|
-
} else {
|
|
491
|
-
// Tab: Indent (sink) the task item
|
|
492
|
-
editor?.chain().focus().sinkListItem('taskItem').run();
|
|
493
|
-
}
|
|
539
|
+
return true; // We handled the event
|
|
494
540
|
}
|
|
495
541
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
542
|
+
return false;
|
|
543
|
+
},
|
|
544
|
+
},
|
|
545
|
+
onCreate: ({ editor }) => {
|
|
546
|
+
if (externalEditorRef) {
|
|
547
|
+
externalEditorRef.current = editor;
|
|
502
548
|
}
|
|
503
|
-
|
|
504
|
-
return false;
|
|
549
|
+
onEditorReady?.(editor);
|
|
505
550
|
},
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
externalEditorRef.current = editor;
|
|
510
|
-
}
|
|
511
|
-
onEditorReady?.(editor);
|
|
512
|
-
},
|
|
513
|
-
onUpdate: ({ editor }) => {
|
|
514
|
-
const currentJson = editor.getJSON();
|
|
515
|
-
const normalizedContent = hasContent(currentJson) ? currentJson : null;
|
|
551
|
+
onUpdate: ({ editor }) => {
|
|
552
|
+
const currentJson = editor.getJSON();
|
|
553
|
+
const normalizedContent = hasContent(currentJson) ? currentJson : null;
|
|
516
554
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
555
|
+
if (!readOnly) {
|
|
556
|
+
onImmediateChangeRef.current?.(normalizedContent);
|
|
557
|
+
}
|
|
520
558
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
559
|
+
// Don't call onChange when using collaboration - Yjs doc is the source of truth
|
|
560
|
+
if (!readOnly && !allowCollaboration) {
|
|
561
|
+
debouncedOnChange(currentJson);
|
|
562
|
+
}
|
|
563
|
+
},
|
|
564
|
+
// Rebuild the editor when the Yjs document binding changes, so the
|
|
565
|
+
// Collaboration extension is attached at creation time. `yjsDoc` is
|
|
566
|
+
// identity-stable (memoized in useYjsCollaboration), so a plain
|
|
567
|
+
// non-collaborative editor never recreates.
|
|
525
568
|
},
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
// Recreate editor when collaboration provider becomes available so the
|
|
529
|
-
// CollaborationCaret extension is included. The provider is null on first
|
|
530
|
-
// render because it's created asynchronously in useEffect; this dep
|
|
531
|
-
// ensures the editor re-initializes once the provider connects.
|
|
532
|
-
const prevProviderRef = useRef(yjsProvider);
|
|
533
|
-
useEffect(() => {
|
|
534
|
-
if (!editor) return;
|
|
535
|
-
if (prevProviderRef.current === yjsProvider) return;
|
|
536
|
-
prevProviderRef.current = yjsProvider;
|
|
537
|
-
|
|
538
|
-
// Provider transitioned from null → available: recreate with extensions
|
|
539
|
-
if (yjsProvider && allowCollaboration) {
|
|
540
|
-
editor.setOptions({
|
|
541
|
-
extensions: getEditorExtensions({
|
|
542
|
-
titlePlaceholder,
|
|
543
|
-
writePlaceholder,
|
|
544
|
-
doc: yjsDoc,
|
|
545
|
-
provider: yjsProvider,
|
|
546
|
-
collaborationUser: collaborationUser,
|
|
547
|
-
onImageUpload,
|
|
548
|
-
onVideoUpload: onImageUpload,
|
|
549
|
-
getOnImageUpload: getDelegatedImageUpload,
|
|
550
|
-
getOnVideoUpload: getDelegatedImageUpload,
|
|
551
|
-
mentionTranslations,
|
|
552
|
-
renderTaskMention,
|
|
553
|
-
readOnly,
|
|
554
|
-
}),
|
|
555
|
-
});
|
|
556
|
-
}
|
|
557
|
-
}, [
|
|
558
|
-
editor,
|
|
559
|
-
yjsProvider,
|
|
560
|
-
yjsDoc,
|
|
561
|
-
allowCollaboration,
|
|
562
|
-
collaborationUser,
|
|
563
|
-
onImageUpload,
|
|
564
|
-
titlePlaceholder,
|
|
565
|
-
writePlaceholder,
|
|
566
|
-
mentionTranslations,
|
|
567
|
-
renderTaskMention,
|
|
568
|
-
readOnly,
|
|
569
|
-
getDelegatedImageUpload,
|
|
570
|
-
]);
|
|
569
|
+
[collaborationDoc]
|
|
570
|
+
);
|
|
571
571
|
|
|
572
572
|
useEffect(() => {
|
|
573
573
|
if (!editor || !allowCollaboration || !yjsProvider || !collaborationUser) {
|
|
@@ -721,6 +721,9 @@ export function RichTextEditor({
|
|
|
721
721
|
// Track whether the fixed toolbar is visible in the viewport.
|
|
722
722
|
// When it is, we suppress the floating BubbleMenu to avoid duplication.
|
|
723
723
|
const fixedToolbarRef = useRef<HTMLDivElement>(null);
|
|
724
|
+
// Mirrors focus-within for the toolbar handlers below. A ref (not state)
|
|
725
|
+
// because the listeners are attached once and read it at event time.
|
|
726
|
+
const hasFocusWithinRef = useRef(false);
|
|
724
727
|
const [fixedToolbarVisible, setFixedToolbarVisible] = useState(true);
|
|
725
728
|
|
|
726
729
|
useEffect(() => {
|
|
@@ -739,10 +742,39 @@ export function RichTextEditor({
|
|
|
739
742
|
}, []);
|
|
740
743
|
|
|
741
744
|
return (
|
|
742
|
-
<div
|
|
745
|
+
<div
|
|
746
|
+
className="group relative h-full"
|
|
747
|
+
onBlurCapture={(event) => {
|
|
748
|
+
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
|
749
|
+
hasFocusWithinRef.current = false;
|
|
750
|
+
}
|
|
751
|
+
}}
|
|
752
|
+
onFocusCapture={() => {
|
|
753
|
+
hasFocusWithinRef.current = true;
|
|
754
|
+
}}
|
|
755
|
+
onMouseDown={(event) => {
|
|
756
|
+
// While the toolbar is hidden it is `pointer-events-none`, so it never
|
|
757
|
+
// shows a hover state or a tooltip — and a press in that strip lands
|
|
758
|
+
// here instead. Put the caret in the text rather than swallowing it, so
|
|
759
|
+
// reaching for a formatting control still gets you into the description.
|
|
760
|
+
if (!revealToolbarOnFocus || hasFocusWithinRef.current || !editor) {
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (event.target !== event.currentTarget) return;
|
|
765
|
+
|
|
766
|
+
event.preventDefault();
|
|
767
|
+
editor.commands.focus();
|
|
768
|
+
}}
|
|
769
|
+
>
|
|
743
770
|
{!readOnly && (
|
|
744
771
|
<FixedToolbar
|
|
745
772
|
ref={fixedToolbarRef}
|
|
773
|
+
className={
|
|
774
|
+
revealToolbarOnFocus
|
|
775
|
+
? REVEAL_TOOLBAR_ON_FOCUS_CLASS_NAME
|
|
776
|
+
: undefined
|
|
777
|
+
}
|
|
746
778
|
editor={editor}
|
|
747
779
|
workspaceId={workspaceId}
|
|
748
780
|
onImageUpload={onImageUpload}
|