@aphexcms/cms-core 9.2.0 → 9.2.2

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 (111) hide show
  1. package/dist/cli/generate-types.d.ts.map +1 -1
  2. package/dist/cli/generate-types.js +21 -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/AdminLayout.svelte.d.ts +3 -3
  10. package/dist/components/admin/DocumentEditor.svelte +548 -21
  11. package/dist/components/admin/DocumentEditor.svelte.d.ts +6 -0
  12. package/dist/components/admin/DocumentEditor.svelte.d.ts.map +1 -1
  13. package/dist/components/admin/SchemaField.svelte +1 -1
  14. package/dist/components/admin/fields/ArrayField.svelte +2 -2
  15. package/dist/components/admin/fields/ImageField.svelte +85 -27
  16. package/dist/components/admin/fields/ImageField.svelte.d.ts.map +1 -1
  17. package/dist/components/admin/fields/NumberField.svelte +61 -16
  18. package/dist/components/admin/fields/NumberField.svelte.d.ts +2 -2
  19. package/dist/components/admin/fields/NumberField.svelte.d.ts.map +1 -1
  20. package/dist/components/admin/fields/StringField.svelte +26 -2
  21. package/dist/components/admin/fields/StringField.svelte.d.ts.map +1 -1
  22. package/dist/components/admin/fields/richtext/ImageBlockModal.svelte +133 -0
  23. package/dist/components/admin/fields/richtext/ImageBlockModal.svelte.d.ts +14 -0
  24. package/dist/components/admin/fields/richtext/ImageBlockModal.svelte.d.ts.map +1 -0
  25. package/dist/components/admin/fields/richtext/PortableTextImageView.svelte +2 -2
  26. package/dist/components/admin/fields/richtext/RichtextField.svelte +164 -13
  27. package/dist/components/admin/fields/richtext/RichtextField.svelte.d.ts.map +1 -1
  28. package/dist/components/admin/fields/richtext/portable-text-serializer.d.ts.map +1 -1
  29. package/dist/components/admin/fields/richtext/portable-text-serializer.js +6 -4
  30. package/dist/components/admin/fields/richtext/svelte-node-view.d.ts.map +1 -1
  31. package/dist/components/admin/fields/richtext/svelte-node-view.js +11 -0
  32. package/dist/lib/client/index.d.ts +1 -0
  33. package/dist/lib/client/index.d.ts.map +1 -1
  34. package/dist/lib/client/index.js +4 -0
  35. package/dist/lib/client/index.js.map +1 -1
  36. package/dist/lib/components/admin/fields/richtext/portable-text-serializer.d.ts.map +1 -1
  37. package/dist/lib/components/admin/fields/richtext/portable-text-serializer.js +6 -4
  38. package/dist/lib/components/admin/fields/richtext/portable-text-serializer.js.map +1 -1
  39. package/dist/lib/components/admin/fields/richtext/svelte-node-view.d.ts.map +1 -1
  40. package/dist/lib/components/admin/fields/richtext/svelte-node-view.js +11 -0
  41. package/dist/lib/components/admin/fields/richtext/svelte-node-view.js.map +1 -1
  42. package/dist/lib/preview/assets.d.ts +19 -0
  43. package/dist/lib/preview/assets.d.ts.map +1 -0
  44. package/dist/lib/preview/assets.js +60 -0
  45. package/dist/lib/preview/assets.js.map +1 -0
  46. package/dist/lib/preview/perspective.d.ts +20 -0
  47. package/dist/lib/preview/perspective.d.ts.map +1 -0
  48. package/dist/lib/preview/perspective.js +21 -0
  49. package/dist/lib/preview/perspective.js.map +1 -0
  50. package/dist/lib/preview/stega.d.ts +25 -0
  51. package/dist/lib/preview/stega.d.ts.map +1 -0
  52. package/dist/lib/preview/stega.js +168 -0
  53. package/dist/lib/preview/stega.js.map +1 -0
  54. package/dist/lib/richtext-context.svelte.d.ts +18 -0
  55. package/dist/lib/richtext-context.svelte.d.ts.map +1 -0
  56. package/dist/lib/richtext-context.svelte.js +13 -0
  57. package/dist/lib/richtext-context.svelte.js.map +1 -0
  58. package/dist/lib/server/index.d.ts +1 -0
  59. package/dist/lib/server/index.d.ts.map +1 -1
  60. package/dist/lib/server/index.js +2 -0
  61. package/dist/lib/server/index.js.map +1 -1
  62. package/dist/lib/services/asset-service.d.ts +12 -0
  63. package/dist/lib/services/asset-service.d.ts.map +1 -1
  64. package/dist/lib/services/asset-service.js +32 -0
  65. package/dist/lib/services/asset-service.js.map +1 -1
  66. package/dist/lib/types/asset.d.ts +15 -0
  67. package/dist/lib/types/asset.d.ts.map +1 -1
  68. package/dist/lib/types/config.d.ts +7 -0
  69. package/dist/lib/types/config.d.ts.map +1 -1
  70. package/dist/lib/types/schemas.d.ts +30 -1
  71. package/dist/lib/types/schemas.d.ts.map +1 -1
  72. package/dist/lib/types/schemas.js.map +1 -1
  73. package/dist/lib/utils/preview.d.ts +4 -0
  74. package/dist/lib/utils/preview.d.ts.map +1 -1
  75. package/dist/lib/utils/preview.js +7 -0
  76. package/dist/lib/utils/preview.js.map +1 -1
  77. package/dist/lib/vite/index.d.ts +12 -1
  78. package/dist/lib/vite/index.d.ts.map +1 -1
  79. package/dist/lib/vite/index.js +89 -1
  80. package/dist/lib/vite/index.js.map +1 -1
  81. package/dist/preview/assets.d.ts +19 -0
  82. package/dist/preview/assets.d.ts.map +1 -0
  83. package/dist/preview/assets.js +59 -0
  84. package/dist/preview/perspective.d.ts +20 -0
  85. package/dist/preview/perspective.d.ts.map +1 -0
  86. package/dist/preview/perspective.js +20 -0
  87. package/dist/preview/stega.d.ts +25 -0
  88. package/dist/preview/stega.d.ts.map +1 -0
  89. package/dist/preview/stega.js +167 -0
  90. package/dist/richtext-context.svelte.d.ts +18 -0
  91. package/dist/richtext-context.svelte.d.ts.map +1 -0
  92. package/dist/richtext-context.svelte.js +12 -0
  93. package/dist/server/index.d.ts +1 -0
  94. package/dist/server/index.d.ts.map +1 -1
  95. package/dist/server/index.js +2 -0
  96. package/dist/services/asset-service.d.ts +12 -0
  97. package/dist/services/asset-service.d.ts.map +1 -1
  98. package/dist/services/asset-service.js +32 -0
  99. package/dist/types/asset.d.ts +15 -0
  100. package/dist/types/asset.d.ts.map +1 -1
  101. package/dist/types/config.d.ts +7 -0
  102. package/dist/types/config.d.ts.map +1 -1
  103. package/dist/types/schemas.d.ts +30 -1
  104. package/dist/types/schemas.d.ts.map +1 -1
  105. package/dist/utils/preview.d.ts +4 -0
  106. package/dist/utils/preview.d.ts.map +1 -1
  107. package/dist/utils/preview.js +7 -0
  108. package/dist/vite/index.d.ts +12 -1
  109. package/dist/vite/index.d.ts.map +1 -1
  110. package/dist/vite/index.js +89 -1
  111. package/package.json +3 -2
