@tuturuuu/ui 0.7.0 → 0.9.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 (226) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/biome.json +1 -1
  3. package/package.json +75 -73
  4. package/src/components/ui/accordion.tsx +1 -1
  5. package/src/components/ui/breadcrumb.tsx +1 -1
  6. package/src/components/ui/calendar-app/calendar-page-shell.tsx +4 -0
  7. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +239 -33
  8. package/src/components/ui/calendar-app/components/load-smart-scheduling-tasks.tsx +143 -0
  9. package/src/components/ui/calendar-app/components/priority-view.tsx +10 -3
  10. package/src/components/ui/calendar-app/components/tasks-sidebar.tsx +4 -116
  11. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +67 -2
  12. package/src/components/ui/calendar.tsx +1 -1
  13. package/src/components/ui/carousel.tsx +1 -1
  14. package/src/components/ui/chat/chat-agent-details-external-thread-panel.test.tsx +1 -1
  15. package/src/components/ui/chat/chat-agent-details-external-thread-panel.tsx +1 -1
  16. package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +1 -1
  17. package/src/components/ui/chat/chat-agent-details-operations-panel.tsx +1 -1
  18. package/src/components/ui/chat/chat-agent-details-setup-panel.tsx +1 -1
  19. package/src/components/ui/chat/chat-agent-details-sidebar.test.tsx +1 -1
  20. package/src/components/ui/chat/chat-agent-details-sidebar.tsx +2 -2
  21. package/src/components/ui/chat/chat-agent-details-utils.test.ts +1 -1
  22. package/src/components/ui/chat/chat-agent-details-utils.tsx +1 -1
  23. package/src/components/ui/chat/chat-agent-details-zalo-personal-panel.tsx +2 -2
  24. package/src/components/ui/checkbox.tsx +1 -1
  25. package/src/components/ui/color-picker.tsx +1 -1
  26. package/src/components/ui/command.tsx +1 -1
  27. package/src/components/ui/context-menu.tsx +5 -1
  28. package/src/components/ui/currency-input.test.tsx +43 -0
  29. package/src/components/ui/currency-input.tsx +1 -1
  30. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +3 -0
  31. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +19 -0
  32. package/src/components/ui/custom/combobox.test.tsx +195 -0
  33. package/src/components/ui/custom/combobox.tsx +273 -156
  34. package/src/components/ui/custom/education/modules/youtube/delete-link-button.tsx +5 -13
  35. package/src/components/ui/custom/facebook-mockup/facebook-mockup.tsx +7 -1
  36. package/src/components/ui/custom/facebook-mockup/form.tsx +1 -1
  37. package/src/components/ui/custom/facebook-mockup/image-upload-field.tsx +1 -1
  38. package/src/components/ui/custom/facebook-mockup/preview.tsx +1 -1
  39. package/src/components/ui/custom/settings-dialog-shell.tsx +2 -1
  40. package/src/components/ui/custom/theme-toggle.tsx +1 -1
  41. package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +60 -35
  42. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +176 -167
  43. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +16 -10
  44. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +75 -36
  45. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +39 -42
  46. package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +1 -1
  47. package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +113 -91
  48. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +73 -32
  49. package/src/components/ui/custom/workspace-select.tsx +8 -3
  50. package/src/components/ui/dialog.test.tsx +52 -0
  51. package/src/components/ui/dialog.tsx +6 -2
  52. package/src/components/ui/dropdown-menu.tsx +5 -1
  53. package/src/components/ui/finance/debts/debt-loan-form.tsx +12 -5
  54. package/src/components/ui/finance/debts/debt-loan-summary.tsx +3 -2
  55. package/src/components/ui/finance/debts/debts-page.test.tsx +54 -5
  56. package/src/components/ui/finance/debts/debts-page.tsx +15 -2
  57. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +3 -5
  58. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +25 -5
  59. package/src/components/ui/finance/invoices/new-invoice-page.tsx +7 -2
  60. package/src/components/ui/finance/invoices/standard-invoice.tsx +4 -2
  61. package/src/components/ui/finance/invoices/subscription-invoice.tsx +4 -2
  62. package/src/components/ui/finance/invoices/utils.ts +3 -1
  63. package/src/components/ui/finance/transactions/form-content-dialog.tsx +3 -0
  64. package/src/components/ui/finance/transactions/form-types.ts +3 -0
  65. package/src/components/ui/finance/transactions/form.tsx +2 -0
  66. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +2 -0
  67. package/src/components/ui/finance/transactions/period-charts/category-breakdown-dialog.tsx +1 -1
  68. package/src/components/ui/finance/transactions/transaction-card.tsx +21 -9
  69. package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +1 -4
  70. package/src/components/ui/finance/transactions/transactions-create-summary.tsx +3 -0
  71. package/src/components/ui/finance/transactions/transactions-page.tsx +4 -1
  72. package/src/components/ui/finance/wallets/form.test.tsx +51 -3
  73. package/src/components/ui/finance/wallets/form.tsx +15 -4
  74. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +4 -0
  75. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +4 -2
  76. package/src/components/ui/finance/wallets/wallets-data-table.tsx +1 -0
  77. package/src/components/ui/finance/wallets/wallets-page.tsx +5 -2
  78. package/src/components/ui/input-otp.tsx +1 -1
  79. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +28 -39
  80. package/src/components/ui/legacy/calendar/calendar-cell.tsx +2 -0
  81. package/src/components/ui/legacy/calendar/calendar-content.tsx +10 -6
  82. package/src/components/ui/legacy/calendar/calendar-header.tsx +23 -3
  83. package/src/components/ui/legacy/calendar/calendar-loading-skeleton.tsx +135 -0
  84. package/src/components/ui/legacy/calendar/calendar-matrix.tsx +175 -237
  85. package/src/components/ui/legacy/calendar/event-card.test.tsx +177 -0
  86. package/src/components/ui/legacy/calendar/event-card.tsx +220 -131
  87. package/src/components/ui/legacy/calendar/event-modal.tsx +17 -17
  88. package/src/components/ui/legacy/calendar/event-provider-display.tsx +69 -0
  89. package/src/components/ui/legacy/calendar/smart-calendar.test.tsx +86 -4
  90. package/src/components/ui/legacy/calendar/smart-calendar.tsx +32 -2
  91. package/src/components/ui/legacy/meet/create-plan-dialog.tsx +19 -10
  92. package/src/components/ui/money-input.test.tsx +64 -0
  93. package/src/components/ui/money-input.tsx +63 -0
  94. package/src/components/ui/navigation-menu.tsx +1 -1
  95. package/src/components/ui/pagination.tsx +1 -1
  96. package/src/components/ui/radio-group.tsx +1 -1
  97. package/src/components/ui/select.tsx +5 -1
  98. package/src/components/ui/sheet.tsx +1 -1
  99. package/src/components/ui/sidebar.tsx +1 -1
  100. package/src/components/ui/storefront/cart-popover.tsx +61 -0
  101. package/src/components/ui/storefront/cart-summary-parts.tsx +290 -0
  102. package/src/components/ui/storefront/cart-summary.tsx +104 -80
  103. package/src/components/ui/storefront/checkout-overlay.tsx +26 -0
  104. package/src/components/ui/storefront/hero-panel.tsx +2 -8
  105. package/src/components/ui/storefront/image-panel.tsx +6 -0
  106. package/src/components/ui/storefront/index.ts +11 -0
  107. package/src/components/ui/storefront/listing-card.tsx +84 -22
  108. package/src/components/ui/storefront/merch-sections.tsx +70 -0
  109. package/src/components/ui/storefront/product-detail.tsx +289 -0
  110. package/src/components/ui/storefront/product-dialog.tsx +72 -0
  111. package/src/components/ui/storefront/storefront-surface.test.tsx +221 -3
  112. package/src/components/ui/storefront/storefront-surface.tsx +288 -153
  113. package/src/components/ui/storefront/types.ts +27 -1
  114. package/src/components/ui/storefront/utils.ts +117 -27
  115. package/src/components/ui/text-editor/__tests__/content-migration.test.ts +32 -0
  116. package/src/components/ui/text-editor/__tests__/extensions.test.ts +123 -0
  117. package/src/components/ui/text-editor/__tests__/image-extension.test.ts +69 -1
  118. package/src/components/ui/text-editor/__tests__/video-extension.test.ts +47 -0
  119. package/src/components/ui/text-editor/background-color-extension.ts +62 -0
  120. package/src/components/ui/text-editor/color-controls.tsx +284 -0
  121. package/src/components/ui/text-editor/content-migration.ts +41 -18
  122. package/src/components/ui/text-editor/editor.tsx +69 -14
  123. package/src/components/ui/text-editor/extensions.ts +9 -3
  124. package/src/components/ui/text-editor/highlight-extension.ts +22 -0
  125. package/src/components/ui/text-editor/image-extension.ts +40 -18
  126. package/src/components/ui/text-editor/tool-bar.tsx +9 -16
  127. package/src/components/ui/text-editor/video-extension.ts +11 -2
  128. package/src/components/ui/toast.tsx +1 -1
  129. package/src/components/ui/tu-do/boards/__tests__/board-share-dialog.test.tsx +270 -0
  130. package/src/components/ui/tu-do/boards/__tests__/workspace-projects-client-page.test.tsx +70 -1
  131. package/src/components/ui/tu-do/boards/board-public-link-section.tsx +231 -0
  132. package/src/components/ui/tu-do/boards/board-share-dialog.tsx +222 -109
  133. package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +127 -0
  134. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +113 -46
  135. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-clear-delete.ts +2 -0
  136. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-move.ts +5 -0
  137. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-updates.ts +3 -0
  138. package/src/components/ui/tu-do/boards/boardId/kanban/data/kanban-deadline-query.ts +50 -2
  139. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/__tests__/column-reorder.test.ts +17 -0
  140. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/column-reorder.ts +4 -1
  141. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +51 -9
  142. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-order.ts +2 -8
  143. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-sort-key.ts +47 -0
  144. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -0
  145. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +127 -38
  146. package/src/components/ui/tu-do/boards/boardId/kanban/planner/__tests__/kanban-planner-island.test.tsx +380 -0
  147. package/src/components/ui/tu-do/boards/boardId/kanban/planner/kanban-planner-dialog.tsx +204 -0
  148. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-digest-panel.tsx +61 -0
  149. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-item-strip.tsx +54 -0
  150. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-plan-toolbar.tsx +251 -0
  151. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-scope-badge.tsx +27 -0
  152. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-section.tsx +58 -0
  153. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-share-dialog.tsx +238 -0
  154. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-target-controls.tsx +143 -0
  155. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-utils.ts +65 -0
  156. package/src/components/ui/tu-do/boards/boardId/kanban/planner/use-kanban-planner-state.ts +234 -0
  157. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +410 -4
  158. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +106 -14
  159. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +443 -19
  160. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +94 -32
  161. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +213 -106
  162. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +186 -0
  163. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +59 -2
  164. package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +3 -0
  165. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.ts +3 -0
  166. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +191 -28
  167. package/src/components/ui/tu-do/boards/boardId/task-filter.test.tsx +152 -0
  168. package/src/components/ui/tu-do/boards/boardId/task-filter.tsx +555 -545
  169. package/src/components/ui/tu-do/boards/boardId/task-list.tsx +7 -0
  170. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-display.ts +9 -0
  171. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +8 -16
  172. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +5 -25
  173. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.test.ts +36 -1
  174. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.ts +51 -2
  175. package/src/components/ui/tu-do/boards/share-section.tsx +100 -0
  176. package/src/components/ui/tu-do/boards/workspace-projects-client-page.tsx +13 -3
  177. package/src/components/ui/tu-do/drafts/draft-convert-dialog.tsx +10 -12
  178. package/src/components/ui/tu-do/drafts/drafts-page.tsx +33 -16
  179. package/src/components/ui/tu-do/initiatives/task-initiatives-client.tsx +56 -88
  180. package/src/components/ui/tu-do/my-tasks/my-tasks-content.tsx +26 -2
  181. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +55 -8
  182. package/src/components/ui/tu-do/notes/note-edit-dialog.tsx +1 -4
  183. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +25 -0
  184. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +341 -38
  185. package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +253 -0
  186. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +237 -3
  187. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +17 -0
  188. package/src/components/ui/tu-do/shared/__tests__/task-legacy-route-recovery.test.tsx +16 -0
  189. package/src/components/ui/tu-do/shared/board-client.tsx +2 -7
  190. package/src/components/ui/tu-do/shared/board-config-storage.ts +7 -1
  191. package/src/components/ui/tu-do/shared/board-header.tsx +465 -937
  192. package/src/components/ui/tu-do/shared/board-layout-settings.tsx +165 -136
  193. package/src/components/ui/tu-do/shared/board-switcher.tsx +209 -217
  194. package/src/components/ui/tu-do/shared/board-views.tsx +596 -82
  195. package/src/components/ui/tu-do/shared/cursor-overlay-multi-wrapper.tsx +53 -12
  196. package/src/components/ui/tu-do/shared/list-view.tsx +227 -1
  197. package/src/components/ui/tu-do/shared/recycle-bin-panel.tsx +142 -94
  198. package/src/components/ui/tu-do/shared/special-task-list-pins.ts +51 -0
  199. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +28 -0
  200. package/src/components/ui/tu-do/shared/task-dialog-presentation.test.ts +53 -0
  201. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +19 -0
  202. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +57 -0
  203. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +136 -111
  204. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -1
  205. package/src/components/ui/tu-do/shared/task-edit-dialog/field-diff-viewer.tsx +3 -2
  206. package/src/components/ui/tu-do/shared/task-edit-dialog/selective-revert-panel.test.tsx +91 -0
  207. package/src/components/ui/tu-do/shared/task-edit-dialog/selective-revert-panel.tsx +123 -78
  208. package/src/components/ui/tu-do/shared/task-edit-dialog/task-activity-section.tsx +7 -1
  209. package/src/components/ui/tu-do/shared/task-edit-dialog/task-snapshot-dialog.tsx +8 -3
  210. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +44 -15
  211. package/src/components/ui/tu-do/shared/task-legacy-route-recovery.tsx +2 -9
  212. package/src/declarations.d.ts +1 -0
  213. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +322 -2
  214. package/src/hooks/__tests__/use-calendar-sync.test.tsx +446 -0
  215. package/src/hooks/__tests__/useBoardRealtime.test.tsx +2 -2
  216. package/src/hooks/__tests__/useCursorTracking.test.tsx +212 -0
  217. package/src/hooks/use-calendar-sync.tsx +247 -243
  218. package/src/hooks/use-calendar.tsx +323 -138
  219. package/src/hooks/use-task-actions.ts +24 -0
  220. package/src/hooks/use-user-workspace-config.ts +75 -0
  221. package/src/hooks/use-workspace-currency.ts +8 -3
  222. package/src/hooks/useBoardRealtime.ts +6 -3
  223. package/src/hooks/useBoardRealtime.types.ts +11 -0
  224. package/src/hooks/useBoardRealtimeEventHandler.ts +11 -0
  225. package/src/hooks/useCursorTracking.ts +91 -27
  226. package/src/hooks/useTaskUserRealtime.ts +5 -3
