@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/biome.json +1 -1
  3. package/package.json +60 -48
  4. package/src/components/ui/badge.tsx +2 -0
  5. package/src/components/ui/button.tsx +2 -0
  6. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +48 -4
  7. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +33 -0
  8. package/src/components/ui/custom/__tests__/workspace-select-reveal.test.tsx +88 -0
  9. package/src/components/ui/custom/animated-slot-text.tsx +20 -0
  10. package/src/components/ui/custom/common-footer.tsx +262 -237
  11. package/src/components/ui/custom/language-dropdown-item.tsx +3 -10
  12. package/src/components/ui/custom/language-toggle.test.tsx +30 -0
  13. package/src/components/ui/custom/language-toggle.tsx +11 -10
  14. package/src/components/ui/custom/locale-preference.ts +32 -0
  15. package/src/components/ui/custom/settings/appearance-settings.tsx +5 -13
  16. package/src/components/ui/custom/settings-dialog-shell.tsx +40 -9
  17. package/src/components/ui/custom/structure.tsx +12 -0
  18. package/src/components/ui/custom/system-language-dropdown-item.tsx +3 -6
  19. package/src/components/ui/custom/workspace-access/adapters.test.ts +10 -1
  20. package/src/components/ui/custom/workspace-access/adapters.ts +36 -4
  21. package/src/components/ui/custom/workspace-access/member-filter-utils.test.ts +14 -0
  22. package/src/components/ui/custom/workspace-access/member-filter-utils.ts +8 -0
  23. package/src/components/ui/custom/workspace-access/types.ts +20 -0
  24. package/src/components/ui/custom/workspace-access/workspace-access-context.test.tsx +111 -0
  25. package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +15 -5
  26. package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.tsx +155 -48
  27. package/src/components/ui/custom/workspace-access/workspace-access-member-profile-dialog.tsx +92 -0
  28. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +171 -76
  29. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +11 -2
  30. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +11 -11
  31. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +180 -22
  32. package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +29 -15
  33. package/src/components/ui/custom/workspace-access/workspace-access-permission-checklist.tsx +56 -10
  34. package/src/components/ui/custom/workspace-access/workspace-access-permission-preview.test.ts +33 -0
  35. package/src/components/ui/custom/workspace-access/workspace-access-permission-preview.tsx +24 -1
  36. package/src/components/ui/custom/workspace-access/workspace-access-responsive.test.ts +64 -0
  37. package/src/components/ui/custom/workspace-access/workspace-access-role-editor-dialog.tsx +28 -16
  38. package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +35 -11
  39. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +23 -11
  40. package/src/components/ui/custom/workspace-select-helpers.ts +5 -3
  41. package/src/components/ui/custom/workspace-select-reveal.tsx +46 -0
  42. package/src/components/ui/custom/workspace-select.tsx +13 -5
  43. package/src/components/ui/finance/shared/charts/monthly-total-chart-client.tsx +1 -1
  44. package/src/components/ui/finance/shared/charts/monthly-total-chart.tsx +1 -1
  45. package/src/components/ui/storefront/cart-summary.tsx +12 -2
  46. package/src/components/ui/storefront/storefront-surface.test.tsx +21 -0
  47. package/src/components/ui/storefront/storefront-surface.tsx +6 -0
  48. package/src/components/ui/text-editor/__tests__/collaboration-binding.test.tsx +161 -0
  49. package/src/components/ui/text-editor/editor.tsx +267 -235
  50. package/src/globals.css +166 -0
  51. package/src/hooks/__tests__/use-workspace-identity-mutation.test.tsx +181 -0
  52. 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