@@ -3,6 +3,7 @@
3
3
  import { Button } from '@aphexcms/ui/shadcn/button';
4
4
  import { Badge } from '@aphexcms/ui/shadcn/badge';
5
5
  import { documents } from '../../api/documents';
6
+ import { assets } from '../../api/assets';
6
7
  import { ApiError } from '../../api/client';
7
8
  import SchemaField from './SchemaField.svelte';
8
9
  import { findOrphanedFields, type OrphanedField } from '../../schema-utils/cleanup';
@@ -18,7 +19,20 @@
18
19
  import { cmsLogger } from '../../utils/logger';
19
20
  import { toast } from 'svelte-sonner';
20
21
  import { confirmDialog } from './confirm-dialog/confirm-dialog.svelte';
21
- import { History, Trash2, Ellipsis, Code, Maximize2, Minimize2 } from '@lucide/svelte';
22
+ import {
23
+ History,
24
+ Trash2,
25
+ Ellipsis,
26
+ Code,
27
+ Maximize2,
28
+ Minimize2,
29
+ Monitor,
30
+ RefreshCw,
31
+ ExternalLink
32
+ } from '@lucide/svelte';
33
+ import { stegaEncodeDocument } from '../../preview/stega.js';
34
+ import { collectAssetRefs, injectAssetData, type ResolvedAsset } from '../../preview/assets.js';
35
+ import { setRichtextEditorRegistry } from '../../richtext-context.svelte.js';
22
36
 
23
37
  interface Props {
24
38
  schemas: SchemaType[];
@@ -45,6 +59,12 @@
45
59
  focusMode?: boolean;
46
60
  /** Toggle host-driven focus mode. Omit to hide the focus button entirely. */
47
61
  onToggleFocus?: () => void;
62
+ /** When true, split the editor with a live preview iframe on the right. */
63
+ presentationMode?: boolean;
64
+ /** Toggle host-driven presentation mode. Omit to hide the button entirely. */
65
+ onTogglePresentation?: () => void;
66
+ /** Organization ID from the host context — used as fallback for new docs that haven't been saved yet. */
67
+ organizationId?: string | null;
48
68
  }
49
69
 
50
70
  let {
@@ -64,13 +84,19 @@
64
84
  externalVersionPreview = null,
65
85
  isReadOnly = false,
66
86
  focusMode = false,
67
- onToggleFocus
87
+ onToggleFocus,
88
+ presentationMode = false,
89
+ onTogglePresentation,
90
+ organizationId = null
68
91
  }: Props = $props();
69
92
 
70
93
  // Set schema context for child components (ArrayField, etc.)
