@tuturuuu/ui 0.7.0 → 0.8.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 (67) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/package.json +8 -8
  3. package/src/components/ui/currency-input.test.tsx +43 -0
  4. package/src/components/ui/currency-input.tsx +1 -1
  5. package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +60 -35
  6. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +176 -167
  7. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +16 -10
  8. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +75 -36
  9. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +39 -42
  10. package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +1 -1
  11. package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +113 -91
  12. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +73 -32
  13. package/src/components/ui/finance/transactions/form-types.ts +2 -0
  14. package/src/components/ui/finance/transactions/transaction-card.tsx +21 -9
  15. package/src/components/ui/money-input.test.tsx +64 -0
  16. package/src/components/ui/money-input.tsx +63 -0
  17. package/src/components/ui/storefront/cart-summary.tsx +114 -29
  18. package/src/components/ui/storefront/checkout-overlay.tsx +27 -0
  19. package/src/components/ui/storefront/hero-panel.tsx +2 -8
  20. package/src/components/ui/storefront/image-panel.tsx +6 -0
  21. package/src/components/ui/storefront/index.ts +11 -0
  22. package/src/components/ui/storefront/listing-card.tsx +84 -22
  23. package/src/components/ui/storefront/product-detail.tsx +289 -0
  24. package/src/components/ui/storefront/product-dialog.tsx +72 -0
  25. package/src/components/ui/storefront/storefront-surface.test.tsx +124 -1
  26. package/src/components/ui/storefront/storefront-surface.tsx +333 -133
  27. package/src/components/ui/storefront/types.ts +23 -1
  28. package/src/components/ui/storefront/utils.ts +111 -27
  29. package/src/components/ui/text-editor/__tests__/content-migration.test.ts +32 -0
  30. package/src/components/ui/text-editor/__tests__/image-extension.test.ts +69 -1
  31. package/src/components/ui/text-editor/__tests__/video-extension.test.ts +47 -0
  32. package/src/components/ui/text-editor/content-migration.ts +41 -18
  33. package/src/components/ui/text-editor/extensions.ts +1 -1
  34. package/src/components/ui/text-editor/image-extension.ts +40 -18
  35. package/src/components/ui/text-editor/video-extension.ts +11 -2
  36. package/src/components/ui/tu-do/boards/__tests__/workspace-projects-client-page.test.tsx +70 -1
  37. package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +127 -0
  38. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +1 -3
  39. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +13 -0
  40. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -0
  41. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +46 -8
  42. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +13 -2
  43. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +3 -1
  44. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +164 -0
  45. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +56 -2
  46. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-display.ts +9 -0
  47. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +8 -16
  48. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +5 -25
  49. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.test.ts +36 -1
  50. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.ts +51 -2
  51. package/src/components/ui/tu-do/boards/workspace-projects-client-page.tsx +13 -3
  52. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +34 -1
  53. package/src/components/ui/tu-do/shared/board-header.tsx +39 -0
  54. package/src/components/ui/tu-do/shared/board-views.tsx +9 -7
  55. package/src/components/ui/tu-do/shared/cursor-overlay-multi-wrapper.tsx +53 -12
  56. package/src/components/ui/tu-do/shared/task-dialog-presentation.test.ts +53 -0
  57. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +19 -0
  58. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +57 -0
  59. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +136 -111
  60. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -1
  61. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +42 -14
  62. package/src/hooks/__tests__/useBoardRealtime.test.tsx +2 -2
  63. package/src/hooks/__tests__/useCursorTracking.test.tsx +212 -0
  64. package/src/hooks/useBoardRealtime.ts +6 -3
  65. package/src/hooks/useBoardRealtime.types.ts +11 -0
  66. package/src/hooks/useCursorTracking.ts +91 -27
  67. package/src/hooks/useTaskUserRealtime.ts +5 -3
@@ -1,46 +1,49 @@
1
1
  import type {
2
+ InventoryListingVariant,
2
3
  InventoryStorefront,
3
4
  InventoryStorefrontListing,
4
5
  } from '@tuturuuu/internal-api/inventory';
6
+ import { formatMoneyFromMinor } from '@tuturuuu/utils/money';
5
7
  import type { CSSProperties } from 'react';
6
8
 
