@cyber-dash-tech/revela 0.16.3 → 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() {
@@ -0,0 +1,295 @@
1
+ import { readFileSync, statSync, writeFileSync } from "fs"
2
+
3
+ export type VisualEditKind = "image" | "text-width" | "box"
4
+
5
+ export interface VisualEditTarget {
6
+ editId: string
7
+ kind: VisualEditKind
8
+ tagName: "img" | "h1" | "h2" | "h3" | "h4" | "p" | "div" | "figure"
9
+ startOffset: number
10
+ openEndOffset: number
11
+ originalOpenTag: string
12
+ originalStyle: string
13
+ }
14
+
15
+ export interface VisualTargetResizeChange {
16
+ type: "resize"
17
+ editId: string
18
+ kind?: VisualEditKind
19
+ after: {
20
+ stylePatch?: Record<string, unknown>
21
+ width?: number
22
+ height?: number
23
+ }
24
+ }
25
+
26
+ export interface VisualTargetMoveChange {
27
+ type: "move"
28
+ editId: string
29
+ kind?: VisualEditKind
30
+ after: {
31
+ stylePatch?: Record<string, unknown>
32
+ dx?: number
33
+ dy?: number
34
+ }
35
+ }
36
+
37
+ export type VisualTargetChange = VisualTargetResizeChange | VisualTargetMoveChange
38
+
39
+ export interface ApplyVisualTargetChangesInput {
40
+ file: string
41
+ deckVersion?: string
42
+ targetDeckVersion?: string
43
+ targets: Map<string, VisualEditTarget>
44
+ changes: VisualTargetChange[]
45
+ }
46
+
47
+ export interface ApplyVisualTargetChangesResult {
48
+ ok: true
49
+ deckVersion: string
50
+ changeCount: number
51
+ }
52
+
53
+ const TEXT_TAGS = new Set(["h1", "h2", "h3", "h4", "p"])
54
+ const BOX_TAGS = new Set(["div", "figure"])
55
+ const EDIT_TAGS = new Set(["img", ...TEXT_TAGS, ...BOX_TAGS])
56
+ const MAX_DIMENSION_PX = 3840
57
+ const MIN_IMAGE_DIMENSION_PX = 24
58
+ const MIN_TEXT_WIDTH_PX = 80
59
+ const MIN_BOX_WIDTH_PX = 40
60
+ const MIN_BOX_HEIGHT_PX = 24
61
+
62
+ export function annotateVisualEditTargets(html: string): { html: string; targets: Map<string, VisualEditTarget> } {
63
+ const targets = new Map<string, VisualEditTarget>()
64
+ const insertions: Array<{ offset: number; text: string }> = []
65
+ let nextId = 1
66
+
67
+ for (const tag of scanOpeningTags(html)) {
68
+ if (!EDIT_TAGS.has(tag.tagName)) continue
69
+ if (BOX_TAGS.has(tag.tagName) && !isSafeBoxTarget(tag.openTag)) continue
70
+ const editId = `rve-${nextId++}`
71
+ const kind: VisualEditKind = tag.tagName === "img" ? "image" : BOX_TAGS.has(tag.tagName) ? "box" : "text-width"
72
+ targets.set(editId, {
73
+ editId,
74
+ kind,
75
+ tagName: tag.tagName as VisualEditTarget["tagName"],
76
+ startOffset: tag.startOffset,
77
+ openEndOffset: tag.openEndOffset,
78
+ originalOpenTag: tag.openTag,
79
+ originalStyle: attrValue(tag.openTag, "style") || "",
80
+ })
81
+ insertions.push({ offset: tag.openEndOffset - (tag.openTag.endsWith("/>") ? 2 : 1), text: ` data-revela-edit-id="${editId}" data-revela-edit-kind="${kind}"` })
82
+ }
83
+
84
+ let annotated = html
85
+ for (const insertion of insertions.reverse()) {
86
+ annotated = annotated.slice(0, insertion.offset) + insertion.text + annotated.slice(insertion.offset)
87
+ }
88
+ return { html: annotated, targets }
89
+ }
90
+
91
+ export function applyVisualTargetChanges(input: ApplyVisualTargetChangesInput): ApplyVisualTargetChangesResult {
92
+ const currentVersion = readDeckVersion(input.file).version
93
+ if (input.deckVersion && input.deckVersion !== currentVersion) throw new Error("Deck changed outside Review. Refresh Review before saving visual edits.")
94
+ if (input.targetDeckVersion && input.targetDeckVersion !== currentVersion) throw new Error("Review visual targets are stale. Refresh Review before saving visual edits.")
95
+ if (!input.changes.length) throw new Error("No visual changes to save.")
96
+
97
+ let html = readFileSync(input.file, "utf-8")
98
+ const resolved = new Map<string, { target: VisualEditTarget; patch: Record<string, string> }>()
99
+ for (const change of input.changes) {
100
+ const target = input.targets.get(change.editId)
101
+ if (!target) throw new Error("Target is no longer editable. Refresh Review and try again.")
102
+ if (change.kind && change.kind !== target.kind) throw new Error("Visual edit target kind changed. Refresh Review and try again.")
103
+ const currentOpenTag = html.slice(target.startOffset, target.openEndOffset)
104
+ if (currentOpenTag !== target.originalOpenTag) throw new Error("Target is no longer editable. Refresh Review and try again.")
105
+ const patch = normalizeStylePatch(target, change)
106
+ if (!Object.keys(patch).length) throw new Error("Visual change does not contain valid style updates.")
107
+ const existing = resolved.get(target.editId)
108
+ resolved.set(target.editId, { target, patch: { ...(existing?.patch ?? {}), ...patch } })
109
+ }
110
+
111
+ for (const { target, patch } of Array.from(resolved.values()).sort((a, b) => b.target.startOffset - a.target.startOffset)) {
112
+ const updatedOpenTag = patchOpenTagStyle(target.originalOpenTag, patch)
113
+ html = html.slice(0, target.startOffset) + updatedOpenTag + html.slice(target.openEndOffset)
114
+ }
115
+
116
+ writeFileSync(input.file, html, "utf-8")
117
+ return { ok: true, deckVersion: readDeckVersion(input.file).version, changeCount: input.changes.length }
118
+ }
119
+
120
+ export function readDeckVersion(file: string): { mtimeMs: number; size: number; version: string } {
121
+ const stat = statSync(file)
122
+ return { mtimeMs: stat.mtimeMs, size: stat.size, version: `${stat.mtimeMs}:${stat.size}` }
123
+ }
124
+
125
+ function normalizeStylePatch(target: VisualEditTarget, change: VisualTargetChange): Record<string, string> {
126
+ const input = change.after.stylePatch ?? {}
127
+ if (change.type === "move") return normalizeMovePatch(target, input)
128
+ if (change.type !== "resize") throw new Error(`Unsupported visual change type: ${(change as any).type}`)
129
+ return normalizeResizePatch(target.kind, input)
130
+ }
131
+
132
+ function normalizeResizePatch(kind: VisualEditKind, input: Record<string, unknown>): Record<string, string> {
133
+ const patch: Record<string, string> = {}
134
+ const width = typeof input.width === "string" ? normalizePxValue(input.width, kind === "image" ? MIN_IMAGE_DIMENSION_PX : kind === "box" ? MIN_BOX_WIDTH_PX : MIN_TEXT_WIDTH_PX) : null
135
+ if (width) patch.width = width
136
+ if (kind === "image" || kind === "box") {
137
+ const height = typeof input.height === "string" ? normalizePxValue(input.height, kind === "image" ? MIN_IMAGE_DIMENSION_PX : MIN_BOX_HEIGHT_PX) : null
138
+ if (height) patch.height = height
139
+ } else {
140
+ const maxWidth = typeof input["max-width"] === "string" ? normalizePxValue(input["max-width"], MIN_TEXT_WIDTH_PX) : null
141
+ if (maxWidth) patch["max-width"] = maxWidth
142
+ }
143
+ return patch
144
+ }
145
+
146
+ function normalizeMovePatch(target: VisualEditTarget, input: Record<string, unknown>): Record<string, string> {
147
+ const translate = typeof input.translate === "string" ? normalizeTranslateValue(input.translate) : null
148
+ if (translate) return { translate }
149
+ const transform = typeof input.transform === "string" ? normalizeLegacyTransformValue(input.transform) : null
150
+ return transform ? { translate: transform } : {}
151
+ }
152
+
153
+ function normalizeTranslateValue(value: string): string | null {
154
+ const direct = parseTranslateProperty(value)
155
+ if (direct) return normalizeTranslatePoint(direct)
156
+ return normalizeLegacyTransformValue(value)
157
+ }
158
+
159
+ function normalizeLegacyTransformValue(value: string): string | null {
160
+ const parsed = parseSimpleTranslate(value)
161
+ if (!parsed) return null
162
+ return normalizeTranslatePoint(parsed)
163
+ }
164
+
165
+ function normalizeTranslatePoint(parsed: { x: number; y: number }): string | null {
166
+ const { x, y } = parsed
167
+ if (Math.abs(x) > MAX_DIMENSION_PX || Math.abs(y) > MAX_DIMENSION_PX) return null
168
+ return `${Math.round(x)}px ${Math.round(y)}px`
169
+ }
170
+
171
+ function parseTranslateProperty(value: string): { x: number; y: number } | null {
172
+ const normalized = value.trim()
173
+ const match = /^(-?\d+(?:\.\d+)?)px(?:\s+|\s*,\s*)(-?\d+(?:\.\d+)?)px$/.exec(normalized)
174
+ return match ? finitePoint(Number(match[1]), Number(match[2])) : null
175
+ }
176
+
177
+ function parseSimpleTranslate(value: string): { x: number; y: number } | null {
178
+ const normalized = value.trim()
179
+ if (!normalized || normalized === "none") return { x: 0, y: 0 }
180
+ const translate = /^translate\(\s*(-?\d+(?:\.\d+)?)px(?:\s*,\s*|\s+)(-?\d+(?:\.\d+)?)px\s*\)$/.exec(normalized)
181
+ if (translate) return finitePoint(Number(translate[1]), Number(translate[2]))
182
+ 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)
183
+ if (!matrix) return null
184
+ const a = Number(matrix[1])
185
+ const b = Number(matrix[2])
186
+ const c = Number(matrix[3])
187
+ const d = Number(matrix[4])
188
+ if (a !== 1 || b !== 0 || c !== 0 || d !== 1) return null
189
+ return finitePoint(Number(matrix[5]), Number(matrix[6]))
190
+ }
191
+
192
+ function finitePoint(x: number, y: number): { x: number; y: number } | null {
193
+ return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null
194
+ }
195
+
196
+ function normalizePxValue(value: string, min: number): string | null {
197
+ const match = /^(\d+(?:\.\d+)?)px$/.exec(value.trim())
198
+ if (!match) return null
199
+ const number = Number(match[1])
200
+ if (!Number.isFinite(number) || number < min || number > MAX_DIMENSION_PX) return null
201
+ return `${Math.round(number)}px`
202
+ }
203
+
204
+ function patchOpenTagStyle(openTag: string, patch: Record<string, string>): string {
205
+ const styleMatch = /\sstyle=("([^"]*)"|'([^']*)')/i.exec(openTag)
206
+ const current = styleMatch ? parseStyle(styleMatch[2] ?? styleMatch[3] ?? "") : {}
207
+ const next = serializeStyle({ ...current, ...patch })
208
+ if (styleMatch) return openTag.slice(0, styleMatch.index) + ` style="${escapeAttr(next)}"` + openTag.slice(styleMatch.index + styleMatch[0].length)
209
+ return openTag.replace(/\s*\/?>$/, (ending) => ` style="${escapeAttr(next)}"${ending}`)
210
+ }
211
+
212
+ function parseStyle(style: string): Record<string, string> {
213
+ const result: Record<string, string> = {}
214
+ for (const part of style.split(";")) {
215
+ const index = part.indexOf(":")
216
+ if (index < 0) continue
217
+ const key = part.slice(0, index).trim().toLowerCase()
218
+ const value = part.slice(index + 1).trim()
219
+ if (key && value) result[key] = value
220
+ }
221
+ return result
222
+ }
223
+
224
+ function serializeStyle(style: Record<string, string>): string {
225
+ return Object.entries(style).map(([key, value]) => `${key}: ${value}`).join("; ")
226
+ }
227
+
228
+ function escapeAttr(value: string): string {
229
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;")
230
+ }
231
+
232
+ interface OpenTagRegion {
233
+ tagName: string
234
+ startOffset: number
235
+ openEndOffset: number
236
+ openTag: string
237
+ }
238
+
239
+ function scanOpeningTags(html: string): OpenTagRegion[] {
240
+ const tags: OpenTagRegion[] = []
241
+ let index = 0
242
+ while (index < html.length) {
243
+ const open = html.indexOf("<", index)
244
+ if (open < 0) break
245
+ if (html.startsWith("<!--", open)) {
246
+ const close = html.indexOf("-->", open + 4)
247
+ index = close < 0 ? html.length : close + 3
248
+ continue
249
+ }
250
+ const close = html.indexOf(">", open + 1)
251
+ if (close < 0) break
252
+ const raw = html.slice(open, close + 1)
253
+ const tagName = normalizeName(/^<\s*([a-zA-Z0-9-]+)/.exec(raw)?.[1] || "")
254
+ if (!tagName || /^<\s*[!/]/.test(raw) || /^<\s*\//.test(raw)) {
255
+ index = close + 1
256
+ continue
257
+ }
258
+ tags.push({ tagName, startOffset: open, openEndOffset: close + 1, openTag: raw })
259
+ if (tagName === "script" || tagName === "style") {
260
+ const closeTag = new RegExp(`</\\s*${tagName}\\s*>`, "ig")
261
+ closeTag.lastIndex = close + 1
262
+ const match = closeTag.exec(html)
263
+ index = match ? match.index + match[0].length : close + 1
264
+ } else {
265
+ index = close + 1
266
+ }
267
+ }
268
+ return tags
269
+ }
270
+
271
+ function attrValue(openTag: string, name: string): string | undefined {
272
+ const escaped = escapeRegExp(name)
273
+ const match = new RegExp(`\\s${escaped}=("([^"]*)"|'([^']*)')`, "i").exec(openTag)
274
+ return match ? match[2] ?? match[3] : undefined
275
+ }
276
+
277
+ function isSafeBoxTarget(openTag: string): boolean {
278
+ const tagName = normalizeName(/^<\s*([a-zA-Z0-9-]+)/.exec(openTag)?.[1] || "")
279
+ const className = attrValue(openTag, "class") || ""
280
+ const classes = className.split(/\s+/).map((item) => item.trim().toLowerCase()).filter(Boolean)
281
+ if (tagName === "div" && classes.length === 0) return false
282
+ if (/\s(?:data-chart|data-echarts|_echarts_instance_)\b/i.test(openTag)) return false
283
+ if (classes.some((name) => name === "slide" || name === "slide-canvas" || name === "deck" || name === "page")) return false
284
+ if (classes.some((name) => name.startsWith("revela-") || name.startsWith("echarts-") || name === "echart-container" || name === "echart-panel")) return false
285
+ if (classes.some((name) => /(^|-)echart($|-)/.test(name) || name === "chart-container" || name === "chart-panel")) return false
286
+ return true
287
+ }
288
+
289
+ function normalizeName(value: string | undefined): string {
290
+ return (value || "").trim().toLowerCase()
291
+ }
292
+
293
+ function escapeRegExp(value: string): string {
294
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
295
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyber-dash-tech/revela",
3
- "version": "0.16.3",
3
+ "version": "0.16.4",
4
4
  "description": "OpenCode plugin that turns AI into an HTML slide deck generator",
5
5
  "type": "module",
6
6
  "main": "./index.ts",