71
94
  // svelte-ignore state_referenced_locally
72
95
  setSchemaContext(schemas);
73
96
 
97
+ // Registry for RichtextField editors — allows focusFieldByName to position the cursor
98
+ const richtextEditors = setRichtextEditorRegistry();
99
+
74
100
  // Read permissions from the AdminApp context for per-action gating.
75
101
  // `isReadOnly` prop, when explicitly true, forces everything off — this is
76
102
  // the escape hatch for hosts that embed the editor outside the RBAC shell
@@ -135,6 +161,379 @@
135
161
  // Inspect modal
136
162
  let showInspect = $state(false);
137
163
 
164
+ // Presentation mode — live preview iframe alongside the editor fields
165
+ let iframeRef = $state<HTMLIFrameElement | null>(null);
166
+ let iframeStega = $state(true);
167
+ let previewEditMode = $state(true);
168
+ let focusedFieldName = $state<string | null>(null);
169
+ let fieldsWidth = $state(500);
170
+ let isResizing = $state(false);
171
+
172
+ function refreshPreview() {
173
+ if (!iframeRef || !iframeUrl) return;
174
+ iframeRef.src = iframeUrl;
175
+ }
176
+
177
+ // --- Live-preview asset resolution -------------------------------------------------
178
+ // Asset URLs are normally resolved server-side at page load, so a newly-added/changed
179
+ // image ref has no URL on the frontend until the change is saved + the page reloads
180
+ // (auto-save is debounced, so a reload would race the save and lose). Instead we resolve
181
+ // refs → { url, alt } here and inject them into the pushed document, so the URL travels
182
+ // with the live snapshot — saved or not. (This mirrors how Payload carries upload URLs in
183
+ // its live-preview form state.) The collect/inject walk is shared with the server's
184
+ // `assetService.injectAssetUrls`, so preview and SSR documents come out the same shape.
185
+ // Cached so typing (no new refs) never refetches.
186
+ const previewAssetCache = new Map<string, ResolvedAsset>();
187
+
188
+ async function resolvePreviewAssets(refs: Set<string>): Promise<void> {
189
+ const missing = [...refs].filter((ref) => !previewAssetCache.has(ref));
190
+ await Promise.all(
191
+ missing.map(async (ref) => {
192
+ try {
193
+ const res = await assets.getById(ref);
194
+ if (res.success && res.data) {
195
+ previewAssetCache.set(ref, { url: res.data.url, alt: res.data.alt ?? undefined });
196
+ }
197
+ } catch {
198
+ // Leave unresolved — the frontend falls back to its server-loaded map.
199
+ }
200
+ })
201
+ );
202
+ }
203
+
204
+ // Snapshot the document, inject resolved asset URLs, and push it to the preview iframe.
205
+ async function postPreviewData(win: Window): Promise<void> {
206
+ const snapshot = $state.snapshot(documentData);
207
+ const refs = collectAssetRefs(snapshot);
208
+ await resolvePreviewAssets(refs);
209
+ injectAssetData(snapshot, previewAssetCache);
210
+ win.postMessage(
211
+ {
212
+ type: 'aphex:data',
213
+ document: iframeStega ? stegaEncodeDocument(snapshot, schema?.fields ?? []) : snapshot
214
+ },
215
+ '*'
216
+ );
217
+ }
218
+
219
+ function openPreviewInNewTab() {
220
+ if (iframeUrl) window.open(iframeUrl, '_blank');
221
+ }
222
+
223
+ function startResize(e: MouseEvent) {
224
+ e.preventDefault();
225
+ isResizing = true;
226
+ const startX = e.clientX;
227
+ const startWidth = fieldsWidth;
228
+ function onMove(ev: MouseEvent) {
229
+ fieldsWidth = Math.max(500, Math.min(700, startWidth + (ev.clientX - startX)));
230
+ }
231
+ function onUp() {
232
+ isResizing = false;
233
+ window.removeEventListener('mousemove', onMove);
234
+ window.removeEventListener('mouseup', onUp);
235
+ }
236
+ window.addEventListener('mousemove', onMove);
237
+ window.addEventListener('mouseup', onUp);
238
+ }
239
+
240
+ // Derived from the already-loaded schema + live documentData — no extra prop needed.
241
+ // orgId comes from _meta so multi-tenant schemas can build org-specific URLs.
242
+ const resolvedPreviewUrl = $derived.by(() => {
243
+ if (!schema?.previewUrl) return null;
244
+ if (typeof schema.previewUrl === 'string') return schema.previewUrl;
245
+ const orgId =
246
+ (fullDocument?._meta?.organizationId as string | null | undefined) ?? organizationId ?? null;
247
+ return schema.previewUrl(documentData as Record<string, unknown>, orgId) ?? null;
248
+ });
249
+
250
+ // Draft = preview URL as-is (with ?aphex-preview=1 + live data push).
251
+ // Published = strip aphex-preview param so the iframe shows the real published page.
252
+ // Relative paths (e.g. '/blog/x') resolve against the studio's own origin;
253
+ // absolute URLs target a separate public site unchanged.
254
+ const iframeUrl = $derived.by(() => {
255
+ if (!resolvedPreviewUrl) return null;
256
+ const base = typeof window !== 'undefined' ? window.location.origin : undefined;
257
+ let u: URL;
258
+ try {
259
+ u = new URL(resolvedPreviewUrl, base);
260
+ } catch {
261
+ return resolvedPreviewUrl;
262
+ }
263
+ if (perspective === 'published') {
264
+ u.searchParams.delete('aphex-preview');
265
+ }
266
+ return u.toString();
267
+ });
268
+
269
+ $effect(() => {
270
+ if (!presentationMode) return;
271
+ const handler = (e: MessageEvent) => {
272
+ if (!e.data || typeof e.data !== 'object') return;
273
+ const msg = e.data as {
274
+ type: string;
275
+ fieldPath?: string;
276
+ blockIndex?: number;
277
+ blockKey?: string;
278
+ arrayIndex?: number;
279
+ objectPath?: string;
280
+ linkHref?: string;
281
+ };
282
+ if (msg.type === 'aphex:ready') {
283
+ iframeStega = (msg as any).stega !== false;
284
+ if (iframeRef?.contentWindow) postPreviewData(iframeRef.contentWindow);
285
+ if (!previewEditMode) {
286
+ iframeRef?.contentWindow?.postMessage({ type: 'aphex:edit-mode', enabled: false }, '*');
287
+ }
288
+ } else if (msg.type === 'aphex:field-click' && msg.fieldPath) {
289
+ focusFieldByName(msg.fieldPath, {
290
+ blockIndex: msg.blockIndex,
291
+ blockKey: msg.blockKey,
292
+ arrayIndex: msg.arrayIndex,
293
+ objectPath: msg.objectPath,
294
+ linkHref: msg.linkHref
295
+ });
296
+ }
297
+ };
298
+ window.addEventListener('message', handler);
299
+ return () => window.removeEventListener('message', handler);
300
+ });
301
+
302
+ $effect(() => {
303
+ if (!presentationMode || perspective === 'published' || !iframeRef?.contentWindow) return;
304
+ // Track documentData so this effect re-runs on every edit; the debounce coalesces.
305
+ void documentData;
306
+ const timer = setTimeout(() => {
307
+ if (iframeRef?.contentWindow) postPreviewData(iframeRef.contentWindow);
308
+ }, 100);
309
+ return () => clearTimeout(timer);
310
+ });
311
+
312
+ /**
313
+ * Map a portable text array index to the absolute ProseMirror cursor position (end of content)
314
+ * for that block in the TipTap editor. Accounts for list grouping (multiple PT list-item blocks
315
+ * collapse into one bulletList/orderedList TipTap node) and blockquote nesting.
316
+ * Returns null when the block can't be navigated to (nested lists, unknown structure).
317
+ */
318
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
319
+ function findTiptapCursorForPTBlock(
320
+ ptBlocks: unknown[],
321
+ targetIdx: number,
322
+ doc: any
323
+ ): number | null {
324
+ // Absolute start positions of each TipTap top-level child
325
+ const nodePositions: number[] = [];
326
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
327
+ doc.forEach((_: any, offset: number) => nodePositions.push(offset));
328
+
329
+ let tiptapIdx = 0;
330
+ let ptIdx = 0;
331
+
332
+ while (ptIdx < ptBlocks.length && tiptapIdx < doc.childCount) {
333
+ const block = ptBlocks[ptIdx] as Record<string, unknown>;
334
+ const isListItem = block._type === 'block' && block.listItem != null;
335
+
336
+ if (isListItem) {
337
+ // Count ALL consecutive list items — buildListNode consumes them into one TipTap node
338
+ let groupCount = 0;
339
+ let targetOffsetInGroup = -1; // which level-1 listItem the target is
340
+ let level1Count = 0;
341
+ let targetIsNested = false;
342
+
343
+ while (ptIdx + groupCount < ptBlocks.length) {
344
+ const b = ptBlocks[ptIdx + groupCount] as Record<string, unknown>;
345
+ if (b._type !== 'block' || b.listItem == null) break;
346
+ const bLevel = (b.level as number | undefined) ?? 1;
347
+ if (ptIdx + groupCount === targetIdx) {
348
+ if (bLevel === 1) targetOffsetInGroup = level1Count;
349
+ else targetIsNested = true;
350
+ }
351
+ if (bLevel === 1) level1Count++;
352
+ groupCount++;
353
+ }
354
+
355
+ if (targetIsNested) return null; // nested list items are too complex to navigate
356
+
357
+ if (targetOffsetInGroup !== -1) {
358
+ const listNode = doc.child(tiptapIdx);
359
+ const listAbsPos = nodePositions[tiptapIdx] as number;
360
+ let result: number | null = null;
361
+ let itemIdx = 0;
362
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
363
+ listNode.forEach((listItem: any, liOffset: number) => {
364
+ if (itemIdx++ !== targetOffsetInGroup) return;
365
+ const para = listItem.firstChild;
366
+ if (para?.isTextblock) {
367
+ // listAbsPos + 1 (list open) + liOffset + 1 (listItem open) + 1 (para open) + content
368
+ result = listAbsPos + 1 + liOffset + 2 + para.content.size;
369
+ }
370
+ });
371
+ return result;
372
+ }
373
+
374
+ ptIdx += groupCount;
375
+ tiptapIdx++;
376
+ } else {
377
+ if (ptIdx === targetIdx) {
378
+ const node = doc.child(tiptapIdx);
379
+ const nodeAbsPos = nodePositions[tiptapIdx] as number;
380
+
381
+ if (node.type.name === 'blockquote') {
382
+ // blockquote > paragraph
383
+ const para = node.firstChild;
384
+ if (para?.isTextblock) {
385
+ return nodeAbsPos + 2 + para.content.size;
386
+ }
387
+ }
388
+
389
+ if (node.isTextblock) {
390
+ return nodeAbsPos + 1 + node.content.size;
391
+ }
392
+
393
+ return null;
394
+ }
395
+ ptIdx++;
396
+ tiptapIdx++;
397
+ }
398
+ }
399
+
400
+ return null;
401
+ }
402
+
403
+ async function focusFieldByName(
404
+ fieldName: string,
405
+ opts: {
406
+ blockIndex?: number;
407
+ blockKey?: string;
408
+ arrayIndex?: number;
409
+ objectPath?: string;
410
+ linkHref?: string;
411
+ } = {}
412
+ ) {
413
+ focusedFieldName = fieldName;
414
+ // Switch to the field's group so it's visible before scrolling
415
+ const field = schema?.fields.find((f) => f.name === fieldName);
416
+ if (field?.group) {
417
+ const group = Array.isArray(field.group) ? field.group[0] : field.group;
418
+ if (group) activeGroup = group;
419
+ }
420
+ await tick();
421
+ const container = document.querySelector<HTMLElement>(`[data-field-name="${fieldName}"]`);
422
+ if (!container) return;
423
+ container.scrollIntoView({ behavior: 'smooth', block: 'center' });
424
+ setTimeout(() => {
425
+ if (focusedFieldName === fieldName) focusedFieldName = null;
426
+ }, 2000);
427
+
428
+ const { blockIndex, blockKey, arrayIndex, objectPath, linkHref } = opts;
429
+
430
+ // 1. Richtext field — position cursor (text blocks) or open modal (custom blocks)
431
+ const richtextHandle = richtextEditors.get(fieldName);
432
+ const richtextEditor = richtextHandle?.editor;
433
+
434
+ // Link click — drop the cursor *inside* the matching link mark and open the link
435
+ // popover explicitly (the editor's transaction heuristic can miss a programmatic
436
+ // selection move, so we don't rely on it here).
437
+ if (richtextEditor && linkHref) {
438
+ const doc = richtextEditor.state.doc;
439
+ let linkPos: number | null = null;
440
+ doc.descendants((node, pos) => {
441
+ if (linkPos !== null) return false;
442
+ if (
443
+ node.isText &&
444
+ node.marks.some((m) => m.type.name === 'link' && m.attrs.href === linkHref)
445
+ ) {
446
+ linkPos = pos + 1; // a position inside the link's text
447
+ return false;
448
+ }
449
+ return true;
450
+ });
451
+ if (linkPos !== null) {
452
+ richtextEditor.commands.focus();
453
+ richtextEditor.commands.setTextSelection(linkPos);
454
+ richtextHandle?.openLinkPopover();
455
+ return;
456
+ }
457
+ }
458
+
459
+ if (richtextEditor && (blockIndex != null || blockKey)) {
460
+ const doc = richtextEditor.state.doc;
461
+
462
+ // Custom blocks carry a _key — find by key so index drift doesn't matter
463
+ if (blockKey) {
464
+ let nodePos: number | null = null;
465
+ let nodeType: string | null = null;
466
+ doc.forEach((node, offset) => {
467
+ if (node.attrs._key === blockKey) {
468
+ nodePos = offset;
469
+ nodeType = node.attrs._type;
470
+ }
471
+ });
472
+ if (nodePos !== null) {
473
+ const domNode = richtextEditor.view.nodeDOM(nodePos) as HTMLElement | null;
474
+ // Image blocks open their editor by clicking the block body — their only
475
+ // <button> is Remove, so clicking the first button would DELETE the image.
476
+ // Custom blocks (callout/code) open via their first button.
477
+ if (nodeType === 'image') {
478
+ domNode?.querySelector<HTMLElement>('[data-pt-image-edit]')?.click();
479
+ } else {
480
+ domNode?.querySelector<HTMLButtonElement>('button')?.click();
481
+ }
482
+ }
483
+ return;
484
+ }
485
+
486
+ // Standard text blocks — use PT array to correctly map blockIndex → TipTap position,
487
+ // accounting for list grouping and blockquote nesting.
488
+ const ptBlocks = documentData[fieldName];
489
+ if (Array.isArray(ptBlocks)) {
490
+ const cursorPos = findTiptapCursorForPTBlock(ptBlocks, blockIndex!, doc);
491
+ if (cursorPos !== null) {
492
+ richtextEditor.commands.focus();
493
+ richtextEditor.commands.setTextSelection(cursorPos);
494
+ return;
495
+ }
496
+ }
497
+ // Fallback: just focus the editor
498
+ richtextEditor.commands.focus();
499
+ return;
500
+ }
501
+
502
+ // 2. Nested object subfield — find the subfield container by its dotted path
503
+ if (objectPath) {
504
+ const subContainer = container.querySelector<HTMLElement>(
505
+ `[data-field-path="${objectPath}"]`
506
+ );
507
+ if (subContainer) {
508
+ subContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
509
+ const focusable = subContainer.querySelector<HTMLElement>(
510
+ 'input:not([disabled]):not([readonly]), textarea:not([disabled]):not([readonly])'
511
+ );
512
+ focusable?.focus();
513
+ return;
514
+ }
515
+ }
516
+
517
+ // 3. Primitive array — focus the Nth input inside the array field
518
+ if (arrayIndex != null) {
519
+ const inputs = container.querySelectorAll<HTMLElement>(
520
+ 'input:not([disabled]):not([readonly]), textarea:not([disabled]):not([readonly])'
521
+ );
522
+ const target = inputs[arrayIndex];
523
+ if (target) {
524
+ target.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
525
+ target.focus();
526
+ return;
527
+ }
528
+ }
529
+
530
+ // 4. Fallback — focus the first interactive element in the field
531
+ const focusable = container.querySelector<HTMLElement>(
532
+ 'input:not([disabled]):not([readonly]), textarea:not([disabled]):not([readonly]), [contenteditable="true"]'
533
+ );
534
+ focusable?.focus();
535
+ }
536
+
138
537
  // Field group tabs — only render when schema declares `groups`.
