@aphexcms/cms-core 9.1.0 → 9.2.1

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 (97) hide show
  1. package/dist/cli/generate-types.d.ts.map +1 -1
  2. package/dist/cli/generate-types.js +8 -3
  3. package/dist/cli/generate-types.js.map +1 -1
  4. package/dist/client/index.d.ts +1 -0
  5. package/dist/client/index.d.ts.map +1 -1
  6. package/dist/client/index.js +4 -0
  7. package/dist/components/AdminApp.svelte +27 -11
  8. package/dist/components/AdminApp.svelte.d.ts.map +1 -1
  9. package/dist/components/admin/DocumentEditor.svelte +548 -21
  10. package/dist/components/admin/DocumentEditor.svelte.d.ts +6 -0
  11. package/dist/components/admin/DocumentEditor.svelte.d.ts.map +1 -1
  12. package/dist/components/admin/SchemaField.svelte +1 -1
  13. package/dist/components/admin/fields/ArrayField.svelte +2 -2
  14. package/dist/components/admin/fields/ImageField.svelte +85 -27
  15. package/dist/components/admin/fields/ImageField.svelte.d.ts.map +1 -1
  16. package/dist/components/admin/fields/richtext/ImageBlockModal.svelte +133 -0
  17. package/dist/components/admin/fields/richtext/ImageBlockModal.svelte.d.ts +14 -0
  18. package/dist/components/admin/fields/richtext/ImageBlockModal.svelte.d.ts.map +1 -0
  19. package/dist/components/admin/fields/richtext/PortableTextImageView.svelte +2 -2
  20. package/dist/components/admin/fields/richtext/RichtextField.svelte +182 -19
  21. package/dist/components/admin/fields/richtext/RichtextField.svelte.d.ts.map +1 -1
  22. package/dist/components/admin/fields/richtext/portable-text-serializer.d.ts.map +1 -1
  23. package/dist/components/admin/fields/richtext/portable-text-serializer.js +22 -1
  24. package/dist/components/admin/fields/richtext/svelte-node-view.d.ts.map +1 -1
  25. package/dist/components/admin/fields/richtext/svelte-node-view.js +11 -0
  26. package/dist/lib/client/index.d.ts +1 -0
  27. package/dist/lib/client/index.d.ts.map +1 -1
  28. package/dist/lib/client/index.js +4 -0
  29. package/dist/lib/client/index.js.map +1 -1
  30. package/dist/lib/components/admin/fields/richtext/portable-text-serializer.d.ts.map +1 -1
  31. package/dist/lib/components/admin/fields/richtext/portable-text-serializer.js +22 -1
  32. package/dist/lib/components/admin/fields/richtext/portable-text-serializer.js.map +1 -1
  33. package/dist/lib/components/admin/fields/richtext/svelte-node-view.d.ts.map +1 -1
  34. package/dist/lib/components/admin/fields/richtext/svelte-node-view.js +11 -0
  35. package/dist/lib/components/admin/fields/richtext/svelte-node-view.js.map +1 -1
  36. package/dist/lib/preview/assets.d.ts +19 -0
  37. package/dist/lib/preview/assets.d.ts.map +1 -0
  38. package/dist/lib/preview/assets.js +60 -0
  39. package/dist/lib/preview/assets.js.map +1 -0
  40. package/dist/lib/preview/perspective.d.ts +20 -0
  41. package/dist/lib/preview/perspective.d.ts.map +1 -0
  42. package/dist/lib/preview/perspective.js +21 -0
  43. package/dist/lib/preview/perspective.js.map +1 -0
  44. package/dist/lib/preview/stega.d.ts +25 -0
  45. package/dist/lib/preview/stega.d.ts.map +1 -0
  46. package/dist/lib/preview/stega.js +168 -0
  47. package/dist/lib/preview/stega.js.map +1 -0
  48. package/dist/lib/richtext-context.svelte.d.ts +18 -0
  49. package/dist/lib/richtext-context.svelte.d.ts.map +1 -0
  50. package/dist/lib/richtext-context.svelte.js +13 -0
  51. package/dist/lib/richtext-context.svelte.js.map +1 -0
  52. package/dist/lib/server/index.d.ts +1 -0
  53. package/dist/lib/server/index.d.ts.map +1 -1
  54. package/dist/lib/server/index.js +2 -0
  55. package/dist/lib/server/index.js.map +1 -1
  56. package/dist/lib/services/asset-service.d.ts +12 -0
  57. package/dist/lib/services/asset-service.d.ts.map +1 -1
  58. package/dist/lib/services/asset-service.js +32 -0
  59. package/dist/lib/services/asset-service.js.map +1 -1
  60. package/dist/lib/types/asset.d.ts +15 -0
  61. package/dist/lib/types/asset.d.ts.map +1 -1
  62. package/dist/lib/types/config.d.ts +7 -0
  63. package/dist/lib/types/config.d.ts.map +1 -1
  64. package/dist/lib/types/schemas.d.ts +17 -0
  65. package/dist/lib/types/schemas.d.ts.map +1 -1
  66. package/dist/lib/utils/preview.d.ts +4 -0
  67. package/dist/lib/utils/preview.d.ts.map +1 -1
  68. package/dist/lib/utils/preview.js +7 -0
  69. package/dist/lib/utils/preview.js.map +1 -1
  70. package/dist/preview/assets.d.ts +19 -0
  71. package/dist/preview/assets.d.ts.map +1 -0
  72. package/dist/preview/assets.js +59 -0
  73. package/dist/preview/perspective.d.ts +20 -0
  74. package/dist/preview/perspective.d.ts.map +1 -0
  75. package/dist/preview/perspective.js +20 -0
  76. package/dist/preview/stega.d.ts +25 -0
  77. package/dist/preview/stega.d.ts.map +1 -0
  78. package/dist/preview/stega.js +167 -0
  79. package/dist/richtext-context.svelte.d.ts +18 -0
  80. package/dist/richtext-context.svelte.d.ts.map +1 -0
  81. package/dist/richtext-context.svelte.js +12 -0
  82. package/dist/server/index.d.ts +1 -0
  83. package/dist/server/index.d.ts.map +1 -1
  84. package/dist/server/index.js +2 -0
  85. package/dist/services/asset-service.d.ts +12 -0
  86. package/dist/services/asset-service.d.ts.map +1 -1
  87. package/dist/services/asset-service.js +32 -0
  88. package/dist/types/asset.d.ts +15 -0
  89. package/dist/types/asset.d.ts.map +1 -1
  90. package/dist/types/config.d.ts +7 -0
  91. package/dist/types/config.d.ts.map +1 -1
  92. package/dist/types/schemas.d.ts +17 -0
  93. package/dist/types/schemas.d.ts.map +1 -1
  94. package/dist/utils/preview.d.ts +4 -0
  95. package/dist/utils/preview.d.ts.map +1 -1
  96. package/dist/utils/preview.js +7 -0
  97. package/package.json +3 -2
