@cyber-dash-tech/revela 0.16.2 → 0.16.4

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.
@@ -11,6 +11,7 @@ import { createInspectRequest, failInspectRequest, getInspectRequest } from "../
11
11
  import { saveMediaAsset } from "../media/save"
12
12
  import { searchRemoteImages, type ImageCandidate } from "../media/search"
13
13
  import type { MediaAssetRecord, MediaPurpose } from "../media/types"
14
+ import { annotateVisualEditTargets, applyVisualTargetChanges, type VisualEditTarget } from "./visual-targets"
14
15
 
15
16
  const TOKEN_BYTES = 24
16
17
  const SESSION_TTL_MS = 2 * 60 * 60 * 1000
@@ -36,6 +37,8 @@ interface EditSession {
36
37
  createdAt: number
37
38
  lastActiveAt: number
38
39
  defaultMode: RefineMode
40
+ visualTargets: Map<string, VisualEditTarget>
41
+ visualTargetDeckVersion?: string
39
42
  }
40
43
 
41
44
  export type RefineMode = "edit" | "inspect"
@@ -79,6 +82,7 @@ export function startRefineServer(): RefineServerHandle {
79
82
  existing.session.file = input.deck.file
80
83
  existing.session.workspaceRoot = resolve(input.workspaceRoot)
81
84
  existing.session.defaultMode = input.mode ?? "edit"
85
+ existing.session.visualTargets = existing.session.visualTargets ?? new Map()
82
86
  return {
83
87
  token: existing.token,
84
88
  reused: true,
@@ -101,6 +105,7 @@ export function startRefineServer(): RefineServerHandle {
101
105
  createdAt: Date.now(),
102
106
  lastActiveAt: Date.now(),
103
107
  defaultMode: input.mode ?? "edit",
108
+ visualTargets: new Map(),
104
109
  })
105
110
  return { token, reused: false, live: false }
106
111
  },
@@ -193,6 +198,12 @@ async function handleRequest(req: Request): Promise<Response> {
193
198
  return handleDeckVersion(session.value)
194
199
  }
195
200
 