139
538
  // 'all' = show every field; otherwise filter to fields whose `group`
140
539
  // matches (supports string or string[]).
@@ -650,6 +1049,9 @@
650
1049
  lastSaved = new Date();
651
1050
  hasUnsavedChanges = false;
652
1051
  if (response.data?.id) notifyDocumentChanged(response.data.id);
1052
+ if (presentationMode && iframeRef?.contentWindow) {
1053
+ iframeRef.contentWindow.postMessage({ type: 'aphex:refresh' }, '*');
1054
+ }
653
1055
  if (isAutoSave) {
654
1056
  // Trigger validation on all fields after auto-save
655
1057
  validateAllFields(); // Update validation status
@@ -1155,6 +1557,20 @@
1155
1557
  </div>
1156
1558
  {/if}
1157
1559
 
1560
+ {#if onTogglePresentation && schema?.previewUrl}
1561
+ <Button
1562
+ variant="ghost"
1563
+ size="icon"
1564
+ onclick={onTogglePresentation}
1565
+ class="hidden h-8 w-8 hover:cursor-pointer lg:flex {presentationMode
1566
+ ? 'text-primary'
1567
+ : ''}"
1568
+ title={presentationMode ? 'Exit presentation mode' : 'Present'}
1569
+ >
1570
+ <Monitor class="h-4 w-4" />
1571
+ </Button>
1572
+ {/if}
1573
+
1158
1574
  {#if onToggleFocus}
1159
1575
  <Button
1160
1576
  variant="ghost"
@@ -1235,8 +1651,16 @@
1235
1651
  </div>
1236
1652
 
1237
1653
  <!-- Content Form -->
1238
- <div data-document-editor class="relative flex min-h-0 flex-1 flex-col">
1239
- <div class="flex flex-1 flex-col overflow-auto p-4 lg:p-6">
1654
+ <div
1655
+ data-document-editor
1656
+ class="relative flex min-h-0 flex-1 {presentationMode ? 'flex-row' : 'flex-col'}"
1657
+ >
1658
+ <div
1659
+ class="{presentationMode
1660
+ ? 'shrink-0 overflow-y-auto'
1661
+ : 'flex flex-1 flex-col overflow-auto'} p-4 lg:p-6"
1662
+ style={presentationMode ? `width: ${fieldsWidth}px; min-width: 500px` : undefined}
1663
+ >
1240
1664
  <div class="flex w-full flex-1 flex-col gap-4 lg:gap-6">
1241
1665
  {#if saveError}
1242
1666
  <div class="bg-destructive/10 border-destructive/20 rounded-md border p-3">
@@ -1371,23 +1795,30 @@
1371
1795
  : isViewingPublished && publishedData
1372
1796
  ? publishedData
1373
1797
  : documentData}
1374
- <SchemaField
1375
- {field}
1376
- value={viewData[field.name]}
1377
- documentData={viewData}
1378
- onUpdate={(newValue) => {
1379
- if (isViewingPublished) return;
1380
- documentData = { ...documentData, [field.name]: newValue };
1381
- hasUnsavedChanges = true;
1382
- }}
1383
- {onOpenReference}
1384
- schemaType={documentType}
1385
- readonly={isReadOnly ||
1386
- isViewingPublished ||
1387
- isPreviewingVersion ||
1388
- isFieldReadonly(field.name)}
1389
- organizationId={fullDocument?._meta?.organizationId}
1390
- />
1798
+ <div
1799
+ data-field-name={field.name}
1800
+ class="rounded-md transition-all duration-300 {focusedFieldName === field.name
1801
+ ? 'ring-primary/40 ring-2 ring-offset-2'
1802
+ : ''}"
1803
+ >
1804
+ <SchemaField
1805
+ {field}
1806
+ value={viewData[field.name]}
1807
+ documentData={viewData}
1808
+ onUpdate={(newValue) => {
1809
+ if (isViewingPublished) return;
1810
+ documentData = { ...documentData, [field.name]: newValue };
1811
+ hasUnsavedChanges = true;
1812
+ }}
1813
+ {onOpenReference}
1814
+ schemaType={documentType}
1815
+ readonly={isReadOnly ||
1816
+ isViewingPublished ||
1817
+ isPreviewingVersion ||
1818
+ isFieldReadonly(field.name)}
1819
+ organizationId={fullDocument?._meta?.organizationId}
1820
+ />
1821
+ </div>
1391
1822
  {/each}
