@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.
- package/CHANGELOG.md +48 -0
- package/package.json +8 -8
- package/src/components/ui/currency-input.test.tsx +43 -0
- package/src/components/ui/currency-input.tsx +1 -1
- package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +60 -35
- package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +176 -167
- package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +16 -10
- package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +75 -36
- package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +39 -42
- package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +1 -1
- package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +113 -91
- package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +73 -32
- package/src/components/ui/finance/transactions/form-types.ts +2 -0
- package/src/components/ui/finance/transactions/transaction-card.tsx +21 -9
- package/src/components/ui/money-input.test.tsx +64 -0
- package/src/components/ui/money-input.tsx +63 -0
- package/src/components/ui/storefront/cart-summary.tsx +114 -29
- package/src/components/ui/storefront/checkout-overlay.tsx +27 -0
- package/src/components/ui/storefront/hero-panel.tsx +2 -8
- package/src/components/ui/storefront/image-panel.tsx +6 -0
- package/src/components/ui/storefront/index.ts +11 -0
- package/src/components/ui/storefront/listing-card.tsx +84 -22
- package/src/components/ui/storefront/product-detail.tsx +289 -0
- package/src/components/ui/storefront/product-dialog.tsx +72 -0
- package/src/components/ui/storefront/storefront-surface.test.tsx +124 -1
- package/src/components/ui/storefront/storefront-surface.tsx +333 -133
- package/src/components/ui/storefront/types.ts +23 -1
- package/src/components/ui/storefront/utils.ts +111 -27
- package/src/components/ui/text-editor/__tests__/content-migration.test.ts +32 -0
- package/src/components/ui/text-editor/__tests__/image-extension.test.ts +69 -1
- package/src/components/ui/text-editor/__tests__/video-extension.test.ts +47 -0
- package/src/components/ui/text-editor/content-migration.ts +41 -18
- package/src/components/ui/text-editor/extensions.ts +1 -1
- package/src/components/ui/text-editor/image-extension.ts +40 -18
- package/src/components/ui/text-editor/video-extension.ts +11 -2
- package/src/components/ui/tu-do/boards/__tests__/workspace-projects-client-page.test.tsx +70 -1
- package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +127 -0
- package/src/components/ui/tu-do/boards/boardId/board-column.tsx +1 -3
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +13 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +46 -8
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +13 -2
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +3 -1
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +164 -0
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +56 -2
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-display.ts +9 -0
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +8 -16
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +5 -25
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.test.ts +36 -1
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.ts +51 -2
- package/src/components/ui/tu-do/boards/workspace-projects-client-page.tsx +13 -3
- package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +34 -1
- package/src/components/ui/tu-do/shared/board-header.tsx +39 -0
- package/src/components/ui/tu-do/shared/board-views.tsx +9 -7
- package/src/components/ui/tu-do/shared/cursor-overlay-multi-wrapper.tsx +53 -12
- package/src/components/ui/tu-do/shared/task-dialog-presentation.test.ts +53 -0
- package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +19 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +57 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +136 -111
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +42 -14
- package/src/hooks/__tests__/useBoardRealtime.test.tsx +2 -2
- package/src/hooks/__tests__/useCursorTracking.test.tsx +212 -0
- package/src/hooks/useBoardRealtime.ts +6 -3
- package/src/hooks/useBoardRealtime.types.ts +11 -0
- package/src/hooks/useCursorTracking.ts +91 -27
- 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:
|
|
12
|
-
rounded:
|
|
13
|
-
soft:
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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('
|
|
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
|
|
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
|
|
50
|
+
for (const node of contentChildren) {
|
|
44
51
|
// Recursively migrate nested structures (lists, blockquotes, etc.)
|
|
45
|
-
if (
|
|
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' &&
|
|
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 (
|
|
95
|
+
if (contentChildren?.length) {
|
|
84
96
|
const migratedChildren: JSONContent[] = [];
|
|
85
97
|
const allExtractedImages: JSONContent[] = [];
|
|
86
98
|
|
|
87
|
-
for (const child of
|
|
88
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
168
|
+
const contentChildren = getContentChildren(node);
|
|
169
|
+
if (!contentChildren || contentChildren.length === 0) return false;
|
|
152
170
|
|
|
153
|
-
return
|
|
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
|
|
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
|
|
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' &&
|
|
184
|
-
const hasImage =
|
|
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 (
|
|
192
|
-
return
|
|
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
|
|
221
|
+
return contentChildren.some((node) => checkNode(node));
|
|
199
222
|
}
|
|
@@ -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?:
|
|
141
|
-
onVideoUpload?:
|
|
142
|
-
getOnImageUpload?:
|
|
143
|
-
getOnVideoUpload?:
|
|
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?:
|
|
170
|
-
onVideoUpload?:
|
|
171
|
-
getOnImageUpload?:
|
|
172
|
-
getOnVideoUpload?:
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
250
|
+
resolveUploadHandler({
|
|
251
|
+
delegatedGetter:
|
|
252
|
+
this?.options?.getOnImageUpload ?? options.getOnImageUpload,
|
|
253
|
+
configuredHandler:
|
|
254
|
+
this?.options?.onImageUpload ?? options.onImageUpload,
|
|
255
|
+
});
|
|
236
256
|
const getOnVideoUpload = () =>
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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;
|