@@ -0,0 +1,284 @@
1
+ import type { Editor } from '@tiptap/react';
2
+ import { Highlighter, Paintbrush, TextIcon, X } from '@tuturuuu/icons';
3
+ import { Button } from '@tuturuuu/ui/button';
4
+ import { Popover, PopoverContent, PopoverTrigger } from '@tuturuuu/ui/popover';
5
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@tuturuuu/ui/tooltip';
6
+ import { cn } from '@tuturuuu/utils/format';
7
+
8
+ interface EditorColorSwatch {
9
+ name: string;
10
+ foreground: string;
11
+ background: string;
12
+ }
13
+
14
+ const EDITOR_COLOR_SWATCHES: EditorColorSwatch[] = [
15
+ {
16
+ name: 'Yellow',
17
+ foreground: 'var(--yellow)',
18
+ background: 'var(--calendar-bg-yellow)',
19
+ },
20
+ {
21
+ name: 'Orange',
22
+ foreground: 'var(--orange)',
23
+ background: 'var(--calendar-bg-orange)',
24
+ },
25
+ {
26
+ name: 'Red',
27
+ foreground: 'var(--red)',
28
+ background: 'var(--calendar-bg-red)',
29
+ },
30
+ {
31
+ name: 'Pink',
32
+ foreground: 'var(--pink)',
33
+ background: 'var(--calendar-bg-pink)',
34
+ },
35
+ {
36
+ name: 'Purple',
37
+ foreground: 'var(--purple)',
38
+ background: 'var(--calendar-bg-purple)',
39
+ },
40
+ {
41
+ name: 'Blue',
42
+ foreground: 'var(--blue)',
43
+ background: 'var(--calendar-bg-blue)',
44
+ },
45
+ {
46
+ name: 'Green',
47
+ foreground: 'var(--green)',
48
+ background: 'var(--calendar-bg-green)',
49
+ },
50
+ {
51
+ name: 'Gray',
52
+ foreground: 'var(--gray)',
53
+ background: 'var(--calendar-bg-gray)',
54
+ },
55
+ ];
56
+
57
+ type HighlightAttributes = {
58
+ color: string;
59
+ textColor?: string;
60
+ };
61
+
62
+ type TextStyleAttributes = {
63
+ color?: string | null;
64
+ backgroundColor?: string | null;
65
+ };
66
+
67
+ interface TextEditorColorControlsProps {
68
+ editor: Editor | null;
69
+ }
70
+
71
+ export function TextEditorColorControls({
72
+ editor,
73
+ }: TextEditorColorControlsProps) {
74
+ if (!editor) return null;
75
+
76
+ const getAttributes = editor.getAttributes?.bind(editor);
77
+ const highlightAttributes = (getAttributes?.('highlight') ?? {}) as {
78
+ color?: string | null;
79
+ textColor?: string | null;
80
+ };
81
+ const textStyleAttributes = (getAttributes?.('textStyle') ??
82
+ {}) as TextStyleAttributes;
83
+
84
+ const textColor = textStyleAttributes.color ?? null;
85
+ const backgroundColor = textStyleAttributes.backgroundColor ?? null;
86
+ const highlightColor = highlightAttributes.color ?? null;
87
+ const highlightTextColor = highlightAttributes.textColor ?? null;
88
+
89
+ return (
90
+ <>
91
+ <ColorControlPopover
92
+ active={editor.isActive('highlight')}
93
+ clearLabel="Clear highlight"
94
+ indicatorBackground={highlightColor}
95
+ indicatorColor={highlightTextColor}
96
+ label="Highlight color"
97
+ icon={<Highlighter className="size-4" />}
98
+ onClear={() => editor.chain().focus().unsetHighlight().run()}
99
+ swatchKind="highlight"
100
+ onSelect={(swatch) => {
101
+ const attributes: HighlightAttributes = {
102
+ color: swatch.background,
103
+ textColor: swatch.foreground,
104
+ };
105
+
106
+ editor.chain().focus().setHighlight(attributes).run();
107
+ }}
108
+ isSwatchActive={(swatch) =>
109
+ editor.isActive('highlight', {
110
+ color: swatch.background,
111
+ textColor: swatch.foreground,
112
+ })
113
+ }
114
+ />
115
+ <ColorControlPopover
116
+ active={Boolean(textColor)}
117
+ clearLabel="Clear text color"
118
+ indicatorColor={textColor}
119
+ label="Text color"
120
+ icon={<TextIcon className="size-4" />}
121
+ onClear={() => editor.chain().focus().unsetColor().run()}
122
+ swatchKind="text"
123
+ onSelect={(swatch) =>
124
+ editor.chain().focus().setColor(swatch.foreground).run()
125
+ }
126
+ isSwatchActive={(swatch) =>
127
+ editor.isActive('textStyle', { color: swatch.foreground })
128
+ }
129
+ />
130
+ <ColorControlPopover
131
+ active={Boolean(backgroundColor)}
132
+ clearLabel="Clear background color"
133
+ indicatorBackground={backgroundColor}
134
+ label="Background color"
135
+ icon={<Paintbrush className="size-4" />}
136
+ onClear={() => editor.chain().focus().unsetBackgroundColor().run()}
137
+ swatchKind="background"
138
+ onSelect={(swatch) =>
139
+ editor.chain().focus().setBackgroundColor(swatch.background).run()
140
+ }
141
+ isSwatchActive={(swatch) =>
142
+ editor.isActive('textStyle', {
143
+ backgroundColor: swatch.background,
144
+ })
145
+ }
146
+ />
147
+ </>
148
+ );
149
+ }
150
+
151
+ interface ColorControlPopoverProps {
152
+ active: boolean;
153
+ clearLabel: string;
154
+ icon: React.ReactNode;
155
+ indicatorBackground?: string | null;
156
+ indicatorColor?: string | null;
157
+ isSwatchActive: (swatch: EditorColorSwatch) => boolean;
158
+ label: string;
159
+ onClear: () => void;
160
+ onSelect: (swatch: EditorColorSwatch) => void;
161
+ swatchKind: 'highlight' | 'text' | 'background';
162
+ }
163
+
164
+ function ColorControlPopover({
165
+ active,
166
+ clearLabel,
167
+ icon,
168
+ indicatorBackground,
169
+ indicatorColor,
170
+ isSwatchActive,
171
+ label,
172
+ onClear,
173
+ onSelect,
174
+ swatchKind,
175
+ }: ColorControlPopoverProps) {
176
+ return (
177
+ <Popover>
178
+ <Tooltip>
179
+ <TooltipTrigger asChild>
180
+ <PopoverTrigger asChild>
181
+ <Button
182
+ aria-label={label}
183
+ aria-pressed={active}
184
+ className={cn(
185
+ 'relative h-8 w-8 rounded-md border border-transparent transition-colors hover:bg-dynamic-surface/80',
186
+ active &&
187
+ 'border-foreground/10 bg-dynamic-surface/80 text-foreground'
188
+ )}
189
+ onMouseDown={(event) => event.preventDefault()}
190
+ size="icon"
191
+ type="button"
192
+ variant="ghost"
193
+ >
194
+ {icon}
195
+ <span
196
+ className="pointer-events-none absolute right-1 bottom-1 h-1.5 w-4 rounded-full border border-background"
197
+ style={{
198
+ backgroundColor:
199
+ indicatorBackground ?? indicatorColor ?? 'transparent',
200
+ }}
201
+ />
202
+ </Button>
203
+ </PopoverTrigger>
204
+ </TooltipTrigger>
205
+ <TooltipContent side="bottom">{label}</TooltipContent>
206
+ </Tooltip>
207
+ <PopoverContent align="start" className="w-56 p-2">
208
+ <div className="space-y-2">
209
+ <div className="font-medium text-muted-foreground text-xs">
210
+ {label}
211
+ </div>
212
+ <div className="grid grid-cols-4 gap-1.5">
213
+ {EDITOR_COLOR_SWATCHES.map((swatch) => (
214
+ <SwatchButton
215
+ active={isSwatchActive(swatch)}
216
+ key={`${swatchKind}-${swatch.name}`}
217
+ swatch={swatch}
218
+ swatchKind={swatchKind}
219
+ onSelect={() => onSelect(swatch)}
220
+ />
221
+ ))}
222
+ </div>
223
+ <Button
224
+ className="h-8 w-full justify-start px-2 text-xs"
225
+ onClick={onClear}
226
+ onMouseDown={(event) => event.preventDefault()}
227
+ type="button"
228
+ variant="ghost"
229
+ >
230
+ <X className="mr-1.5 size-3.5" />
231
+ {clearLabel}
232
+ </Button>
233
+ </div>
234
+ </PopoverContent>
235
+ </Popover>
236
+ );
237
+ }
238
+
239
+ interface SwatchButtonProps {
240
+ active: boolean;
241
+ onSelect: () => void;
242
+ swatch: EditorColorSwatch;
243
+ swatchKind: 'highlight' | 'text' | 'background';
244
+ }
245
+
246
+ function SwatchButton({
247
+ active,
248
+ onSelect,
249
+ swatch,
250
+ swatchKind,
251
+ }: SwatchButtonProps) {
252
+ const backgroundColor =
253
+ swatchKind === 'text' ? 'var(--background)' : swatch.background;
254
+ const color =
255
+ swatchKind === 'background' ? 'var(--foreground)' : swatch.foreground;
256
+
257
+ return (
258
+ <Button
259
+ aria-label={`${swatch.name} ${swatchKind}`}
260
+ className={cn(
261
+ 'h-8 w-8 rounded-md border border-dynamic-border p-0 transition-all hover:scale-105',
262
+ active &&
263
+ 'ring-2 ring-foreground/30 ring-offset-1 ring-offset-background'
264
+ )}
265
+ onClick={onSelect}
266
+ onMouseDown={(event) => event.preventDefault()}
267
+ size="icon"
268
+ style={{ backgroundColor, color }}
269
+ type="button"
270
+ variant="ghost"
271
+ >
272
+ {swatchKind === 'text' ? (
273
+ <TextIcon className="size-4" />
274
+ ) : (
275
+ <span
276
+ className="size-4 rounded-sm border border-foreground/10"
277
+ style={{
278
+ backgroundColor: swatch.background,
279
+ }}
280
+ />
281
+ )}
282
+ </Button>
283
+ );
284
+ }
@@ -6,6 +6,10 @@ import type { JSONContent } from '@tiptap/react';
6
6
  */