1392
1823
 
1393
1824
  {#snippet failed(error, reset)}
@@ -1414,6 +1845,102 @@
1414
1845
  {/if}
1415
1846
  </div>
1416
1847
  </div>
1848
+
1849
+ <!-- Preview iframe (presentation mode only) -->
1850
+ {#if presentationMode}
1851
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
1852
+ <!-- Drag handle -->
1853
+ <div
1854
+ role="separator"
1855
+ aria-orientation="vertical"
1856
+ class="hover:bg-primary/20 active:bg-primary/30 w-1 shrink-0 cursor-ew-resize transition-colors"
1857
+ onmousedown={startResize}
1858
+ ></div>
1859
+ <div class="flex min-h-0 flex-1 flex-col">
1860
+ {#if iframeUrl}
1861
+ <!-- Preview toolbar -->
1862
+ <div
1863
+ class="border-rule bg-background flex h-10 shrink-0 items-center gap-1 border-b px-2"
1864
+ >
1865
+ <!-- Edit toggle -->
1866
+ <button
1867
+ onclick={() => {
1868
+ previewEditMode = !previewEditMode;
1869
+ iframeRef?.contentWindow?.postMessage(
1870
+ { type: 'aphex:edit-mode', enabled: previewEditMode },
1871
+ '*'
1872
+ );
1873
+ }}
1874
+ class="hover:bg-muted flex cursor-pointer items-center gap-1.5 rounded px-2 py-1 transition-colors"
1875
+ title={previewEditMode ? 'Disable edit mode' : 'Enable edit mode'}
1876
+ >
1877
+ <!-- Toggle pill -->
1878
+ <div
1879
+ class="relative h-[14px] w-6 rounded-full transition-colors {previewEditMode
1880
+ ? 'bg-primary'
1881
+ : 'bg-muted-foreground/30'}"
1882
+ >
1883
+ <div
1884
+ class="absolute top-[2px] h-[10px] w-[10px] rounded-full bg-white shadow transition-all {previewEditMode
1885
+ ? 'left-[12px]'
1886
+ : 'left-[2px]'}"
1887
+ ></div>
1888
+ </div>
1889
+ <span
1890
+ class="text-[11px] font-medium tracking-wide {previewEditMode
1891
+ ? 'text-foreground'
1892
+ : 'text-muted-foreground'}">Edit</span
1893
+ >
1894
+ </button>
1895
+
1896
+ <!-- Refresh -->
1897
+ <button
1898
+ onclick={refreshPreview}
1899
+ class="hover:bg-muted text-muted-foreground hover:text-foreground cursor-pointer rounded p-1.5 transition-colors"
1900
+ title="Refresh preview"
1901
+ >
1902
+ <RefreshCw class="h-3.5 w-3.5" />
1903
+ </button>
1904
+
1905
+ <!-- URL bar -->
1906
+ <div class="bg-muted mx-1 min-w-0 flex-1 rounded px-2.5 py-1">
1907
+ <span class="text-muted-foreground block truncate text-center font-mono text-[11px]"
1908
+ >{iframeUrl}</span
1909
+ >
1910
+ </div>
1911
+
1912
+ <!-- Open in new tab -->
1913
+ <button
1914
+ onclick={openPreviewInNewTab}
1915
+ class="hover:bg-muted text-muted-foreground hover:text-foreground cursor-pointer rounded p-1.5 transition-colors"
1916
+ title="Open in new tab"
1917
+ >
1918
+ <ExternalLink class="h-3.5 w-3.5" />
1919
+ </button>
1920
+ </div>
1921
+
1922
+ <div class="relative min-h-0 flex-1">
1923
+ <iframe
1924
+ bind:this={iframeRef}
1925
+ src={iframeUrl}
1926
+ class="h-full w-full border-none"
1927
+ title="Page preview"
1928
+ ></iframe>
1929
+ {#if isResizing}
1930
+ <div class="absolute inset-0"></div>
1931
+ {/if}
1932
+ </div>
1933
+ {:else}
1934
+ <div class="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
1935
+ <Monitor class="text-muted-foreground/30 h-10 w-10" />
1936
+ <p class="text-muted-foreground text-sm">No preview URL yet.</p>
1937
+ <p class="text-muted-foreground/50 text-xs">
1938
+ Fill in the required fields to enable preview.
1939
+ </p>
1940
+ </div>
1941
+ {/if}
1942
+ </div>
1943
+ {/if}
1417
1944
  </div>
1418
1945
 
1419
1946
  <!-- Sanity-style bottom bar -->
@@ -24,6 +24,12 @@ interface Props {
24
24
  focusMode?: boolean;
25
25
  /** Toggle host-driven focus mode. Omit to hide the focus button entirely. */
26
26
  onToggleFocus?: () => void;
27
+ /** When true, split the editor with a live preview iframe on the right. */
28
+ presentationMode?: boolean;
29
+ /** Toggle host-driven presentation mode. Omit to hide the button entirely. */
30
+ onTogglePresentation?: () => void;
31
+ /** Organization ID from the host context — used as fallback for new docs that haven't been saved yet. */
32
+ organizationId?: string | null;
27
33
  }
28
34
  declare const DocumentEditor: import("svelte").Component<Props, {}, "">;
29
35
  type DocumentEditor = ReturnType<typeof DocumentEditor>;
@@ -1 +1 @@
1
- {"version":3,"file":"DocumentEditor.svelte.d.ts","sourceRoot":"","sources":["../../../src/lib/components/admin/DocumentEditor.svelte.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAexD,UAAU,KAAK;IACd,OAAO,EAAE,UAAU,EAAE,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,UAAU,EAAE,OAAO,CAAC;IACpB,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,OAAO,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,WAAW,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1D,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,WAAW,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3C,aAAa,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7C,UAAU,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1C,eAAe,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,IAAI,CAAC;IACrE,oBAAoB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IACpD,sBAAsB,CAAC,EAAE;QACxB,aAAa,EAAE,MAAM,CAAC;QACtB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QAC1B,SAAS,EAAE,MAAM,CAAC;QAClB,SAAS,CAAC,EAAE,MAAM,CAAC;KACnB,GAAG,IAAI,CAAC;IACT,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,2EAA2E;IAC3E,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,6EAA6E;IAC7E,aAAa,CAAC,EAAE,MAAM,IAAI,CAAC;CAC3B;AAsjDF,QAAA,MAAM,cAAc,2CAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
1
+ {"version":3,"file":"DocumentEditor.svelte.d.ts","sourceRoot":"","sources":["../../../src/lib/components/admin/DocumentEditor.svelte.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AA4BxD,UAAU,KAAK;IACd,OAAO,EAAE,UAAU,EAAE,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,UAAU,EAAE,OAAO,CAAC;IACpB,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,OAAO,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,WAAW,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1D,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,WAAW,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3C,aAAa,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7C,UAAU,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1C,eAAe,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,IAAI,CAAC;IACrE,oBAAoB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IACpD,sBAAsB,CAAC,EAAE;QACxB,aAAa,EAAE,MAAM,CAAC;QACtB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QAC1B,SAAS,EAAE,MAAM,CAAC;QAClB,SAAS,CAAC,EAAE,MAAM,CAAC;KACnB,GAAG,IAAI,CAAC;IACT,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,2EAA2E;IAC3E,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,6EAA6E;IAC7E,aAAa,CAAC,EAAE,MAAM,IAAI,CAAC;IAC3B,2EAA2E;IAC3E,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,8EAA8E;IAC9E,oBAAoB,CAAC,EAAE,MAAM,IAAI,CAAC;IAClC,yGAAyG;IACzG,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC/B;AAsgEF,QAAA,MAAM,cAAc,2CAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
@@ -123,7 +123,7 @@
123
123
  const validationClasses = $derived(getValidationClasses(hasErrors));
124
124
  </script>
125
125
 
126
- <div class="space-y-2">
126
+ <div class="space-y-2" data-field-path={fieldPath}>
127
127
  <div class="flex items-center justify-between">
128
128
  <Label for={field.name}>
129
129
  {field.title}
@@ -507,7 +507,7 @@
507
507
  {#key dndKey}
508
508
  <DragDropProvider onDragEnd={handleDragEnd}>
509
509
  <div class="space-y-1">
510
- {#each keyedItems as item, index (item._key)}
510
+ {#each keyedItems as item, index (item._key ?? `__row-${index}`)}
511
511
  {@const sortable = createSortable({ id: item._key, index, disabled: readonly })}
512
512
  <div
513
513
  {@attach sortable.attach}
@@ -908,7 +908,7 @@
908
908
  {#key dndKey}
909
909
  <DragDropProvider onDragEnd={handleDragEnd}>
910
910
  <div class="space-y-1">
911
- {#each keyedItems as item, index (item._key)}
911
+ {#each keyedItems as item, index (item._key ?? `__row-${index}`)}
912
912
  {@const sortable = createSortable({ id: item._key, index, disabled: readonly })}
913
913
  <div
914
914
  {@attach sortable.attach}