@aphexcms/cms-core 9.2.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.
- package/dist/cli/generate-types.d.ts.map +1 -1
- package/dist/cli/generate-types.js +8 -3
- package/dist/cli/generate-types.js.map +1 -1
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +4 -0
- package/dist/components/AdminApp.svelte +27 -11
- package/dist/components/AdminApp.svelte.d.ts.map +1 -1
- package/dist/components/admin/DocumentEditor.svelte +548 -21
- package/dist/components/admin/DocumentEditor.svelte.d.ts +6 -0
- package/dist/components/admin/DocumentEditor.svelte.d.ts.map +1 -1
- package/dist/components/admin/SchemaField.svelte +1 -1
- package/dist/components/admin/fields/ArrayField.svelte +2 -2
- package/dist/components/admin/fields/ImageField.svelte +85 -27
- package/dist/components/admin/fields/ImageField.svelte.d.ts.map +1 -1
- package/dist/components/admin/fields/richtext/ImageBlockModal.svelte +133 -0
- package/dist/components/admin/fields/richtext/ImageBlockModal.svelte.d.ts +14 -0
- package/dist/components/admin/fields/richtext/ImageBlockModal.svelte.d.ts.map +1 -0
- package/dist/components/admin/fields/richtext/PortableTextImageView.svelte +2 -2
- package/dist/components/admin/fields/richtext/RichtextField.svelte +156 -11
- package/dist/components/admin/fields/richtext/RichtextField.svelte.d.ts.map +1 -1
- package/dist/components/admin/fields/richtext/svelte-node-view.d.ts.map +1 -1
- package/dist/components/admin/fields/richtext/svelte-node-view.js +11 -0
- package/dist/lib/client/index.d.ts +1 -0
- package/dist/lib/client/index.d.ts.map +1 -1
- package/dist/lib/client/index.js +4 -0
- package/dist/lib/client/index.js.map +1 -1
- package/dist/lib/components/admin/fields/richtext/svelte-node-view.d.ts.map +1 -1
- package/dist/lib/components/admin/fields/richtext/svelte-node-view.js +11 -0
- package/dist/lib/components/admin/fields/richtext/svelte-node-view.js.map +1 -1
- package/dist/lib/preview/assets.d.ts +19 -0
- package/dist/lib/preview/assets.d.ts.map +1 -0
- package/dist/lib/preview/assets.js +60 -0
- package/dist/lib/preview/assets.js.map +1 -0
- package/dist/lib/preview/perspective.d.ts +20 -0
- package/dist/lib/preview/perspective.d.ts.map +1 -0
- package/dist/lib/preview/perspective.js +21 -0
- package/dist/lib/preview/perspective.js.map +1 -0
- package/dist/lib/preview/stega.d.ts +25 -0
- package/dist/lib/preview/stega.d.ts.map +1 -0
- package/dist/lib/preview/stega.js +168 -0
- package/dist/lib/preview/stega.js.map +1 -0
- package/dist/lib/richtext-context.svelte.d.ts +18 -0
- package/dist/lib/richtext-context.svelte.d.ts.map +1 -0
- package/dist/lib/richtext-context.svelte.js +13 -0
- package/dist/lib/richtext-context.svelte.js.map +1 -0
- package/dist/lib/server/index.d.ts +1 -0
- package/dist/lib/server/index.d.ts.map +1 -1
- package/dist/lib/server/index.js +2 -0
- package/dist/lib/server/index.js.map +1 -1
- package/dist/lib/services/asset-service.d.ts +12 -0
- package/dist/lib/services/asset-service.d.ts.map +1 -1
- package/dist/lib/services/asset-service.js +32 -0
- package/dist/lib/services/asset-service.js.map +1 -1
- package/dist/lib/types/asset.d.ts +15 -0
- package/dist/lib/types/asset.d.ts.map +1 -1
- package/dist/lib/types/config.d.ts +7 -0
- package/dist/lib/types/config.d.ts.map +1 -1
- package/dist/lib/types/schemas.d.ts +17 -0
- package/dist/lib/types/schemas.d.ts.map +1 -1
- package/dist/lib/utils/preview.d.ts +4 -0
- package/dist/lib/utils/preview.d.ts.map +1 -1
- package/dist/lib/utils/preview.js +7 -0
- package/dist/lib/utils/preview.js.map +1 -1
- package/dist/preview/assets.d.ts +19 -0
- package/dist/preview/assets.d.ts.map +1 -0
- package/dist/preview/assets.js +59 -0
- package/dist/preview/perspective.d.ts +20 -0
- package/dist/preview/perspective.d.ts.map +1 -0
- package/dist/preview/perspective.js +20 -0
- package/dist/preview/stega.d.ts +25 -0
- package/dist/preview/stega.d.ts.map +1 -0
- package/dist/preview/stega.js +167 -0
- package/dist/richtext-context.svelte.d.ts +18 -0
- package/dist/richtext-context.svelte.d.ts.map +1 -0
- package/dist/richtext-context.svelte.js +12 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +2 -0
- package/dist/services/asset-service.d.ts +12 -0
- package/dist/services/asset-service.d.ts.map +1 -1
- package/dist/services/asset-service.js +32 -0
- package/dist/types/asset.d.ts +15 -0
- package/dist/types/asset.d.ts.map +1 -1
- package/dist/types/config.d.ts +7 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/schemas.d.ts +17 -0
- package/dist/types/schemas.d.ts.map +1 -1
- package/dist/utils/preview.d.ts +4 -0
- package/dist/utils/preview.d.ts.map +1 -1
- package/dist/utils/preview.js +7 -0
- 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 {
|
|
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
|
|
1239
|
-
|
|
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
|
-
<
|
|
1375
|
-
{field}
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
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":"
|
|
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}
|