7
7
  const IMAGE_NODE_TYPES = ['image', 'imageResize'];
8
8
 
9
+ function getContentChildren(node: JSONContent): JSONContent[] | null {
10
+ return Array.isArray(node.content) ? node.content : null;
11
+ }
12
+
9
13
  /**
10
14
  * Migrates content from inline images (inside paragraphs) to block-level images.
11
15
  * This ensures backward compatibility when switching from inline: true to inline: false.
@@ -36,13 +40,19 @@ const IMAGE_NODE_TYPES = ['image', 'imageResize'];
36
40
  export function migrateInlineImagesToBlock(
37
41
  content: JSONContent | null
38
42
  ): JSONContent | null {
39
- if (!content?.content) return content;
43
+ if (!content) return content;
44
+
45
+ const contentChildren = getContentChildren(content);
46
+ if (!contentChildren) return content;
40
47
 
41
48
  const newContent: JSONContent[] = [];
42
49
 
43
- for (const node of content.content) {
50
+ for (const node of contentChildren) {
44
51
  // Recursively migrate nested structures (lists, blockquotes, etc.)
45
- if (node.content && !IMAGE_NODE_TYPES.includes(node.type || '')) {
52
+ if (
53
+ getContentChildren(node) &&
54
+ !IMAGE_NODE_TYPES.includes(node.type || '')
55
+ ) {
46
56
  const migratedNode = migrateNodeContent(node);
47
57
  if (migratedNode.extractedImages.length > 0) {
48
58
  // Add the node with non-image content (if it has any)
@@ -74,18 +84,23 @@ interface MigratedNode {
74
84
  * Recursively migrates a node's content, extracting inline images from paragraphs.
75
85
  */