201
+ if (url.pathname === "/api/visual-changes" && req.method === "POST") {
202
+ const session = validateSession(url.searchParams.get("token"))
203
+ if (!session.ok) return session.response
204
+ return handleVisualChanges(req, session.value)
205
+ }
206
+
196
207
  if (url.pathname === "/api/assets/search" && req.method === "GET") {
197
208
  const session = validateSession(url.searchParams.get("token"))
198
209
  if (!session.ok) return session.response
@@ -339,8 +350,12 @@ function handleDeck(session: EditSession): Response {
339
350
  session.assets.clear()
340
351
  session.assetKeys.clear()
341
352
  session.nextAssetId = 1
342
- const html = readFileSync(session.absoluteFile, "utf-8")
343
- return htmlResponse(rewriteLocalAssetRefs(html, {
353
+ const sourceHtml = readFileSync(session.absoluteFile, "utf-8")
354
+ const version = readDeckVersion(session).version
355
+ const annotated = annotateVisualEditTargets(sourceHtml)
356
+ session.visualTargets = annotated.targets
357
+ session.visualTargetDeckVersion = version
358
+ return htmlResponse(rewriteLocalAssetRefs(annotated.html, {
344
359
  session,
345
360
  sourceFile: session.absoluteFile,
346
361
  contentType: "html",
@@ -550,6 +565,33 @@ function readDeckVersion(session: EditSession): { mtimeMs: number; size: number;
550
565
  return { mtimeMs: stat.mtimeMs, size: stat.size, version }
551
566
  }
552
567
 
568
+ async function handleVisualChanges(req: Request, session: EditSession): Promise<Response> {
569
+ let body: any
570
+ try {
571
+ body = await req.json()
572
+ } catch {
573
+ return jsonResponse({ ok: false, error: "Invalid JSON body" }, 400)
574
+ }
575
+
576
+ const changes = Array.isArray(body?.changes) ? body.changes : []
577
+ if (!changes.length) return jsonResponse({ ok: false, error: "No visual changes to save." }, 400)
578
+ try {
579
+ const result = applyVisualTargetChanges({
580
+ file: session.absoluteFile,
581
+ deckVersion: typeof body?.deckVersion === "string" ? body.deckVersion : undefined,
582
+ targetDeckVersion: session.visualTargetDeckVersion,
583
+ targets: session.visualTargets,
584
+ changes,
585
+ })
586
+ session.lastActiveAt = Date.now()
587
+ scheduleIdleStop()
588
+ return jsonResponse({ ok: true, deckVersion: result.deckVersion, changeCount: result.changeCount })
589
+ } catch (error) {
590
+ const message = error instanceof Error ? error.message : String(error)
591
+ return jsonResponse({ ok: false, error: message }, 400)
592
+ }
593
+ }
594
+
553
595
  async function handleComment(req: Request, session: EditSession): Promise<Response> {
554
596
  let body: Partial<EditCommentPayload>
555
597
  try {
@@ -778,6 +820,14 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
778
820
  .resize-handle:hover::before, body.resizing .resize-handle::before { height: 52px; background: #94a3b8; box-shadow: 0 0 0 4px rgba(148,163,184,.16); }
779
821
  iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
780
822
  .hitbox { position: absolute; inset: 0; z-index: 2; cursor: crosshair; background: transparent; }
823
+ .visual-move-handle { position: absolute; z-index: 4; width: 16px; height: 16px; border: 2px solid #111827; border-radius: 999px; background: #fbfaf7; box-shadow: 0 6px 16px rgba(31,41,51,.22); transform: translate(-50%, -50%); pointer-events: none; display: none; }
824
+ .visual-move-handle::before { content: ""; position: absolute; inset: 4px; border-top: 2px solid #111827; border-left: 2px solid #111827; transform: rotate(45deg); }
825
+ .visual-resize-handle { position: absolute; z-index: 3; width: 14px; height: 14px; border: 2px solid #111827; border-radius: 4px; background: #fbfaf7; box-shadow: 0 6px 16px rgba(31,41,51,.22); transform: translate(-50%, -50%); pointer-events: none; display: none; }
826
+ .visual-resize-handle[data-mode="text-width"] { width: 10px; height: 28px; border-radius: 999px; cursor: ew-resize; }
827
+ .visual-edit-toolbar { position: absolute; top: 14px; left: 50%; z-index: 6; display: none; align-items: center; gap: 8px; transform: translateX(-50%); padding: 8px 10px; border: 1px solid rgba(148,163,184,.42); border-radius: 999px; background: rgba(17,24,39,.88); color: #fbfaf7; box-shadow: 0 16px 40px rgba(31,41,51,.26); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); font-size: 12px; font-weight: 800; }
828
+ .visual-edit-toolbar.active { display: inline-flex; }
829
+ .visual-edit-toolbar button { width: auto; min-width: 0; padding: 7px 10px; border-radius: 999px; border-color: rgba(255,255,255,.2); background: rgba(255,255,255,.12); color: #fff; box-shadow: none; font-size: 12px; }
830
+ .visual-edit-toolbar .save-visual { background: #fbfaf7; color: #111827; }
781
831
  .deck-nav { position: absolute; left: 50%; bottom: 18px; z-index: 4; display: inline-flex; align-items: center; gap: 8px; transform: translateX(-50%); padding: 7px; border: 1px solid rgba(148,163,184,.42); border-radius: 999px; background: rgba(15,23,42,.76); box-shadow: 0 16px 44px rgba(15,23,42,.24); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); pointer-events: auto; }
782
832
  .deck-nav button { width: auto; min-width: 84px; padding: 8px 12px; border-radius: 999px; background: rgba(255,255,255,.12); color: #fff; box-shadow: none; font-size: 12px; font-weight: 900; }
783
833
  .deck-nav button:hover:not(:disabled) { background: rgba(255,255,255,.22); }
@@ -882,7 +932,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
882
932
  </head>
883
933
  <body>
884
934
  <main class="app">
885
- <section class="preview"><iframe id="deck" src="/deck?token=${encodeURIComponent(token)}"></iframe><div id="hitbox" class="hitbox" aria-label="Deck element selection layer"></div><nav class="deck-nav" aria-label="Deck navigation"><button id="deckPrev" type="button" title="Previous slide (ArrowLeft / ArrowUp / PageUp)">Previous</button><div id="deckCounter" class="deck-nav-status" aria-live="polite">-- / --</div><button id="deckNext" type="button" title="Next slide (ArrowRight / ArrowDown / Space / PageDown)">Next</button></nav></section>
935
+ <section class="preview"><iframe id="deck" src="/deck?token=${encodeURIComponent(token)}"></iframe><div id="hitbox" class="hitbox" aria-label="Deck element selection layer"></div><div id="visualMoveHandle" class="visual-move-handle" aria-hidden="true"></div><div id="visualResizeHandle" class="visual-resize-handle" aria-hidden="true"></div><div id="visualEditToolbar" class="visual-edit-toolbar" aria-live="polite"><span id="visualEditCount">No unsaved visual changes</span><button id="visualUndo" type="button">Undo</button><button id="visualReset" type="button">Reset</button><button id="visualSave" class="save-visual" type="button">Save Changes</button></div><nav class="deck-nav" aria-label="Deck navigation"><button id="deckPrev" type="button" title="Previous slide (ArrowLeft / ArrowUp / PageUp)">Previous</button><div id="deckCounter" class="deck-nav-status" aria-live="polite">-- / --</div><button id="deckNext" type="button" title="Next slide (ArrowRight / ArrowDown / Space / PageDown)">Next</button></nav></section>
886
936
  <div id="resizeHandle" class="resize-handle" role="separator" aria-label="Resize editor panel" aria-orientation="vertical" title="Drag to resize editor. Double-click to reset."></div>
887
937
  <aside>
888
938
  <div>
@@ -970,6 +1020,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
970
1020
  resizeDrag: null,
971
1021
  deckSlideIndex: 0,
972
1022
  deckSlideCount: 0,
1023
+ pendingDeckSlideRestore: null,
973
1024
  mode: defaultMode === 'inspect' ? 'inspect' : 'edit',
974
1025
  inspecting: false,
975
1026
  activeInspectRequestId: '',
@@ -988,11 +1039,23 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
988
1039
  assetSearchKey: '',
989
1040
  assetVisibleCount: 0,
990
1041
  assetPendingCount: 0,
1042
+ visualChanges: [],
1043
+ activeVisualMove: null,
1044
+ activeVisualResize: null,
1045
+ hoverVisualTarget: null,
1046
+ savingVisualChanges: false,
991
1047
  };
992
1048
  const els = {
993
1049
  frame: null,
994
1050
  hitbox: null,
995
1051
  resizeHandle: null,
1052
+ visualMoveHandle: null,
1053
+ visualResizeHandle: null,
1054
+ visualEditToolbar: null,
1055
+ visualEditCount: null,
1056
+ visualUndo: null,
1057
+ visualReset: null,
1058
+ visualSave: null,
996
1059
  deckPrev: null,
997
1060
  deckNext: null,
998
1061
  deckCounter: null,
@@ -1036,6 +1099,13 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1036
1099
  els.frame = document.getElementById('deck');
1037
1100
  els.hitbox = document.getElementById('hitbox');
1038
1101
  els.resizeHandle = document.getElementById('resizeHandle');
1102
+ els.visualMoveHandle = document.getElementById('visualMoveHandle');
1103
+ els.visualResizeHandle = document.getElementById('visualResizeHandle');
1104
+ els.visualEditToolbar = document.getElementById('visualEditToolbar');
1105
+ els.visualEditCount = document.getElementById('visualEditCount');
1106
+ els.visualUndo = document.getElementById('visualUndo');
1107
+ els.visualReset = document.getElementById('visualReset');
1108
+ els.visualSave = document.getElementById('visualSave');
1039
1109
  els.deckPrev = document.getElementById('deckPrev');
1040
1110
  els.deckNext = document.getElementById('deckNext');
1041
1111
  els.deckCounter = document.getElementById('deckCounter');
@@ -1065,7 +1135,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1065
1135
 
1066
1136
  els.inspectLanguage = document.getElementById('inspectLanguage');
1067
1137
 
1068
- if (!els.frame || !els.hitbox || !els.resizeHandle || !els.deckPrev || !els.deckNext || !els.deckCounter || !els.selectionSummary || !els.selectionChips || !els.editTab || !els.inspectTab || !els.editPanel || !els.inspectPanel || !els.comment || !els.commentThread || !els.send || !els.inspectComment || !els.inspectButton || !els.inspectLanguage || !els.inspectCards || !els.inspectStale || !els.assetSearchToggle || !els.assetSearchBack || !els.assetSearchView || !els.assetQuery || !els.assetPurpose || !els.assetSearchButton || !els.assetShuffleButton || !els.assetResults || !els.editSavedAssets || !els.status) {
1138
+ if (!els.frame || !els.hitbox || !els.resizeHandle || !els.visualMoveHandle || !els.visualResizeHandle || !els.visualEditToolbar || !els.visualEditCount || !els.visualUndo || !els.visualReset || !els.visualSave || !els.deckPrev || !els.deckNext || !els.deckCounter || !els.selectionSummary || !els.selectionChips || !els.editTab || !els.inspectTab || !els.editPanel || !els.inspectPanel || !els.comment || !els.commentThread || !els.send || !els.inspectComment || !els.inspectButton || !els.inspectLanguage || !els.inspectCards || !els.inspectStale || !els.assetSearchToggle || !els.assetSearchBack || !els.assetSearchView || !els.assetQuery || !els.assetPurpose || !els.assetSearchButton || !els.assetShuffleButton || !els.assetResults || !els.editSavedAssets || !els.status) {
1069
1139
  throw new Error('Editor boot failed: required DOM nodes are missing.');
1070
1140
  }
1071
1141
 
@@ -1132,6 +1202,9 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1132
1202
  }, { passive: false });
1133
1203
  els.resizeHandle.addEventListener('pointerdown', startEditorResize);
1134
1204
  els.resizeHandle.addEventListener('dblclick', resetEditorWidth);
1205
+ els.visualUndo.addEventListener('click', undoVisualChange);
1206
+ els.visualReset.addEventListener('click', resetVisualChanges);
1207
+ els.visualSave.addEventListener('click', saveVisualChanges);
1135
1208
  els.deckPrev.addEventListener('click', prevDeckSlide);
1136
1209
  els.deckNext.addEventListener('click', nextDeckSlide);
1137
1210
  els.send.addEventListener('click', sendComment);
@@ -1223,6 +1296,329 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1223
1296
  setEditorWidth(DEFAULT_EDITOR_WIDTH, true);
1224
1297
  }
1225
1298
 
1299
+ function isDirectResizable(target) {
1300
+ if (!target || !target.dataset || !target.dataset.revelaEditId || !target.dataset.revelaEditKind) return false;
1301
+ if (target.dataset.revelaEditKind === 'image') return target.tagName?.toLowerCase() === 'img';
1302
+ if (target.dataset.revelaEditKind === 'box') {
1303
+ const computed = els.frame.contentWindow?.getComputedStyle(target);
1304
+ return computed ? computed.display !== 'inline' : true;
1305
+ }
1306
+ if (target.dataset.revelaEditKind === 'text-width') {
1307
+ const computed = els.frame.contentWindow?.getComputedStyle(target);
1308
+ return computed ? computed.display !== 'inline' : true;
1309
+ }
1310
+ return false;
1311
+ }
1312
+
1313
+ function isDirectMovable(target) {
1314
+ return isDirectResizable(target);
1315
+ }
1316
+
1317
+ function visualResizeMode(target) {
1318
+ if (target?.dataset?.revelaEditKind === 'text-width') return 'text-width';
1319
+ if (target?.dataset?.revelaEditKind === 'image') return 'image';
1320
+ return 'box';
1321
+ }
1322
+
1323
+ function visualChangeKey(payload) {
1324
+ return (payload?.type || '') + ':' + (payload?.editId || '');
1325
+ }
1326
+
1327
+ function upsertVisualChange(change) {
1328
+ const key = visualChangeKey(change);
1329
+ const existing = state.visualChanges.find((item) => visualChangeKey(item) === key);
1330
+ if (existing) {
1331
+ existing.after = change.after;
1332
+ return;
1333
+ }
1334
+ state.visualChanges.push(change);
1335
+ }
1336
+
1337
+ function currentTranslate(target) {
1338
+ const computed = els.frame.contentWindow?.getComputedStyle(target);
1339
+ return simpleTranslateFromTranslate(target.style.translate)
1340
+ || simpleTranslateFromTranslate(computed?.translate)
1341
+ || simpleTranslateFromTransform(target.style.transform)
1342
+ || simpleTranslateFromTransform(computed?.transform)
1343
+ || { x: 0, y: 0 };
1344
+ }
1345
+
1346
+ function simpleTranslateFromTranslate(translate) {
1347
+ const normalized = String(translate || '').trim();
1348
+ if (!normalized || normalized === 'none') return null;
1349
+ const match = /^(-?\d+(?:\.\d+)?)px(?:\s+|\s*,\s*)(-?\d+(?:\.\d+)?)px$/.exec(normalized);
1350
+ return match ? finitePoint(Number(match[1]), Number(match[2])) : null;
1351
+ }
1352
+
1353
+ function simpleTranslateFromTransform(transform) {
1354
+ const normalized = String(transform || '').trim();
1355
+ if (!normalized || normalized === 'none') return { x: 0, y: 0 };
1356
+ const translate = /^translate\(\s*(-?\d+(?:\.\d+)?)px(?:\s*,\s*|\s+)(-?\d+(?:\.\d+)?)px\s*\)$/.exec(normalized);
1357
+ if (translate) return finitePoint(Number(translate[1]), Number(translate[2]));
1358
+ const matrix = /^matrix\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)$/.exec(normalized);
1359
+ if (!matrix) return null;
1360
+ const a = Number(matrix[1]);
1361
+ const b = Number(matrix[2]);
1362
+ const c = Number(matrix[3]);
1363
+ const d = Number(matrix[4]);
1364
+ if (a !== 1 || b !== 0 || c !== 0 || d !== 1) return null;
1365
+ return finitePoint(Number(matrix[5]), Number(matrix[6]));
1366
+ }
1367
+
1368
+ function finitePoint(x, y) {
1369
+ return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null;
1370
+ }
1371
+
1372
+ function renderVisualMoveHandle(target) {
1373
+ if (!els.visualMoveHandle) return;
1374
+ if (!target || !isDirectMovable(target) || state.activeVisualMove) {
1375
+ els.visualMoveHandle.style.display = 'none';
1376
+ return;
1377
+ }
1378
+ const rect = target.getBoundingClientRect();
1379
+ els.visualMoveHandle.style.display = 'block';
1380
+ els.visualMoveHandle.style.left = rect.left + 'px';
1381
+ els.visualMoveHandle.style.top = rect.top + 'px';
1382
+ }
1383
+
1384
+ function boundedImageSize(active, event) {
1385
+ const dx = event.clientX - active.startX;
1386
+ const dy = event.clientY - active.startY;
1387
+ const width = Math.max(24, Math.round(active.startWidth + dx));
1388
+ if (event.shiftKey) return { width, height: Math.max(24, Math.round(active.startHeight + dy)) };
1389
+ const ratio = active.startHeight && active.startWidth ? active.startHeight / active.startWidth : 1;
1390
+ return { width, height: Math.max(24, Math.round(width * ratio)) };
1391
+ }
1392
+
1393
+ function boundedBoxSize(active, event) {
1394
+ return {
1395
+ width: Math.max(40, Math.round(active.startWidth + event.clientX - active.startX)),
1396
+ height: Math.max(24, Math.round(active.startHeight + event.clientY - active.startY)),
1397
+ };
1398
+ }
1399
+
1400
+ function boundedTextWidth(active, event) {
1401
+ return Math.max(80, Math.round(active.startWidth + event.clientX - active.startX));
1402
+ }
1403
+
1404
+ function renderVisualResizeHandle(target) {
1405
+ if (!els.visualResizeHandle) return;
1406
+ if (!target || !isDirectResizable(target) || state.activeVisualResize) {
1407
+ els.visualResizeHandle.style.display = 'none';
1408
+ return;
1409
+ }
1410
+ const rect = target.getBoundingClientRect();
1411
+ const mode = visualResizeMode(target);
1412
+ els.visualResizeHandle.dataset.mode = mode;
1413
+ els.visualResizeHandle.style.display = 'block';
1414
+ els.visualResizeHandle.style.left = rect.right + 'px';
1415
+ els.visualResizeHandle.style.top = (mode === 'text-width' ? rect.top + rect.height / 2 : rect.bottom) + 'px';
1416
+ }
1417
+
1418
+ function renderVisualHandles(target) {
1419
+ state.hoverVisualTarget = target && (isDirectResizable(target) || isDirectMovable(target)) ? target : null;
1420
+ renderVisualMoveHandle(state.hoverVisualTarget);
1421
+ renderVisualResizeHandle(state.hoverVisualTarget);
1422
+ }
1423
+
1424
+ function pointerIsOnVisualMoveHandle(event) {
1425
+ if (!state.hoverVisualTarget || !els.visualMoveHandle || els.visualMoveHandle.style.display === 'none') return false;
1426
+ const rect = els.visualMoveHandle.getBoundingClientRect();
1427
+ return event.clientX >= rect.left - 6 && event.clientX <= rect.right + 6 && event.clientY >= rect.top - 6 && event.clientY <= rect.bottom + 6;
1428
+ }
1429
+
1430
+ function pointerIsOnVisualResizeHandle(event) {
1431
+ if (!state.hoverVisualTarget || !els.visualResizeHandle || els.visualResizeHandle.style.display === 'none') return false;
1432
+ const rect = els.visualResizeHandle.getBoundingClientRect();
1433
+ return event.clientX >= rect.left - 6 && event.clientX <= rect.right + 6 && event.clientY >= rect.top - 6 && event.clientY <= rect.bottom + 6;
1434
+ }
1435
+
1436
+ function startVisualMove(event) {
1437
+ const target = state.hoverVisualTarget;
1438
+ if (!target || !isDirectMovable(target)) return false;
1439
+ event.preventDefault();
1440
+ event.stopPropagation();
1441
+ const beforeStyle = target.getAttribute('style') || '';
1442
+ const startTranslate = currentTranslate(target);
1443
+ state.activeVisualMove = {
1444
+ target,
1445
+ payload: { editId: target.dataset.revelaEditId, kind: target.dataset.revelaEditKind },
1446
+ startX: event.clientX,
1447
+ startY: event.clientY,
1448
+ beforeStyle,
1449
+ startTranslate,
1450
+ };
1451
+ setStatus('Moving preview only. Click Save Changes to write the deck.');
1452
+ window.addEventListener('pointermove', updateVisualMove);
1453
+ window.addEventListener('pointerup', finishVisualMove, { once: true });
1454
+ return true;
1455
+ }
1456
+
1457
+ function updateVisualMove(event) {
1458
+ const active = state.activeVisualMove;
1459
+ if (!active) return;
1460
+ let dx = Math.round(event.clientX - active.startX);
1461
+ let dy = Math.round(event.clientY - active.startY);
1462
+ if (event.shiftKey) {
1463
+ if (Math.abs(dx) >= Math.abs(dy)) dy = 0;
1464
+ else dx = 0;
1465
+ }
1466
+ const nextX = active.startTranslate.x + dx;
1467
+ const nextY = active.startTranslate.y + dy;
1468
+ active.target.style.translate = nextX + 'px ' + nextY + 'px';
1469
+ renderHoverOutline(active.target);
1470
+ renderVisualMoveHandle(active.target);
1471
+ renderVisualResizeHandle(active.target);
1472
+ }
1473
+
1474
+ function finishVisualMove() {
1475
+ const active = state.activeVisualMove;
1476
+ state.activeVisualMove = null;
1477
+ window.removeEventListener('pointermove', updateVisualMove);
1478
+ if (!active) return;
1479
+ const translate = active.target.style.translate || 'none';
1480
+ if (translate !== 'none') {
1481
+ upsertVisualChange({
1482
+ type: 'move',
1483
+ editId: active.payload.editId,
1484
+ kind: active.payload.kind,
1485
+ before: { style: active.beforeStyle },
1486
+ after: { stylePatch: { translate } },
1487
+ });
1488
+ }
1489
+ updateVisualToolbar();
1490
+ renderVisualMoveHandle(active.target);
1491
+ renderVisualResizeHandle(active.target);
1492
+ }
1493
+
1494
+ function startVisualResize(event) {
1495
+ const target = state.hoverVisualTarget;
1496
+ if (!target || !isDirectResizable(target)) return false;
1497
+ event.preventDefault();
1498
+ event.stopPropagation();
1499
+ const rect = target.getBoundingClientRect();
1500
+ const tag = target.tagName.toLowerCase();
1501
+ const beforeStyle = target.getAttribute('style') || '';
1502
+ state.activeVisualResize = {
1503
+ target,
1504
+ payload: { editId: target.dataset.revelaEditId, kind: target.dataset.revelaEditKind },
1505
+ tag,
1506
+ startX: event.clientX,
1507
+ startY: event.clientY,
1508
+ startWidth: rect.width,
1509
+ startHeight: rect.height,
1510
+ beforeStyle,
1511
+ mode: visualResizeMode(target),
1512
+ before: { width: rect.width, height: rect.height },
1513
+ };
1514
+ setStatus('Resizing preview only. Click Save Changes to write the deck.');
1515
+ window.addEventListener('pointermove', updateVisualResize);
1516
+ window.addEventListener('pointerup', finishVisualResize, { once: true });
1517
+ return true;
1518
+ }
1519
+
1520
+ function updateVisualResize(event) {
1521
+ const active = state.activeVisualResize;
1522
+ if (!active) return;
1523
+ if (active.mode === 'text-width') {
1524
+ const width = boundedTextWidth(active, event);
1525
+ active.target.style.width = width + 'px';
1526
+ active.target.style.maxWidth = width + 'px';
1527
+ } else if (active.mode === 'image') {
1528
+ const size = boundedImageSize(active, event);
1529
+ const width = size.width;
1530
+ const height = size.height;
1531
+ active.target.style.width = width + 'px';
1532
+ active.target.style.height = height + 'px';
1533
+ } else {
1534
+ const size = boundedBoxSize(active, event);
1535
+ active.target.style.width = size.width + 'px';
1536
+ active.target.style.height = size.height + 'px';
1537
+ }
1538
+ renderHoverOutline(active.target);
1539
+ renderVisualMoveHandle(active.target);
1540
+ renderVisualResizeHandle(active.target);
1541
+ }
1542
+
1543
+ function finishVisualResize() {
1544
+ const active = state.activeVisualResize;
1545
+ state.activeVisualResize = null;
1546
+ window.removeEventListener('pointermove', updateVisualResize);
1547
+ if (!active) return;
1548
+ const rect = active.target.getBoundingClientRect();
1549
+ const stylePatch = active.mode === 'text-width'
1550
+ ? { width: Math.round(rect.width) + 'px', 'max-width': Math.round(rect.width) + 'px' }
1551
+ : { width: Math.round(rect.width) + 'px', height: Math.round(rect.height) + 'px' };
1552
+ upsertVisualChange({
1553
+ type: 'resize',
1554
+ editId: active.payload.editId,
1555
+ kind: active.payload.kind,
1556
+ before: { style: active.beforeStyle, width: active.before.width, height: active.before.height },
1557
+ after: { stylePatch, width: rect.width, height: rect.height },
1558
+ });
1559
+ updateVisualToolbar();
1560
+ renderVisualMoveHandle(active.target);
1561
+ renderVisualResizeHandle(active.target);
1562
+ }
1563
+
1564
+ function undoVisualChange() {
1565
+ const change = state.visualChanges.pop();
1566
+ if (!change) return;
1567
+ const target = elementFromVisualChange(change);
1568
+ if (target) target.setAttribute('style', change.before.style || '');
1569
+ updateVisualToolbar();
1570
+ renderHoverOutline(state.hoverEl);
1571
+ renderVisualHandles(state.hoverEl);
1572
+ setStatus('Undid last visual change.');
1573
+ }
1574
+
1575
+ function resetVisualChanges() {
1576
+ while (state.visualChanges.length) {
1577
+ const change = state.visualChanges.pop();
1578
+ const target = change ? elementFromVisualChange(change) : null;
1579
+ if (target) target.setAttribute('style', change.before.style || '');
1580
+ }
1581
+ updateVisualToolbar();
1582
+ renderHoverOutline(state.hoverEl);
1583
+ renderVisualHandles(state.hoverEl);
1584
+ setStatus('Reset unsaved visual changes.');
1585
+ }
1586
+
1587
+ function updateVisualToolbar() {
1588
+ const count = state.visualChanges.length;
1589
+ els.visualEditToolbar.classList.toggle('active', count > 0);
1590
+ els.visualEditCount.textContent = count + ' unsaved visual change' + (count === 1 ? '' : 's');
1591
+ els.visualUndo.disabled = count === 0 || state.savingVisualChanges;
1592
+ els.visualReset.disabled = count === 0 || state.savingVisualChanges;
1593
+ els.visualSave.disabled = count === 0 || state.savingVisualChanges;
1594
+ }
1595
+
1596
+ async function saveVisualChanges() {
1597
+ if (!state.visualChanges.length || state.savingVisualChanges) return;
1598
+ state.savingVisualChanges = true;
1599
+ updateVisualToolbar();
1600
+ setStatus('Saving visual changes...');
1601
+ try {
1602
+ const res = await fetch('/api/visual-changes?token=' + encodeURIComponent(token), {
1603
+ method: 'POST',
1604
+ headers: { 'content-type': 'application/json' },
1605
+ body: JSON.stringify({ deckVersion: state.deckVersion, changes: state.visualChanges }),
1606
+ });
1607
+ const body = await res.json().catch(() => ({}));
1608
+ if (!res.ok || !body.ok) throw new Error(body.error || 'Could not save visual changes.');
1609
+ state.visualChanges = [];
1610
+ state.deckVersion = body.deckVersion || state.deckVersion;
1611
+ updateVisualToolbar();
1612
+ refreshDeckPreview(Date.now());
1613
+ setStatus('Visual changes saved. Refreshing preview...');
1614
+ } catch (error) {
1615
+ reportError(error);
1616
+ } finally {
1617
+ state.savingVisualChanges = false;
1618
+ updateVisualToolbar();
1619
+ }
1620
+ }
1621
+
1226
1622
  function setEditorWidth(width, persist) {
1227
1623
  const nextWidth = clampEditorWidth(width);
1228
1624
  document.querySelector('.app')?.style.setProperty('--editor-width', nextWidth + 'px');
@@ -1255,10 +1651,12 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1255
1651
  state.referenceOutlines = [];
1256
1652
  doc.addEventListener('scroll', () => {
1257
1653
  renderHoverOutline(state.hoverEl);
1654
+ renderVisualHandles(state.hoverEl);
1258
1655
  renderReferenceOutlines();
1259
1656
  }, true);
1260
1657
  const slides = getSlides(doc);
1261
1658
  syncDeckNavigation();
1659
+ restoreDeckSlideAfterRefresh();
1262
1660
  updateSendState();
1263
1661
  if (state.pendingRefreshMessage) {
1264
1662
  state.pendingRefreshMessage = false;
@@ -1342,12 +1740,47 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1342
1740
  updateDeckNavControls();
1343
1741
  if (changed) clearHoverSilently();
1344
1742
  else renderHoverOutline(state.hoverEl);
1743
+ renderVisualHandles(state.hoverEl);
1345
1744
  renderReferenceOutlines();
1346
1745
  } catch (error) {
1347
1746
  reportError(error);
1348
1747
  }
1349
1748
  }
1350
1749
 
1750
+ function restoreDeckSlideAfterRefresh() {
1751
+ if (state.pendingDeckSlideRestore === null || state.pendingDeckSlideRestore === undefined) return;
1752
+ const targetIndex = state.pendingDeckSlideRestore;
1753
+ state.pendingDeckSlideRestore = null;
1754
+ restoreDeckSlide(targetIndex);
1755
+ }
1756
+
1757
+ function restoreDeckSlide(index) {
1758
+ try {
1759
+ const doc = els.frame.contentDocument;
1760
+ const win = els.frame.contentWindow;
1761
+ if (!doc || !win) return;
1762
+ const slides = getSlides(doc);
1763
+ if (!slides.length) {
1764
+ syncDeckNavigation();
1765
+ return;
1766
+ }
1767
+ const clamped = Math.max(0, Math.min(slides.length - 1, index));
1768
+ const nav = win.RevelaDeckNav;
1769
+ let handled = false;
1770
+ if (nav && typeof nav.goTo === 'function') {
1771
+ try {
1772
+ nav.goTo(clamped);
1773
+ handled = true;
1774
+ } catch {}
1775
+ }
1776
+ if (!handled) applyFallbackDeckNavigation(win, doc, slides, clamped);
1777
+ state.deckSlideIndex = clamped;
1778
+ updateDeckNavControls();
1779
+ } catch (error) {
1780
+ reportError(error);
1781
+ }
1782
+ }
1783
+
1351
1784
  function applyFallbackDeckNavigation(win, doc, slides, index) {
1352
1785
  const target = slides[index];
1353
1786
  const usesOverlaySlides = slides.some((slide) => {
@@ -1400,10 +1833,16 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1400
1833
 
1401
1834
  function refreshDeckPreview(version) {
1402
1835
  state.pendingRefreshMessage = true;
1836
+ state.pendingDeckSlideRestore = state.deckSlideIndex;
1403
1837
  state.initializedDoc = null;
1838
+ state.visualChanges = [];
1839
+ updateVisualToolbar();
1404
1840
  clearReferences(true);
1405
1841
  state.hoverEl = null;
1406
1842
  if (state.hoverOutline) state.hoverOutline.style.display = 'none';
1843
+ if (els.visualMoveHandle) els.visualMoveHandle.style.display = 'none';
1844
+ if (els.visualResizeHandle) els.visualResizeHandle.style.display = 'none';
1845
+ state.hoverVisualTarget = null;
1407
1846
  state.assetDropTarget = null;
1408
1847
  if (state.assetDropOutline) state.assetDropOutline.style.display = 'none';
1409
1848
  state.referenceOutlines.forEach((outline) => outline.style.display = 'none');
@@ -1416,14 +1855,17 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1416
1855
  function onHover(event) {
1417
1856
  try {
1418
1857
  initFrame();
1419
- const target = selectable(targetFromPointer(event));
1858
+ const directTarget = visualTargetFromPointer(event);
1859
+ const target = directTarget || selectable(targetFromPointer(event));
1420
1860
  if (!target || isReferenced(target)) {
1421
1861
  state.hoverEl = null;
1422
1862
  renderHoverOutline(null);
1863
+ renderVisualHandles(null);
1423
1864
  return;
1424
1865
  }
1425
1866
  state.hoverEl = target;
1426
1867
  renderHoverOutline(target);
1868
+ renderVisualHandles(target);
1427
1869
  } catch (error) {
1428
1870
  reportError(error);
1429
1871
  }
@@ -1446,6 +1888,12 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1446
1888
  }
1447
1889
 
1448
1890
  function onPointerDown(event) {
1891
+ if (!event.ctrlKey && !event.metaKey && pointerIsOnVisualMoveHandle(event)) {
1892
+ if (startVisualMove(event)) return;
1893
+ }
1894
+ if (!event.ctrlKey && !event.metaKey && pointerIsOnVisualResizeHandle(event)) {
1895
+ if (startVisualResize(event)) return;
1896
+ }
1449
1897
  if (!event.ctrlKey && !event.metaKey) return;
1450
1898
  try {
1451
1899
  initFrame();
@@ -1779,6 +2227,17 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1779
2227
  return slide;
1780
2228
  }
1781
2229
 
2230
+ function elementFromVisualChange(change) {
2231
+ const editId = change?.editId;
2232
+ const doc = els.frame.contentDocument;
2233
+ if (!editId || !doc) return null;
2234
+ try {
2235
+ return doc.querySelector('[data-revela-edit-id="' + cssEscape(editId) + '"]');
2236
+ } catch {
2237
+ return null;
2238
+ }
2239
+ }
2240
+
1782
2241
  async function sendAssetPlacement(asset, placement) {
1783
2242
  const modeText = placement.targetMode === 'replace'
1784
2243
  ? 'replace the image at the drop target'
@@ -1968,6 +2427,12 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1968
2427
  return doc.elementFromPoint(x, y);
1969
2428
  }
1970
2429
 
2430
+ function visualTargetFromPointer(event) {
2431
+ const raw = targetFromPointer(event);
2432
+ const target = raw?.closest?.('[data-revela-edit-id]') || null;
2433
+ return isDirectResizable(target) || isDirectMovable(target) ? target : null;
2434
+ }
2435
+
1971
2436
  function createOutline(doc, border, fill) {
1972
2437
  const outline = doc.createElement('div');
1973
2438
  outline.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;border:2px solid ' + border + ';background:' + fill + ';border-radius:6px;display:none;';
@@ -2020,12 +2485,14 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2020
2485
  function clearHoverSilently() {
2021
2486
  state.hoverEl = null;
2022
2487
  if (state.hoverOutline) state.hoverOutline.style.display = 'none';
2488
+ renderVisualHandles(null);
2023
2489
  }
2024
2490
 
2025
2491
  function clearHover() {
2026
2492
  state.hoverEl = null;
2027
2493
  setStatus('Hover cleared. Existing references are kept.');
2028
2494
  if (state.hoverOutline) state.hoverOutline.style.display = 'none';
2495
+ renderVisualHandles(null);
2029
2496
  }
2030
2497
 
2031
2498
  function updateSendState() {