9
+ // The storefront now ships a single, unified design language. The merchant
10
+ // preset fields (cornerStyle/surfaceStyle/themePreset/layoutStyle) are retained
11
+ // in the data model for backwards compatibility, but every value resolves to the
12
+ // same refined look so the experience is consistent across all storefronts.
13
+
14
+ /** One soft, modern corner radius for every surface. */
15
+ export const STOREFRONT_RADIUS = 'rounded-2xl';
16
+
7
17
  export const storefrontRadiusClasses: Record<
8
18
  InventoryStorefront['cornerStyle'],
9
19
  string
10
20
  > = {
11
- compact: 'rounded-md',
12
- rounded: 'rounded-lg',
13
- soft: 'rounded-2xl',
21
+ compact: STOREFRONT_RADIUS,
22
+ rounded: STOREFRONT_RADIUS,
23
+ soft: STOREFRONT_RADIUS,
14
24
  };
15
25
 
26
+ /** One elevated card surface for every storefront. */
27
+ export const STOREFRONT_SURFACE =
28
+ 'border border-border/60 bg-card shadow-sm shadow-foreground/5';
29
+
16
30
  export const storefrontSurfaceClasses: Record<
17
31
  InventoryStorefront['surfaceStyle'],
18
32
  string
19
33
  > = {
20
- glass:
21
- 'border border-border/70 bg-card/75 shadow-sm shadow-foreground/5 backdrop-blur',
22
- soft: 'border border-border/70 bg-muted/35 shadow-sm shadow-foreground/5',
23
- solid: 'border border-border bg-card shadow-sm shadow-foreground/5',
34
+ glass: STOREFRONT_SURFACE,
35
+ soft: STOREFRONT_SURFACE,
36
+ solid: STOREFRONT_SURFACE,
24
37
  };
25
38
 
26
- /**
27
- * Theme presets change the storefront's typographic personality so the choice
28
- * is actually visible to shoppers. Applied at the surface root and inherited by
29
- * headings/body inside.
30
- */
39
+ /** Unified typography for the whole storefront. */
31
40
  export const storefrontThemeClasses: Record<
32
41
  InventoryStorefront['themePreset'],
33
42
  string
34
43
  > = {
35
- // Spacious, headline-led magazine feel with serif headings.
36
- editorial:
37
- 'font-sans [&_h1]:font-serif [&_h1]:tracking-tight [&_h2]:font-serif [&_h2]:tracking-tight',
38
- // Refined boutique look: airy, wide-tracked uppercase headings.
39
- boutique:
40
- 'font-sans [&_h1]:uppercase [&_h1]:tracking-[0.12em] [&_h2]:tracking-wide',
41
- // Dense, scannable product-catalog density.
42
- catalog: 'font-sans text-[0.95rem] [&_h1]:tracking-tight',
43
- // Clean default.
44
+ editorial: 'font-sans',
45
+ boutique: 'font-sans',
46
+ catalog: 'font-sans',
44
47
  minimal: 'font-sans',
45
48
  };
46
49
 
@@ -65,6 +68,19 @@ export function sanitizeStorefrontAccentColor(value?: string | null) {
65
68
  return null;
66
69
  }
67
70
 