- extensions: getEditorExtensions({
308
- titlePlaceholder,
309
- writePlaceholder,
310
- doc: allowCollaboration ? yjsDoc : undefined,
311
- provider: allowCollaboration ? yjsProvider : undefined,
312
- collaborationUser: allowCollaboration ? collaborationUser : undefined,
313
- onImageUpload,
314
- onVideoUpload: onImageUpload,
315
- getOnImageUpload: getDelegatedImageUpload,
316
- getOnVideoUpload: getDelegatedImageUpload,
317
- mentionTranslations,
318
- renderTaskMention,
319
- readOnly,
320
- }),
321
- // Migrate inline images to block-level for backward compatibility
322
- content: allowCollaboration
323
- ? undefined
324
- : migrateInlineImagesToBlock(content),
325
- editable: !readOnly,
326
- immediatelyRender: false,
327
- editorProps: {
328
- attributes: {
329
- class: getEditorClasses,
330
- },
331
- handleKeyDown: (view, event) => {
332
- // Prevent Ctrl+Enter / Cmd+Enter from creating a new line
333
- // Let the parent component handle the save action
334
- if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
335
- event.preventDefault();
336
- return true;
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
- // Handle Backspace
348
- if (event.key === 'Backspace' && onArrowUpRef.current) {
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
- console.log('Backspace at pos:', $from.pos);
381
+ const { state, dispatch } = view;
382
+ const { selection } = state;
383
+ const { $from } = selection;
355
384
 
356
- // Check if we're on the first line
357
- const coordsAtCursor = view.coordsAtPos($from.pos);
358
- const coordsAtStart = view.coordsAtPos(1);
359
- const isOnFirstLine =
360
- coordsAtCursor &&
361
- coordsAtStart &&
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
- console.log('Is on first line:', isOnFirstLine);
392
+ console.log('Backspace at pos:', $from.pos);
365
393
 
366
- if (isOnFirstLine) {
367
- const firstChild = state.doc.firstChild;
368
- console.log('First child:', {
369
- type: firstChild?.type.name,
370
- text: firstChild?.textContent,
371
- isEmpty: firstChild?.textContent.trim() === '',
372
- nodeSize: firstChild?.nodeSize,
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
- // If cursor is at the absolute start (position 1)
376
- if ($from.pos === 1) {
377
- const firstChild = state.doc.firstChild;
402
+ console.log('Is on first line:', isOnFirstLine);
378
403
 
379
- // If first line is empty and there's a second line, delete the empty line
380
- if (firstChild && firstChild.textContent.trim() === '') {
381
- const secondChild = state.doc.maybeChild(1);
382
- if (secondChild) {
383
- console.log(
384
- 'Empty first line - manually deleting via commands'
385
- );
386
- event.preventDefault();
387
-
388
- // Use commands to delete the node
389
- const tr = state.tr;
390
- const nodeSize = firstChild.nodeSize;
391
-
392
- // Delete from position 0 to end of first child (including the node itself)
393
- tr.delete(0, nodeSize);
394
-
395
- // Dispatch and trigger onChange manually
396
- dispatch(tr);
397
-
398
- // Manually trigger onChange since we're in handleKeyDown
399
- if (!readOnly && onChangeRef.current) {
400
- const newJson = tr.doc.toJSON();
401
- onChangeRef.current(hasContent(newJson) ? newJson : null);
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
- // If first line is NOT empty, go to title
409
- console.log('Non-empty first line - going to title');
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
- // Check if we're in a position where up arrow won't move us
428
- // This happens when we're on the first line of the first block
429
- const coordsAtCursor = view.coordsAtPos($from.pos);
430
- const coordsAtStart = view.coordsAtPos(1);
431
-
432
- // If cursor is on the same line as the start, go to title
433
- if (
434
- coordsAtCursor &&
435
- coordsAtStart &&
436
- Math.abs(coordsAtCursor.top - coordsAtStart.top) < 5
437
- ) {
438
- event.preventDefault();
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
- // Calculate character offset from start of the first line
441
- // Position 1 is the start of the document, $from.pos is current cursor
442
- // Since we're on the first line, the offset is simply the distance from position 1
443
- const offset = $from.pos - 1;
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
- onArrowUpRef.current(offset);
446
- return true;
483
+ onArrowUpRef.current(offset);
484
+ return true;
485
+ }
447
486
  }
448
- }
449
487
 
450
- // Handle ArrowLeft when at the very start of the document
451
- if (event.key === 'ArrowLeft' && onArrowLeftRef.current) {
452
- // If we're at position 1 (start of document), go back to title
453
- if ($from.pos === 1) {
454
- event.preventDefault();
455
- onArrowLeftRef.current();
456
- return true;
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
- // Handle Tab for list indentation while maintaining focus
461
- if (event.key === 'Tab') {
462
- // Always prevent default Tab behavior to avoid focus loss
463
- event.preventDefault();
464
- event.stopPropagation();
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
- const { $from } = selection;
534
+ // Ensure focus stays in the editor
535
+ setTimeout(() => {
536
+ view.focus();
537
+ }, 0);
467
538
 
468
- // Check if we're in a regular list item (bullet or ordered)
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
- // Ensure focus stays in the editor
497
- setTimeout(() => {
498
- view.focus();
499
- }, 0);
500
-
501
- return true; // We handled the event
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
- onCreate: ({ editor }) => {
508
- if (externalEditorRef) {
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
- if (!readOnly) {
518
- onImmediateChangeRef.current?.(normalizedContent);
519
- }
555
+ if (!readOnly) {
556
+ onImmediateChangeRef.current?.(normalizedContent);
557
+ }
520
558
 
521
- // Don't call onChange when using collaboration - Yjs doc is the source of truth
522
- if (!readOnly && !allowCollaboration) {
523
- debouncedOnChange(currentJson);
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 className="group relative h-full">
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}