@@ -211,21 +211,25 @@
211
211
  assetData?.originalFilename || assetData?.filename || value?.asset?._ref || 'Image'
212
212
  );
213
213
 
214
- // Alt text stored on the asset record, not on the ImageValue
215
- let altText = $state('');
216
- let altSaveTimer: ReturnType<typeof setTimeout> | null = null;
217
-
218
- // Sync altText from asset data when it loads
214
+ // Two layers of alt text:
215
+ // • Default stored on the ASSET, shared across every placement. Set it once and
216
+ // it applies everywhere the image is used.
217
+ // • Override — stored on THIS image value, for when one spot needs a different
218
+ // description. Render precedence is `value.alt || asset.alt`.
219
+ // The override is also what carries visual-editing stega.
220
+ let assetAltText = $state('');
221
+ let assetAltTimer: ReturnType<typeof setTimeout> | null = null;
222
+
223
+ // Sync the default from the loaded asset record.
219
224
  $effect(() => {
220
- altText = assetData?.alt || '';
225
+ assetAltText = assetData?.alt || '';
221
226
  });
222
227
 
223
- function updateAltText(newAlt: string) {
224
- altText = newAlt;
225
-
226
- // Debounce the API call
227
- if (altSaveTimer) clearTimeout(altSaveTimer);
228
- altSaveTimer = setTimeout(async () => {
228
+ function updateAssetAlt(newAlt: string) {
229
+ assetAltText = newAlt;
230
+ // Debounce the asset-update API call.
231
+ if (assetAltTimer) clearTimeout(assetAltTimer);
232
+ assetAltTimer = setTimeout(async () => {
229
233
  const assetId = value?.asset?._ref;
230
234
  if (!assetId) return;
231
235
  try {
@@ -236,6 +240,17 @@
236
240
  }, 800);
237
241
  }
238
242
 
243
+ function updateOverrideAlt(newAlt: string) {
244
+ if (!value) return;
245
+ onUpdate({ ...value, alt: newAlt || undefined });
246
+ }
247
+
248
+ // Whether to reveal the per-placement override input.
249
+ let showOverride = $state(false);
250
+ $effect(() => {
251
+ if (value?.alt) showOverride = true;
252
+ });
253
+
239
254
  // Download image
240
255
  function downloadImage() {
241
256
  if (previewUrl) {
@@ -272,7 +287,7 @@
272
287
  {:else if previewUrl}
273
288
  <img
274
289
  src={previewUrl}
275
- alt={assetData?.alt || displayName}
290
+ alt={value?.alt || assetData?.alt || displayName}
276
291
  class="h-full w-full object-cover"
277
292
  loading="lazy"
278
293
  />
@@ -299,7 +314,7 @@
299
314
  {:else if previewUrl}
300
315
  <img
301
316
  src={previewUrl}
302
- alt={assetData?.alt || displayName}
317
+ alt={value?.alt || assetData?.alt || displayName}
303
318
  class="h-full w-full object-cover"
304
319
  loading="lazy"
305
320
  />
@@ -389,7 +404,7 @@
389
404
  {:else if previewUrl}
390
405
  <img
391
406
  src={previewUrl}
392
- alt={assetData?.alt || 'Uploaded image'}
407
+ alt={value?.alt || assetData?.alt || 'Uploaded image'}
393
408
  class="h-full w-full object-contain"
394
409
  loading="lazy"
395
410
  />
@@ -453,18 +468,61 @@
453
468
  {/if}
454
469
  </div>
455
470
 
456
- <!-- Alt text field -->
457
- <div class="border-border space-y-1.5 border-t p-3">
458
- <label class="text-sm font-medium" for="alt-{field.name}">Alt text</label>
459
- <p class="text-muted-foreground text-xs">Describe the image for accessibility and SEO</p>
460
- <Input
461
- id="alt-{field.name}"
462
- type="text"
463
- placeholder="Describe this image..."
464
- value={altText}
465
- oninput={(e) => updateAltText(e.currentTarget.value)}
466
- disabled={isReadOnly}
467
- />
471
+ <!-- Alt text asset-level default + optional per-placement override -->
472
+ <div class="border-border space-y-2 border-t p-3">
473
+ <div class="space-y-1.5">
474
+ <label class="text-sm font-medium" for="alt-{field.name}">Alt text</label>
475
+ <p class="text-muted-foreground text-xs">
476
+ Describes the image everywhere it's used (accessibility &amp; SEO).
477
+ </p>
478
+ <Input
479
+ id="alt-{field.name}"
480
+ type="text"
481
+ placeholder="Describe this image..."
482
+ value={assetAltText}
483
+ oninput={(e) => updateAssetAlt(e.currentTarget.value)}
484
+ disabled={isReadOnly}
485
+ />
486
+ </div>
487
+
488
+ {#if showOverride}
489
+ <div class="space-y-1.5">
490
+ <div class="flex items-center justify-between">
491
+ <label
492
+ class="text-muted-foreground text-xs font-medium"
493
+ for="alt-override-{field.name}">Override for this placement</label
494
+ >
495
+ {#if !isReadOnly}
496
+ <button
497
+ type="button"
498
+ class="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
499
+ onclick={() => {
500
+ updateOverrideAlt('');
501
+ showOverride = false;
502
+ }}
503
+ >
504
+ Remove
505
+ </button>
506
+ {/if}
507
+ </div>
508
+ <Input
509
+ id="alt-override-{field.name}"
510
+ type="text"
511
+ placeholder={assetAltText || 'Describe this image...'}
512
+ value={value?.alt ?? ''}
513
+ oninput={(e) => updateOverrideAlt(e.currentTarget.value)}
514
+ disabled={isReadOnly}
515
+ />
516
+ </div>
517
+ {:else if !isReadOnly}
518
+ <button
519
+ type="button"
520
+ class="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
521
+ onclick={() => (showOverride = true)}
522
+ >
523
+ + Override for this placement
524
+ </button>
525
+ {/if}
468
526
  </div>
469
527
  </div>
470
528
  {:else}
@@ -1 +1 @@
1
- {"version":3,"file":"ImageField.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/admin/fields/ImageField.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AACvD,OAAO,KAAK,EAAE,UAAU,IAAI,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAmB1E,UAAU,KAAK;IACd,KAAK,EAAE,cAAc,CAAC;IACtB,KAAK,EAAE,UAAU,GAAG,IAAI,CAAC;IACzB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,KAAK,IAAI,CAAC;IAC7C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;CACxB;AA4cF,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
1
+ {"version":3,"file":"ImageField.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/admin/fields/ImageField.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AACvD,OAAO,KAAK,EAAE,UAAU,IAAI,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAmB1E,UAAU,KAAK;IACd,KAAK,EAAE,cAAc,CAAC;IACtB,KAAK,EAAE,UAAU,GAAG,IAAI,CAAC;IACzB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,KAAK,IAAI,CAAC;IAC7C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;CACxB;AAofF,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
@@ -0,0 +1,133 @@
1
+ <script lang="ts">
2
+ import { Button } from '@aphexcms/ui/shadcn/button';
3
+ import { Input } from '@aphexcms/ui/shadcn/input';
4
+ import { Image as ImageIcon, Upload, Trash2, X } from '@lucide/svelte';
5
+ import { assets } from '../../../../api/assets';
6
+
7
+ interface Props {
8
+ open: boolean;
9
+ assetRef?: string;
10
+ alt: string;
11
+ readonly?: boolean;
12
+ onAltChange: (alt: string) => void;
13
+ onReplace: () => void;
14
+ onRemove: () => void;
15
+ onClose: () => void;
16
+ }
17
+
18
+ let {
19
+ open,
20
+ assetRef,
21
+ alt,
22
+ readonly = false,
23
+ onAltChange,
24
+ onReplace,
25
+ onRemove,
26
+ onClose
27
+ }: Props = $props();
28
+
29
+ const assetPromise = $derived(assetRef ? assets.getById(assetRef) : null);
30
+
31
+ function handleBackdropClick(e: MouseEvent) {
32
+ if (e.target === e.currentTarget) onClose();
33
+ }
34
+
35
+ function handleKeydown(e: KeyboardEvent) {
36
+ if (e.key === 'Escape') onClose();
37
+ }
38
+ </script>
39
+
40
+ {#if open}
41
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
42
+ <div
43
+ class="bg-background/80 absolute inset-0 z-50 flex items-center justify-center p-4 lg:p-6"
44
+ onclick={handleBackdropClick}
45
+ onkeydown={handleKeydown}
46
+ role="button"
47
+ tabindex="-1"
48
+ >
49
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
50
+ <div
51
+ class="border-border bg-background w-full max-w-lg overflow-hidden rounded-lg border shadow-lg"
52
+ onclick={(e) => e.stopPropagation()}
53
+ onkeydown={(e) => e.stopPropagation()}
54
+ >
55
+ <div class="border-border flex items-center justify-between border-b px-4 py-2">
56
+ <span class="text-sm font-medium">Edit image</span>
57
+ <Button variant="ghost" size="icon" class="h-7 w-7" onclick={onClose}>
58
+ <X class="h-3.5 w-3.5" />
59
+ </Button>
60
+ </div>
61
+
62
+ <div class="space-y-3 p-4">
63
+ <!-- Preview -->
64
+ <div
65
+ class="bg-muted flex items-center justify-center overflow-hidden rounded-md"
66
+ style="aspect-ratio: 16 / 9;"
67
+ >
68
+ {#if assetPromise}
69
+ {#await assetPromise}
70
+ <div
71
+ class="border-primary h-5 w-5 animate-spin rounded-full border-2 border-t-transparent"
72
+ ></div>
73
+ {:then result}
74
+ {#if result.success && result.data?.url}
75
+ <img src={result.data.url} {alt} class="h-full w-full object-contain" />
76
+ {:else}
77
+ <div class="text-muted-foreground flex flex-col items-center gap-1">
78
+ <ImageIcon class="h-8 w-8" />
79
+ <span class="text-xs">Image not found</span>
80
+ </div>
81
+ {/if}
82
+ {:catch}
83
+ <div class="text-muted-foreground flex flex-col items-center gap-1">
84
+ <ImageIcon class="h-8 w-8" />
85
+ <span class="text-xs">Failed to load</span>
86
+ </div>
87
+ {/await}
88
+ {:else}
89
+ <div class="text-muted-foreground flex flex-col items-center gap-1">
90
+ <ImageIcon class="h-8 w-8" />
91
+ <span class="text-xs">No image</span>
92
+ </div>
93
+ {/if}
94
+ </div>
95
+
96
+ <!-- Alt text (per-placement override of the asset's default alt) -->
97
+ <div class="space-y-1.5">
98
+ <label class="text-sm font-medium" for="image-block-alt">Alt text</label>
99
+ <p class="text-muted-foreground text-xs">
100
+ Overrides the image's default alt for this spot. Leave blank to use the default.
101
+ </p>
102
+ <Input
103
+ id="image-block-alt"
104
+ type="text"
105
+ placeholder="Describe this image..."
106
+ value={alt}
107
+ oninput={(e) => onAltChange(e.currentTarget.value)}
108
+ disabled={readonly}
109
+ />
110
+ </div>
111
+
112
+ <!-- Actions -->
113
+ {#if !readonly}
114
+ <div class="flex items-center justify-between gap-2 pt-1">
115
+ <Button variant="outline" size="sm" onclick={onReplace}>
116
+ <Upload class="mr-1.5 h-4 w-4" />
117
+ Replace
118
+ </Button>
119
+ <Button
120
+ variant="ghost"
121
+ size="sm"
122
+ class="text-destructive hover:text-destructive"
123
+ onclick={onRemove}
124
+ >
125
+ <Trash2 class="mr-1.5 h-4 w-4" />
126
+ Remove
127
+ </Button>
128
+ </div>
129
+ {/if}
130
+ </div>
131
+ </div>
132
+ </div>
133
+ {/if}
@@ -0,0 +1,14 @@
1
+ interface Props {
2
+ open: boolean;
3
+ assetRef?: string;
4
+ alt: string;
5
+ readonly?: boolean;
6
+ onAltChange: (alt: string) => void;
7
+ onReplace: () => void;
8
+ onRemove: () => void;
9
+ onClose: () => void;
10
+ }
11
+ declare const ImageBlockModal: import("svelte").Component<Props, {}, "">;
12
+ type ImageBlockModal = ReturnType<typeof ImageBlockModal>;
13
+ export default ImageBlockModal;
14
+ //# sourceMappingURL=ImageBlockModal.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ImageBlockModal.svelte.d.ts","sourceRoot":"","sources":["../../../../../src/lib/components/admin/fields/richtext/ImageBlockModal.svelte.ts"],"names":[],"mappings":"AASC,UAAU,KAAK;IACd,IAAI,EAAE,OAAO,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,WAAW,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,OAAO,EAAE,MAAM,IAAI,CAAC;CACpB;AAoGF,QAAA,MAAM,eAAe,2CAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
@@ -19,7 +19,7 @@
19
19
 
20
20
  <!-- svelte-ignore a11y_click_events_have_key_events -->
21
21
  <!-- svelte-ignore a11y_no_static_element_interactions -->
22
- <div class="image-block-view group" class:selected onclick={onEdit}>
22
+ <div class="image-block-view group" class:selected data-pt-image-edit onclick={onEdit}>
23
23
  {#if assetPromise}
24
24
  {#await assetPromise}
25
25
  <div class="image-block-placeholder">
@@ -29,7 +29,7 @@
29
29
  </div>
30
30
  {:then result}
31
31
  {#if result.success && result.data?.url}
32
- <img src={result.data.url} alt={result.data.alt || ''} class="image-block-img" />
32
+ <img src={result.data.url} alt={(data?.alt as string) || ''} class="image-block-img" />
33
33
  {:else}
34
34
  <div class="image-block-placeholder">
35
35
  <ImageIcon class="text-muted-foreground h-8 w-8" />
@@ -35,7 +35,9 @@
35
35
  import type { ArrayField as ArrayFieldType, SchemaType } from '../../../../types/schemas';
36
36
  import { getSchemaContext } from '../../../../schema-context.svelte';
37
37
  import { getSchemaByName } from '../../../../schema-utils/utils';
38
+ import { getRichtextEditorRegistry } from '../../../../richtext-context.svelte.js';
38
39
  import ObjectModal from '../../ObjectModal.svelte';
40
+ import ImageBlockModal from './ImageBlockModal.svelte';
39
41
  import AssetBrowserModal from '../../AssetBrowserModal.svelte';
40
42
  import * as Tooltip from '@aphexcms/ui/shadcn/tooltip';
41
43
  import { Image as ImageIcon } from '@lucide/svelte';
@@ -66,6 +68,7 @@
66
68
  let editor = $state<Editor | null>(null);
67
69
  let editorState = $state(0);
68
70
  let internalVersion = 0;
71
+ let isSyncingFromParent = false;
69
72
 
70
73
  let expanded = $state(false);
71
74
  let containerBounds = $state<{ top: number; left: number; width: number; height: number } | null>(
@@ -189,8 +192,9 @@
189
192
 
190
193
  function handleEditBlock(attrs: { _type: string; _key: string; data: Record<string, unknown> }) {
191
194
  if (attrs._type === 'image') {
192
- editingNodeKey = attrs._key;
193
- showAssetBrowser = true;
195
+ editingImageKey = attrs._key;
196
+ editingImageData = { ...attrs.data };
197
+ imageModalOpen = true;
194
198
  return;
195
199
  }
196
200
  const typeDef = customTypes.find((t) => t.type === attrs._type);
@@ -277,6 +281,50 @@
277
281
 
278
282
  let showAssetBrowser = $state(false);
279
283
 
284
+ // Inline image block editing — preview + alt + replace/remove. `editingImageData`
285
+ // holds the block's data ({ asset, alt }); alt is persisted into the node so it
286
+ // serializes to the portable-text block and carries visual-editing stega.
287
+ let imageModalOpen = $state(false);
288
+ let editingImageKey = $state<string | null>(null);
289
+ let editingImageData = $state<Record<string, any>>({});
290
+
291
+ function applyImageData(key: string, data: Record<string, any>) {
292
+ if (!editor) return;
293
+ const existing = findNodeByKey(key);
294
+ if (!existing) return;
295
+ const { tr } = editor.state;
296
+ tr.setNodeMarkup(existing.pos, undefined, { _type: 'image', _key: key, data });
297
+ editor.view.dispatch(tr);
298
+ }
299
+
300
+ function handleImageAltChange(alt: string) {
301
+ if (!editingImageKey) return;
302
+ editingImageData = { ...editingImageData, alt: alt || undefined };
303
+ applyImageData(editingImageKey, editingImageData);
304
+ }
305
+
306
+ function handleImageReplace() {
307
+ // Hand off to the asset browser, keeping editingImageKey so the alt is preserved
308
+ // and the modal can re-open with the new asset.
309
+ editingNodeKey = editingImageKey;
310
+ imageModalOpen = false;
311
+ showAssetBrowser = true;
312
+ }
313
+
314
+ function handleImageRemove() {
315
+ const key = editingImageKey;
316
+ imageModalOpen = false;
317
+ editingImageKey = null;
318
+ editingImageData = {};
319
+ if (key) handleDeleteBlock(key);
320
+ }
321
+
322
+ function handleImageModalClose() {
323
+ imageModalOpen = false;
324
+ editingImageKey = null;
325
+ editingImageData = {};
326
+ }
327
+
280
328
  function handleInsertImageBlock() {
281
329
  editingNodeKey = null;
282
330
  insertMenuOpen = false;
@@ -286,13 +334,17 @@
286
334
  function handleAssetSelected(asset: any) {
287
335
  if (!editor || !asset) return;
288
336
 
337
+ const newAssetRef = { _type: 'reference', _ref: asset.id };
289
338
  const existing = editingNodeKey ? findNodeByKey(editingNodeKey) : null;
290
339
  if (existing) {
340
+ // Replace — merge into existing data so the block's alt survives the swap.
341
+ const prevData = (existing.node.attrs?.data as Record<string, any>) ?? {};
342
+ const merged = { ...prevData, asset: newAssetRef };
291
343
  const { tr } = editor.state;
292
344
  tr.setNodeMarkup(existing.pos, undefined, {
293
345
  _type: 'image',
294
346
  _key: editingNodeKey,
295
- data: { asset: { _type: 'reference', _ref: asset.id } }
347
+ data: merged
296
348
  });
297
349
  editor.view.dispatch(tr);
298
350
  } else {
@@ -304,7 +356,7 @@
304
356
  attrs: {
305
357
  _type: 'image',
306
358
  _key: genKey(),
307
- data: { asset: { _type: 'reference', _ref: asset.id } }
359
+ data: { asset: newAssetRef }
308
360
  }
309
361
  })
310
362
  .run();
@@ -312,6 +364,14 @@
312
364
 
313
365
  showAssetBrowser = false;
314
366
  editingNodeKey = null;
367
+
368
+ // If the browser was opened from the image modal (a replace), re-open it on the
369
+ // updated block so the user can keep editing the alt.
370
+ if (editingImageKey) {
371
+ const updated = findNodeByKey(editingImageKey);
372
+ editingImageData = (updated?.node.attrs?.data as Record<string, any>) ?? {};
373
+ imageModalOpen = true;
374
+ }
315
375
  }
316
376
 
317
377
  function handleModalUpdate(updatedData: Record<string, any>) {
@@ -440,6 +500,7 @@
440
500
  extensions,
441
501
  content: tiptapDoc,
442
502
  onUpdate: ({ editor: e }) => {
503
+ if (isSyncingFromParent) return;
443
504
  internalVersion++;
444
505
  const json = e.getJSON();
445
506
  const pt = tiptapToPortableText(json);
@@ -495,14 +556,18 @@
495
556
  annotationModalOpen = true;
496
557
  }
497
558
 
559
+ const editorRegistry = getRichtextEditorRegistry();
560
+
498
561
  onMount(() => {
499
562
  createEditor();
563
+ if (editor) editorRegistry?.set(field.name, { editor, openLinkPopover: showLinkPreview });
500
564
  document.addEventListener('click', handleClickOutside);
501
565
  document.addEventListener('keydown', handleKeydown);
502
566
  element?.addEventListener('click', handleAnnotationClick);
503
567
  });
504
568
 
505
569
  onDestroy(() => {
570
+ editorRegistry?.delete(field.name);
506
571
  editor?.destroy();
507
572
  document.removeEventListener('click', handleClickOutside);
508
573
  document.removeEventListener('keydown', handleKeydown);
@@ -534,6 +599,33 @@
534
599
  // own change back" vs "parent loaded a different document."
535
600
  let lastSyncedVersion = 0;
536
601
  let editorJustCreated = false;
602
+
603
+ // Link marks round-trip lossy: only `href` is persisted to Portable Text, so a
604
+ // rehydrated link drops tiptap's presentation attrs (e.g. `class: 'richtext-link'`
605
+ // → null). Strip those volatile attrs before the equality check below, otherwise an
606
+ // identical round-trip looks "changed", replaces the doc, and collapses the caret to
607
+ // the end — yanking it out of a link the user just clicked in presentation mode.
608
+ function stripVolatileMarks(node: unknown): unknown {
609
+ if (Array.isArray(node)) return node.map(stripVolatileMarks);
610
+ if (node && typeof node === 'object') {
611
+ const obj = node as Record<string, unknown>;
612
+ const out: Record<string, unknown> = {};
613
+ for (const key in obj) {
614
+ if (key === 'marks' && Array.isArray(obj.marks)) {
615
+ out.marks = (obj.marks as Array<Record<string, unknown>>).map((m) =>
616
+ m?.type === 'link'
617
+ ? { type: 'link', attrs: { href: (m.attrs as { href?: unknown })?.href ?? '' } }
618
+ : m
619
+ );
620
+ } else {
621
+ out[key] = stripVolatileMarks(obj[key]);
622
+ }
623
+ }
624
+ return out;
625
+ }
626
+ return node;
627
+ }
628
+
537
629
  $effect(() => {
538
630
  const v = value;
539
631
  if (!editor) return;
@@ -545,12 +637,24 @@
545
637
  lastSyncedVersion = internalVersion;
546
638
  return;
547
639
  }
548
- // Replace content without polluting undo history
640
+ // Skip when the incoming value would produce identical content. The parent
641
+ // can hand us a fresh array reference with the same data (e.g. the live-
642
+ // preview round-trip in presentation mode reassigns documentData); replacing
643
+ // the doc in that case needlessly collapses the selection to the end — which
644
+ // reads as "the cursor jumps to the bottom" mid-edit.
549
645
  const tiptapDoc = portableTextToTiptap(v || []);
646
+ if (
647
+ JSON.stringify(stripVolatileMarks(tiptapDoc)) ===
648
+ JSON.stringify(stripVolatileMarks(editor.getJSON()))
649
+ )
650
+ return;
651
+ // Replace content without polluting undo history
550
652
  const newDoc = editor.state.schema.nodeFromJSON(tiptapDoc);
551
653
  const tr = editor.state.tr.replaceWith(0, editor.state.doc.content.size, newDoc.content);
552
654
  tr.setMeta('addToHistory', false);
655
+ isSyncingFromParent = true;
553
656
  editor.view.dispatch(tr);
657
+ isSyncingFromParent = false;
554
658
  });
555
659
 
556
660
  $effect(() => {
@@ -585,15 +689,45 @@
585
689
  let linkPopoverPos = $state({ top: 0, left: 0 });
586
690
  let linkPreviewHref = $state('');
587
691
 
692
+ // Render the link popover at <body> level. The editor can sit inside an ancestor with a
693
+ // `transform` (e.g. the sliding responsive document panel), which would make the popover's
694
+ // `position: fixed` resolve against that ancestor instead of the viewport — placing it
695
+ // off-screen. Portaling escapes any such containing block / overflow clip.
696
+ function portal(node: HTMLElement) {
697
+ document.body.appendChild(node);
698
+ return {
699
+ destroy() {
700
+ node.remove();
701
+ }
702
+ };
703
+ }
704
+
588
705
  function positionLinkPopover() {
589
- if (!editor || !element) return;
706
+ if (!editor) return;
590
707
  const { from, to } = editor.state.selection;
591
708
  const start = editor.view.coordsAtPos(from);
592
709
  const end = editor.view.coordsAtPos(to);
593
- const editorRect = element.getBoundingClientRect();
710
+
711
+ // The popover is `position: fixed`, so it works in viewport coordinates — `coordsAtPos`
712
+ // already returns those. This sidesteps the editor's scroll/overflow container (e.g. the
713
+ // split form panel in visual-editing mode), which would otherwise clip or shift an
714
+ // absolutely-positioned child.
715
+ const POPOVER_H = 44;
716
+ const POPOVER_W = 320; // approx; only used to keep the popover off the right edge
717
+ const GAP = 4;
718
+ const MARGIN = 8;
719
+
720
+ // Below the selection by default; flip above when it would spill past the viewport bottom.
721
+ const roomBelow = end.bottom + GAP + POPOVER_H <= window.innerHeight - MARGIN;
722
+ const roomAbove = start.top - GAP - POPOVER_H >= MARGIN;
723
+ const placeBelow = roomBelow || !roomAbove;
724
+
594
725
  linkPopoverPos = {
595
- top: end.bottom - editorRect.top + 4,
596
- left: Math.max(0, Math.min(start.left, end.left) - editorRect.left)
726
+ top: placeBelow ? end.bottom + GAP : start.top - GAP - POPOVER_H,
727
+ left: Math.min(
728
+ Math.max(MARGIN, Math.min(start.left, end.left)),
729
+ window.innerWidth - POPOVER_W - MARGIN
730
+ )
597
731
  };
598
732
  }
599
733
 
@@ -850,14 +984,12 @@
850
984
  Strikethrough
851
985
  )}
852
986
  {/if}
853
- {#if hasDecorator('code')}
854
- {@render toolbarBtn(
855
- () => editor?.chain().focus().toggleCode().run(),
856
- 'Inline code',
857
- () => editor?.isActive('code') ?? false,
858
- Code
859
- )}
860
- {/if}
987
+ {@render toolbarBtn(
988
+ () => editor?.chain().focus().toggleCodeBlock().run(),
989
+ 'Code block',
990
+ () => editor?.isActive('codeBlock') ?? false,
991
+ Code
992
+ )}
861
993
 
862
994
  {#if (hasDecorator('strong') || hasDecorator('em')) && (hasList('bullet') || hasList('number') || hasLink)}
863
995
  <div class="bg-border mx-1 h-4 w-px"></div>
@@ -1016,7 +1148,8 @@
1016
1148
 
1017
1149
  {#if linkMode === 'preview'}
1018
1150
  <div
1019
- class="border-rule bg-popover absolute z-40 flex items-center gap-1.5 rounded-md border px-2 py-1.5 shadow-lg"
1151
+ use:portal
1152
+ class="border-rule bg-popover fixed z-[60] flex items-center gap-1.5 rounded-md border px-2 py-1.5 shadow-lg"
1020
1153
  style="top: {linkPopoverPos.top}px; left: {linkPopoverPos.left}px;"
1021
1154
  >
1022
1155
  <a
@@ -1058,7 +1191,8 @@
1058
1191
  </div>
1059
1192
  {:else if linkMode === 'edit'}
1060
1193
  <div
1061
- class="border-rule bg-popover absolute z-40 flex items-center gap-2 rounded-md border px-3 py-2 shadow-lg"
1194
+ use:portal
1195
+ class="border-rule bg-popover fixed z-[60] flex items-center gap-2 rounded-md border px-3 py-2 shadow-lg"
1062
1196
  style="top: {linkPopoverPos.top}px; left: {linkPopoverPos.left}px;"
1063
1197
  >
1064
1198
  <LinkIcon class="text-muted-foreground h-4 w-4 shrink-0" />
@@ -1137,6 +1271,19 @@
1137
1271
  onSelect={handleAssetSelected}
1138
1272
  />
1139
1273
 
1274
+ {#if imageModalOpen}
1275
+ <ImageBlockModal
1276
+ open={imageModalOpen}
1277
+ assetRef={editingImageData?.asset?._ref}
1278
+ alt={(editingImageData?.alt as string) ?? ''}
1279
+ {readonly}
1280
+ onAltChange={handleImageAltChange}
1281
+ onReplace={handleImageReplace}
1282
+ onRemove={handleImageRemove}
1283
+ onClose={handleImageModalClose}
1284
+ />
1285
+ {/if}
1286
+
1140
1287
  <style>
1141
1288
  .richtext-editor {
1142
1289
  position: relative;
@@ -1251,6 +1398,22 @@
1251
1398
  margin: 0.25em 0;
1252
1399
  }
1253
1400
 
1401
+ .richtext-content :global(.tiptap pre) {
1402
+ background: var(--muted);
1403
+ border-radius: 6px;
1404
+ padding: 0.75rem 1rem;
1405
+ margin: 0.75em 0;
1406
+ overflow-x: auto;
1407
+ }
1408
+
1409
+ .richtext-content :global(.tiptap pre code) {
1410
+ background: none;
1411
+ padding: 0;
1412
+ border-radius: 0;
1413
+ font-size: 0.875em;
1414
+ font-family: var(--font-mono, monospace);
1415
+ }
1416
+
1254
1417
  .richtext-content :global(.tiptap code) {
1255
1418
  background: var(--muted);
1256
1419
  border-radius: 3px;
@@ -1 +1 @@
1
- {"version":3,"file":"RichtextField.svelte.d.ts","sourceRoot":"","sources":["../../../../../src/lib/components/admin/fields/richtext/RichtextField.svelte.ts"],"names":[],"mappings":"AA4BA,OAAO,EAGL,KAAK,iBAAiB,EACtB,MAAM,4BAA4B,CAAC;AAIrC,OAAO,KAAK,EAAE,UAAU,IAAI,cAAc,EAAc,MAAM,2BAA2B,CAAC;AASzF,UAAU,KAAK;IACd,KAAK,EAAE,cAAc,CAAC;IACtB,KAAK,EAAE,iBAAiB,GAAG,IAAI,GAAG,SAAS,CAAC;IAC5C,QAAQ,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAC7C,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,eAAe,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,IAAI,CAAC;IACrE,cAAc,CAAC,EAAE,MAAM,CAAC;CACxB;AAi+BF,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"RichtextField.svelte.d.ts","sourceRoot":"","sources":["../../../../../src/lib/components/admin/fields/richtext/RichtextField.svelte.ts"],"names":[],"mappings":"AA4BA,OAAO,EAGL,KAAK,iBAAiB,EACtB,MAAM,4BAA4B,CAAC;AAIrC,OAAO,KAAK,EAAE,UAAU,IAAI,cAAc,EAAc,MAAM,2BAA2B,CAAC;AAWzF,UAAU,KAAK;IACd,KAAK,EAAE,cAAc,CAAC;IACtB,KAAK,EAAE,iBAAiB,GAAG,IAAI,GAAG,SAAS,CAAC;IAC5C,QAAQ,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAC7C,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,eAAe,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,IAAI,CAAC;IACrE,cAAc,CAAC,EAAE,MAAM,CAAC;CACxB;AAymCF,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}