76
86
  function migrateNodeContent(node: JSONContent): MigratedNode {
87
+ const contentChildren = getContentChildren(node);
88
+
77
89
  // Handle paragraph nodes - extract inline images
78
- if (node.type === 'paragraph' && node.content?.length) {
90
+ if (node.type === 'paragraph' && contentChildren?.length) {
79
91
  return extractImagesFromParagraph(node);
80
92
  }
81
93
 
82
94
  // Handle container nodes (list items, blockquotes, table cells, etc.) - recurse into children
83
- if (node.content?.length) {
95
+ if (contentChildren?.length) {
84
96
  const migratedChildren: JSONContent[] = [];
85
97
  const allExtractedImages: JSONContent[] = [];
86
98
 
87
- for (const child of node.content) {
88
- if (child.content && !IMAGE_NODE_TYPES.includes(child.type || '')) {
99
+ for (const child of contentChildren) {
100
+ if (
101
+ getContentChildren(child) &&
102
+ !IMAGE_NODE_TYPES.includes(child.type || '')
103
+ ) {
89
104
  const result = migrateNodeContent(child);
90
105
 
91
106
  if (result.extractedImages.length > 0) {
@@ -116,14 +131,16 @@ function migrateNodeContent(node: JSONContent): MigratedNode {
116
131
  * and the extracted images separately.
117
132
  */
118
133
  function extractImagesFromParagraph(paragraph: JSONContent): MigratedNode {
119
- if (!paragraph.content) {
134
+ const contentChildren = getContentChildren(paragraph);
135
+
136
+ if (!contentChildren) {
120
137
  return { node: paragraph, extractedImages: [] };
121
138
  }
122
139
 
123
140
  const textContent: JSONContent[] = [];
124
141
  const images: JSONContent[] = [];
125
142
 
126
- for (const child of paragraph.content) {
143
+ for (const child of contentChildren) {
127
144
  if (IMAGE_NODE_TYPES.includes(child.type || '')) {
128
145
  images.push(child);
129
146
  } else {
@@ -148,9 +165,10 @@ function extractImagesFromParagraph(paragraph: JSONContent): MigratedNode {
148
165
  * Empty paragraphs should be filtered out when all content was extracted.
149
166
  */
150
167
  function hasNonEmptyContent(node: JSONContent): boolean {
151
- if (!node.content || node.content.length === 0) return false;
168
+ const contentChildren = getContentChildren(node);
169
+ if (!contentChildren || contentChildren.length === 0) return false;
152
170
 
153
- return node.content.some((child) => {
171
+ return contentChildren.some((child) => {
154
172
  // Check for text content
155
173
  if (child.text && child.text.trim().length > 0) return true;
156
174
 
@@ -165,7 +183,7 @@ function hasNonEmptyContent(node: JSONContent): boolean {
165
183
  }
166
184
 
167
185
  // Recursively check children
168
- if (child.content) return hasNonEmptyContent(child);
186
+ if (getContentChildren(child)) return hasNonEmptyContent(child);
169
187
 
170
188
  return false;
171
189
  });
@@ -176,24 +194,29 @@ function hasNonEmptyContent(node: JSONContent): boolean {
176
194
  * Used to avoid unnecessary processing.
177
195
  */
178
196
  export function needsMigration(content: JSONContent | null): boolean {
179
- if (!content?.content) return false;
197
+ if (!content) return false;
198
+
199
+ const contentChildren = getContentChildren(content);
200
+ if (!contentChildren) return false;
180
201
 
181
202
  function checkNode(node: JSONContent): boolean {
203
+ const nodeChildren = getContentChildren(node);
204
+
182
205
  // Check if this is a paragraph with inline images
183
- if (node.type === 'paragraph' && node.content?.length) {
184
- const hasImage = node.content.some((child) =>
206
+ if (node.type === 'paragraph' && nodeChildren?.length) {
207
+ const hasImage = nodeChildren.some((child) =>
185
208
  IMAGE_NODE_TYPES.includes(child.type || '')
186
209
  );
187
210
  if (hasImage) return true;
188
211
  }
189
212
 
190
213
  // Recursively check children
191
- if (node.content?.length) {
192
- return node.content.some((child) => checkNode(child));
214
+ if (nodeChildren?.length) {
215
+ return nodeChildren.some((child) => checkNode(child));
193
216
  }
194
217
 
195
218
  return false;
196
219
  }
197
220
 
198
- return content.content.some((node) => checkNode(node));
221
+ return contentChildren.some((node) => checkNode(node));
199
222
  }
@@ -42,6 +42,26 @@ const hasContent = (node: JSONContent): boolean => {
42
42
  return false;
43
43
  };
44
44
 
45
+ function syncEditorContent(editor: Editor, nextContent: JSONContent | null) {
46
+ const migratedContent = migrateInlineImagesToBlock(nextContent);
47
+ const currentContent = editor.getJSON();
48
+ const contentChanged =
49
+ JSON.stringify(currentContent) !== JSON.stringify(migratedContent);
50
+
51
+ if (contentChanged) {
52
+ editor.commands.setContent(
53
+ migratedContent || { type: 'doc', content: [] },
54
+ {
55
+ emitUpdate: false,
56
+ }
57
+ );
58
+ }
59
+ }
60
+
61
+ function serializeEditorContent(content: JSONContent | null) {
62
+ return JSON.stringify(content ?? { type: 'doc', content: [] });
63
+ }
64
+
45
65
  interface RichTextEditorProps {
46
66
  content: JSONContent | null;
47
67
  onChange?: (content: JSONContent | null) => void;
@@ -127,6 +147,9 @@ export function RichTextEditor({
127
147
  const debouncedOnChangeRef = useRef<ReturnType<typeof debounce> | null>(null);
128
148
  // Track when we're in a programmatic update to skip content sync
129
149
  const isProgrammaticUpdateRef = useRef(false);
150
+ const hasDeferredExternalContentRef = useRef(false);
151
+ const deferredExternalContentRef = useRef<JSONContent | null>(null);
152
+ const deferredEditorSnapshotRef = useRef<string | null>(null);
130
153
  const getDelegatedImageUpload = useCallback(
131
154
  () => onImageUploadRef.current,
132
155
  []
@@ -566,26 +589,58 @@ export function RichTextEditor({
566
589
  // This prevents the effect from reverting editor content before state update propagates
567
590
  if (isProgrammaticUpdateRef.current) {
568
591
  isProgrammaticUpdateRef.current = false;
592
+ hasDeferredExternalContentRef.current = false;
593
+ deferredExternalContentRef.current = null;
594
+ deferredEditorSnapshotRef.current = null;
569
595
  return;
570
596
  }
571
597
 
572
- // Migrate inline images to block-level for backward compatibility
573
- const migratedContent = migrateInlineImagesToBlock(content);
574
- const currentContent = editor.getJSON();
575
- const contentChanged =
576
- JSON.stringify(currentContent) !== JSON.stringify(migratedContent);
577
-
578
- if (contentChanged) {
579
- // Update editor content without triggering onChange
580
- editor.commands.setContent(
581
- migratedContent || { type: 'doc', content: [] },
582
- {
583
- emitUpdate: false,
584
- }
585
- );
598
+ if (editor.isFocused) {
599
+ if (!hasDeferredExternalContentRef.current) {
600
+ deferredEditorSnapshotRef.current = serializeEditorContent(
601
+ editor.getJSON()
602
+ );
603
+ }
604
+ hasDeferredExternalContentRef.current = true;
605
+ deferredExternalContentRef.current = content;
606
+ return;
586
607
  }
608
+
609
+ hasDeferredExternalContentRef.current = false;
610
+ deferredExternalContentRef.current = null;
611
+ deferredEditorSnapshotRef.current = null;
612
+ syncEditorContent(editor, content);
587
613
  }, [editor, content, allowCollaboration]);
588
614
 
615
+ useEffect(() => {
616
+ if (!editor || allowCollaboration) return;
617
+
618
+ const syncDeferredContent = () => {
619
+ if (!hasDeferredExternalContentRef.current) return;
620
+
621
+ hasDeferredExternalContentRef.current = false;
622
+ const deferredContent = deferredExternalContentRef.current;
623
+ const deferredEditorSnapshot = deferredEditorSnapshotRef.current;
624
+ deferredExternalContentRef.current = null;
625
+ deferredEditorSnapshotRef.current = null;
626
+
627
+ if (
628
+ deferredEditorSnapshot !== null &&
629
+ serializeEditorContent(editor.getJSON()) !== deferredEditorSnapshot
630
+ ) {
631
+ return;
632
+ }
633
+
634
+ syncEditorContent(editor, deferredContent);
635
+ };
636
+
637
+ editor.on('blur', syncDeferredContent);
638
+
639
+ return () => {
640
+ editor.off('blur', syncDeferredContent);
641
+ };
642
+ }, [editor, allowCollaboration]);
643
+
589
644
  // Handle initial cursor positioning when focusing from title
590
645
  useEffect(() => {
591
646
  if (
@@ -1,7 +1,7 @@
1
1
  import { InputRule } from '@tiptap/core';
2
2
  import Collaboration from '@tiptap/extension-collaboration';
3
3
  import CollaborationCaret from '@tiptap/extension-collaboration-caret';
4
- import Highlight from '@tiptap/extension-highlight';
4
+ import { Color } from '@tiptap/extension-color';
5
5
  import HorizontalRule from '@tiptap/extension-horizontal-rule';
6
6
  import Link from '@tiptap/extension-link';
7
7
  import { TaskList } from '@tiptap/extension-list';
@@ -14,11 +14,14 @@ import TableCell from '@tiptap/extension-table-cell';
14
14
  import TableHeader from '@tiptap/extension-table-header';
15
15
  import TableRow from '@tiptap/extension-table-row';
16
16
  import TextAlign from '@tiptap/extension-text-align';
17
+ import { TextStyle } from '@tiptap/extension-text-style';
17
18
  import Youtube from '@tiptap/extension-youtube';
18
19
  import type { Extensions } from '@tiptap/react';
19
20
  import StarterKit from '@tiptap/starter-kit';
20
21
  import type SupabaseProvider from '@tuturuuu/ui/hooks/supabase-provider';
21
22
  import type * as Y from 'yjs';
23
+ import { BackgroundColor } from './background-color-extension';
24
+ import { ThemeAwareHighlight } from './highlight-extension';
22
25
  import { CustomImage } from './image-extension';
23
26
  import { ListConverter } from './list-converter-extension';
24
27
  import { ListItemBase } from './list-item-extension';
@@ -148,7 +151,10 @@ export function getEditorExtensions({
148
151
  }),
149
152
  TextShortcuts,
150
153
  MarkdownPaste,
151
- Highlight,
154
+ TextStyle,
155
+ Color,
156
+ BackgroundColor,
157
+ ThemeAwareHighlight,
152
158
  Link.configure({
153
159
  openOnClick: true,
154
160
  autolink: true,
@@ -213,7 +219,7 @@ export function getEditorExtensions({
213
219
  getOnImageUpload,
214
220
  getOnVideoUpload,
215
221
  }),
216
- Video({ onVideoUpload }).configure({
222
+ Video({ onVideoUpload, getOnVideoUpload }).configure({
217
223
  HTMLAttributes: {
218
224
  class: 'rounded-md my-4',
219
225
  },
@@ -0,0 +1,22 @@
1
+ import Highlight from '@tiptap/extension-highlight';
2
+
3
+ export const ThemeAwareHighlight = Highlight.extend({
4
+ addAttributes() {
5
+ return {
6
+ ...(this.parent?.() ?? {}),
7
+ textColor: {
8
+ default: null,
9
+ parseHTML: (element) => element.style.getPropertyValue('color') || null,
10
+ renderHTML: (attributes) => {
11
+ if (!attributes.textColor) return {};
12
+
13
+ return {
14
+ style: `color: ${attributes.textColor}`,
15
+ };
16
+ },
17
+ },
18
+ };
19
+ },
20
+ }).configure({
21
+ multicolor: true,
22
+ });
@@ -66,13 +66,31 @@ function clearImageResizeUIFromNodeDom(nodeDom: Node | null): void {
66
66
  }
67
67
  }
68
68
 
69
+ export type ImageSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
70
+
71
+ type UploadHandler = (file: File) => Promise<string>;
72
+ type UploadHandlerGetter = () => UploadHandler | undefined;
73
+
74
+ function resolveUploadHandler({
75
+ configuredHandler,
76
+ delegatedGetter,
77
+ }: {
78
+ configuredHandler?: UploadHandler;
79
+ delegatedGetter?: UploadHandlerGetter;
80
+ }): UploadHandler | undefined {
81
+ if (delegatedGetter) {
82
+ return delegatedGetter();
83
+ }
84
+
85
+ return configuredHandler;
86
+ }
87
+
69
88
  export const __imageExtensionPrivate = {
70
89
  clearImageResizeUIFromNodeDom,
71
90
  getSelectedImagePos,
91
+ resolveUploadHandler,
72
92
  };
73
93
 
74
- export type ImageSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
75
-
76
94
  // Size presets in pixels (will be calculated based on editor width)
77
95
  const SIZE_PERCENTAGES: Record<ImageSize, number> = {
78
96
  xs: 25, // 25% of editor width
@@ -137,10 +155,10 @@ function calculatePresetWidth(
137
155
  }
138
156
 
139
157
  interface ImageOptions {
140
- onImageUpload?: (file: File) => Promise<string>;
141
- onVideoUpload?: (file: File) => Promise<string>;
142
- getOnImageUpload?: () => ((file: File) => Promise<string>) | undefined;
143
- getOnVideoUpload?: () => ((file: File) => Promise<string>) | undefined;
158
+ onImageUpload?: UploadHandler;
159
+ onVideoUpload?: UploadHandler;
160
+ getOnImageUpload?: UploadHandlerGetter;
161
+ getOnVideoUpload?: UploadHandlerGetter;
144
162
  }
145
163
 
146
164
  /**
@@ -166,10 +184,10 @@ interface ExtendedImageResizeOptions {
166
184
  HTMLAttributes: Record<string, any>;
167
185
  minWidth?: number;
168
186
  maxWidth?: number;
169
- onImageUpload?: (file: File) => Promise<string>;
170
- onVideoUpload?: (file: File) => Promise<string>;
171
- getOnImageUpload?: () => ((file: File) => Promise<string>) | undefined;
172
- getOnVideoUpload?: () => ((file: File) => Promise<string>) | undefined;
187
+ onImageUpload?: UploadHandler;
188
+ onVideoUpload?: UploadHandler;
189
+ getOnImageUpload?: UploadHandlerGetter;
190
+ getOnVideoUpload?: UploadHandlerGetter;
173
191
  }
174
192
 
175
193
  export const CustomImage = (options: ImageOptions = {}) => {
@@ -229,15 +247,19 @@ export const CustomImage = (options: ImageOptions = {}) => {
229
247
  addProseMirrorPlugins() {
230
248
  const parentPlugins = this.parent?.() || [];
231
249
  const getOnImageUpload = () =>
232
- this?.options?.getOnImageUpload?.() ??
233
- this?.options?.onImageUpload ??
234
- options.getOnImageUpload?.() ??
235
- options.onImageUpload;
250
+ resolveUploadHandler({
251
+ delegatedGetter:
252
+ this?.options?.getOnImageUpload ?? options.getOnImageUpload,
253
+ configuredHandler:
254
+ this?.options?.onImageUpload ?? options.onImageUpload,
255
+ });
236
256
  const getOnVideoUpload = () =>
237
- this?.options?.getOnVideoUpload?.() ??
238
- this?.options?.onVideoUpload ??
239
- options.getOnVideoUpload?.() ??
240
- options.onVideoUpload;
257
+ resolveUploadHandler({
258
+ delegatedGetter:
259
+ this?.options?.getOnVideoUpload ?? options.getOnVideoUpload,
260
+ configuredHandler:
261
+ this?.options?.onVideoUpload ?? options.onVideoUpload,
262
+ });
241
263
 
242
264
  return [
243
265
  ...parentPlugins,