@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.
- package/lib/refine/server.ts +472 -5
- package/lib/refine/visual-targets.ts +295 -0
- package/package.json +1 -1
package/lib/refine/server.ts
CHANGED
|
@@ -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
|
|
343
|
-
|
|
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
|
|
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, "&").replace(/"/g, """)
|
|
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
|
+
}
|