71
+ export function getSafeStorefrontHttpUrl(value?: string | null) {
72
+ const normalized = value?.trim();
73
+ if (!normalized) return null;
74
+
75
+ try {
76
+ const url = new URL(normalized);
77
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
78
+ return url.toString();
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
68
84
  export function getStorefrontListingLimit(listing: InventoryStorefrontListing) {
69
85
  const available =
70
86
  typeof listing.availableQuantity === 'number'
@@ -74,12 +90,80 @@ export function getStorefrontListingLimit(listing: InventoryStorefrontListing) {
74
90
  return Math.max(0, Math.min(listing.maxPerOrder, available));
75
91
  }
76
92
 
77
- export function formatStorefrontPrice(value: number, currency: string) {
78
- return new Intl.NumberFormat(undefined, {
79
- currency,
80
- maximumFractionDigits: 0,
81
- style: 'currency',
82
- }).format(value);
93
+ /** Active, selectable variants for a listing, in display order. */
94
+ export function getStorefrontListingVariants(
95
+ listing: InventoryStorefrontListing
96
+ ): InventoryListingVariant[] {
97
+ return (listing.variants ?? []).filter(
98
+ (variant) => variant.status === 'active'
99
+ );
100
+ }
101
+
102
+ export function listingHasVariants(listing: InventoryStorefrontListing) {
103
+ return getStorefrontListingVariants(listing).length > 0;
104
+ }
105
+
106
+ /** Per-order limit for a specific variant, capped by the listing's maxPerOrder. */
107
+ export function getStorefrontVariantLimit(
108
+ listing: InventoryStorefrontListing,
109
+ variant: InventoryListingVariant
110
+ ) {
111
+ const available =
112
+ typeof variant.availableQuantity === 'number'
113
+ ? variant.availableQuantity
114
+ : Number.POSITIVE_INFINITY;
115
+
116
+ return Math.max(0, Math.min(listing.maxPerOrder, available));
117
+ }
118
+
119
+ /**
120
+ * Resolves the price to charge for a cart line: the variant's resolved price
121
+ * when a variant is selected, otherwise the listing price. Both are minor units.
122
+ */
123
+ export function getStorefrontLinePrice(
124
+ listing: InventoryStorefrontListing,
125
+ variant?: InventoryListingVariant | null
126
+ ) {
127
+ return variant ? variant.price : listing.price;
128
+ }
129
+
130
+ /** Lowest active-variant price, for a "from {price}" label on variant listings. */
131
+ export function getStorefrontListingFromPrice(
132
+ listing: InventoryStorefrontListing
133
+ ) {
134
+ const variants = getStorefrontListingVariants(listing);
135
+ if (variants.length === 0) return listing.price;
136
+ return variants.reduce(
137
+ (min, variant) => Math.min(min, variant.price),
138
+ Number.POSITIVE_INFINITY
139
+ );
140
+ }
141
+
142
+ /** Stable identity for a cart line so listing+variant combos stay distinct. */
143
+ export function storefrontCartLineKey(
144
+ listingId: string,
145
+ variantId?: string | null
146
+ ) {
147
+ return `${listingId}::${variantId ?? ''}`;
148
+ }
149
+
150
+ /** Composes a human label for the selected variant from its option values. */
151
+ export function getStorefrontVariantLabel(
152
+ variant: InventoryListingVariant
153
+ ): string | null {
154
+ if (variant.title) return variant.title;
155
+ const labels = variant.optionValues.map((value) => value.label);
156
+ if (labels.length > 0) return labels.join(' / ');
157
+ return variant.sku ?? null;
158
+ }
159
+
160
+ /**
161
+ * Format a storefront price. Listing/bundle prices and cart totals are stored
162
+ * in integer minor units (cents for USD), so convert through the shared money
163
+ * helper, which also applies the currency's correct decimal precision.
164
+ */
165
+ export function formatStorefrontPrice(minorValue: number, currency: string) {
166
+ return formatMoneyFromMinor(minorValue, currency);
83
167
  }
84
168
 
85
169
  export function getListingInitials(title: string) {
@@ -16,6 +16,29 @@ describe('content-migration', () => {
16
16
  expect(migrateInlineImagesToBlock(content)).toEqual(content);
17
17
  });
18
18
 
19
+ it('should return malformed non-array content unchanged', () => {
20
+ const content = {
21
+ type: 'doc',
22
+ content: {},
23
+ } as unknown as JSONContent;
24
+
25
+ expect(migrateInlineImagesToBlock(content)).toEqual(content);
26
+ });
27
+
28
+ it('should ignore malformed nested non-array content', () => {
29
+ const content = {
30
+ type: 'doc',
31
+ content: [
32
+ {
33
+ type: 'paragraph',
34
+ content: {},
35
+ },
36
+ ],
37
+ } as unknown as JSONContent;
38
+
39
+ expect(migrateInlineImagesToBlock(content)).toEqual(content);
40
+ });
41
+
19
42
  it('should pass through content with no images', () => {
20
43
  const content: JSONContent = {
21
44
  type: 'doc',
@@ -662,6 +685,15 @@ describe('content-migration', () => {
662
685
  expect(needsMigration(content)).toBe(false);
663
686
  });
664
687
 
688
+ it('should return false for malformed non-array content', () => {
689
+ const content = {
690
+ type: 'doc',
691
+ content: {},
692
+ } as unknown as JSONContent;
693
+
694
+ expect(needsMigration(content)).toBe(false);
695
+ });
696
+
665
697
  it('should return false for content with no images', () => {
666
698
  const content: JSONContent = {
667
699
  type: 'doc',
@@ -1,4 +1,5 @@
1
1
  import { type EditorState, NodeSelection, type Plugin } from '@tiptap/pm/state';
2
+ import { toast } from '@tuturuuu/ui/sonner';
2
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
4
  import { __imageExtensionPrivate, CustomImage } from '../image-extension';
4
5
  import { MAX_IMAGE_SIZE, MAX_VIDEO_SIZE } from '../media-utils';
@@ -16,7 +17,7 @@ vi.mock('../media-utils', async () => {
16
17
  });
17
18
 
18
19
  // Mock the sonner toast
19
- vi.mock('../../sonner', () => ({
20
+ vi.mock('@tuturuuu/ui/sonner', () => ({
20
21
  toast: {
21
22
  error: vi.fn(),
22
23
  success: vi.fn(),
@@ -113,6 +114,27 @@ describe('ImageExtension', () => {
113
114
 
114
115
  expect(extension).toBeDefined();
115
116
  });
117
+
118
+ it('should let a delegated getter clear the configured image upload handler', () => {
119
+ const staleUpload = vi.fn().mockResolvedValue('stale-url');
120
+
121
+ expect(
122
+ __imageExtensionPrivate.resolveUploadHandler({
123
+ configuredHandler: staleUpload,
124
+ delegatedGetter: () => undefined,
125
+ })
126
+ ).toBeUndefined();
127
+ });
128
+
129
+ it('should fall back to the configured upload handler only without a delegated getter', () => {
130
+ const upload = vi.fn().mockResolvedValue('url');
131
+
132
+ expect(
133
+ __imageExtensionPrivate.resolveUploadHandler({
134
+ configuredHandler: upload,
135
+ })
136
+ ).toBe(upload);
137
+ });
116
138
  });
117
139
 
118
140
  describe('size presets', () => {
@@ -300,6 +322,52 @@ describe('ImageExtension', () => {
300
322
  );
301
323
  expect(result).toBe(false);
302
324
  });
325
+
326
+ it('should block pasted images when delegated upload permission is cleared', () => {
327
+ const staleUpload = vi.fn().mockResolvedValue('stale-url');
328
+ const extension = CustomImage({
329
+ onImageUpload: staleUpload,
330
+ getOnImageUpload: () => undefined,
331
+ });
332
+
333
+ const plugins = extension.config.addProseMirrorPlugins();
334
+ const pastePlugin = plugins.find(
335
+ (p: Plugin) => p.props?.handleDOMEvents?.paste !== undefined
336
+ );
337
+
338
+ const imageFile = new File(['image'], 'image.png', {
339
+ type: 'image/png',
340
+ });
341
+ const mockView = {
342
+ state: { selection: { from: 0, to: 0 } },
343
+ dispatch: vi.fn(),
344
+ dom: document.createElement('div'),
345
+ };
346
+ const mockEvent = {
347
+ clipboardData: {
348
+ items: [
349
+ {
350
+ type: 'image/png',
351
+ getAsFile: () => imageFile,
352
+ },
353
+ ],
354
+ },
355
+ preventDefault: vi.fn(),
356
+ } as unknown as ClipboardEvent;
357
+
358
+ const result = pastePlugin?.props?.handleDOMEvents?.paste(
359
+ mockView as any,
360
+ mockEvent
361
+ );
362
+
363
+ expect(result).toBe(true);
364
+ expect(mockEvent.preventDefault).toHaveBeenCalled();
365
+ expect(staleUpload).not.toHaveBeenCalled();
366
+ expect(toast.error).toHaveBeenCalledWith('Insufficient permissions', {
367
+ description:
368
+ 'You do not have permission to upload images in this editor.',
369
+ });
370
+ });
303
371
  });
304
372
 
305
373
  describe('drop handler behavior', () => {
@@ -53,6 +53,16 @@ describe('VideoExtension', () => {
53
53
  const extension = Video();
54
54
  expect(extension).toBeDefined();
55
55
  });
56
+
57
+ it('should accept a delegated upload getter', () => {
58
+ const mockUpload = vi.fn().mockResolvedValue('url');
59
+ const extension = Video({
60
+ onVideoUpload: vi.fn().mockResolvedValue('stale-url'),
61
+ getOnVideoUpload: () => mockUpload,
62
+ });
63
+
64
+ expect(extension).toBeDefined();
65
+ });
56
66
  });
57
67
 
58
68
  describe('node configuration', () => {
@@ -251,6 +261,43 @@ describe('VideoExtension', () => {
251
261
  expect(result).toBe(false);
252
262
  });
253
263
 
264
+ it('should return false when delegated video upload permission is cleared', () => {
265
+ const staleUpload = vi.fn().mockResolvedValue('stale-url');
266
+ const extension = Video({
267
+ onVideoUpload: staleUpload,
268
+ getOnVideoUpload: () => undefined,
269
+ });
270
+ const plugins = (
271
+ extension.config as any
272
+ ).addProseMirrorPlugins() as any[];
273
+ const pastePlugin = plugins[1];
274
+
275
+ const mockView = {
276
+ state: { selection: { from: 0, to: 0 }, tr: {} },
277
+ dispatch: vi.fn(),
278
+ };
279
+ const mockEvent = {
280
+ clipboardData: {
281
+ items: [
282
+ {
283
+ type: 'video/mp4',
284
+ getAsFile: () => new File(['video'], 'video.mp4'),
285
+ },
286
+ ],
287
+ },
288
+ preventDefault: vi.fn(),
289
+ } as unknown as ClipboardEvent;
290
+
291
+ const result = pastePlugin.props?.handleDOMEvents?.paste(
292
+ mockView as any,
293
+ mockEvent
294
+ );
295
+
296
+ expect(result).toBe(false);
297
+ expect(mockEvent.preventDefault).not.toHaveBeenCalled();
298
+ expect(staleUpload).not.toHaveBeenCalled();
299
+ });
300
+
254
301
  it('should return false when clipboard has no items', () => {
255
302
  const mockUpload = vi.fn().mockResolvedValue('url');
256
303
  const extension = Video({ onVideoUpload: mockUpload });
@@ -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
  }
@@ -213,7 +213,7 @@ export function getEditorExtensions({
213
213
  getOnImageUpload,
214
214
  getOnVideoUpload,
215
215
  }),
216
- Video({ onVideoUpload }).configure({
216
+ Video({ onVideoUpload, getOnVideoUpload }).configure({
217
217
  HTMLAttributes: {
218
218
  class: 'rounded-md my-4',
219
219
  },
@@ -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,
@@ -29,6 +29,15 @@ const VIDEO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/;
29
29
 
30
30
  interface VideoOptions {
31
31
  onVideoUpload?: (file: File) => Promise<string>;
32
+ getOnVideoUpload?: () => ((file: File) => Promise<string>) | undefined;
33
+ }
34
+
35
+ function resolveVideoUploadHandler(options: VideoOptions) {
36
+ if (options.getOnVideoUpload) {
37
+ return options.getOnVideoUpload();
38
+ }
39
+
40
+ return options.onVideoUpload;
32
41
  }
33
42
 
34
43
  /**
@@ -114,8 +123,6 @@ export const Video = (options: VideoOptions = {}) =>
114
123
  },
115
124
 
116
125
  addProseMirrorPlugins() {
117
- const { onVideoUpload } = options;
118
-
119
126
  return [
120
127
  // Video upload placeholder plugin - manages loading state decorations
121
128
  new Plugin({
@@ -166,6 +173,7 @@ export const Video = (options: VideoOptions = {}) =>
166
173
  props: {
167
174
  handleDOMEvents: {
168
175
  paste: (view, event: ClipboardEvent) => {
176
+ const onVideoUpload = resolveVideoUploadHandler(options);
169
177
  if (!onVideoUpload) return false;
170
178
 
171
179
  const items = event.clipboardData?.items;
@@ -303,6 +311,7 @@ export const Video = (options: VideoOptions = {}) =>
303
311
  props: {
304
312
  handleDOMEvents: {
305
313
  drop(view, event) {
314
+ const onVideoUpload = resolveVideoUploadHandler(options);
306
315
  if (!onVideoUpload) return false;
307
316
 
308
317
  const { schema } = view.state;