@cyber-dash-tech/revela 0.9.0 → 0.10.0
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/README.md +29 -4
- package/README.zh-CN.md +29 -4
- package/designs/monet/DESIGN.md +9 -9
- package/designs/starter/DESIGN.md +8 -8
- package/designs/summit/DESIGN.md +9 -9
- package/lib/commands/help.ts +2 -0
- package/lib/commands/inspect.ts +23 -0
- package/lib/commands/refine.ts +26 -0
- package/lib/commands/review.ts +8 -2
- package/lib/decks-state.ts +476 -6
- package/lib/inspect/open.ts +61 -0
- package/lib/inspect/prompt.ts +32 -0
- package/lib/inspect/request.ts +70 -0
- package/lib/inspect/requests.ts +86 -0
- package/lib/inspect/server.ts +1063 -0
- package/lib/inspect/slide-index.ts +12 -0
- package/lib/inspection-context/compile.ts +346 -0
- package/lib/inspection-context/match.ts +169 -0
- package/lib/inspection-context/project.ts +263 -0
- package/lib/inspection-context/result.ts +160 -0
- package/lib/refine/open.ts +68 -0
- package/lib/refine/server.ts +1581 -0
- package/package.json +1 -1
- package/plugin.ts +22 -0
- package/skill/SKILL.md +10 -5
- package/tools/decks.ts +12 -2
- package/tools/inspection-context.ts +22 -0
- package/tools/inspection-result.ts +63 -0
|
@@ -0,0 +1,1581 @@
|
|
|
1
|
+
import { randomBytes } from "crypto"
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "fs"
|
|
3
|
+
import { fileURLToPath } from "url"
|
|
4
|
+
import { dirname, extname, isAbsolute, resolve, sep } from "path"
|
|
5
|
+
import type { EditableDeck } from "../edit/resolve-deck"
|
|
6
|
+
import { buildEditPrompt, type EditCommentPayload } from "../edit/prompt"
|
|
7
|
+
import type { InspectionElementSnapshot } from "../inspection-context/match"
|
|
8
|
+
import { buildInspectionPrompt } from "../inspect/prompt"
|
|
9
|
+
import { projectWorkspaceElement } from "../inspect/request"
|
|
10
|
+
import { createInspectRequest, failInspectRequest, getInspectRequest } from "../inspect/requests"
|
|
11
|
+
|
|
12
|
+
const TOKEN_BYTES = 24
|
|
13
|
+
const SESSION_TTL_MS = 2 * 60 * 60 * 1000
|
|
14
|
+
const IDLE_STOP_MS = 30 * 60 * 1000
|
|
15
|
+
export const LIVE_EDITOR_IDLE_MS = 10 * 1000
|
|
16
|
+
|
|
17
|
+
interface EditAsset {
|
|
18
|
+
id: string
|
|
19
|
+
absoluteFile: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface EditSession {
|
|
23
|
+
token: string
|
|
24
|
+
client: any
|
|
25
|
+
sessionID: string
|
|
26
|
+
deck: string
|
|
27
|
+
file: string
|
|
28
|
+
absoluteFile: string
|
|
29
|
+
workspaceRoot: string
|
|
30
|
+
assets: Map<string, EditAsset>
|
|
31
|
+
assetKeys: Map<string, string>
|
|
32
|
+
nextAssetId: number
|
|
33
|
+
createdAt: number
|
|
34
|
+
lastActiveAt: number
|
|
35
|
+
defaultMode: RefineMode
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type RefineMode = "edit" | "inspect"
|
|
39
|
+
|
|
40
|
+
export interface RefineServerHandle {
|
|
41
|
+
baseUrl: string
|
|
42
|
+
getOrCreateSession(input: { client: any; sessionID: string; workspaceRoot: string; deck: EditableDeck; mode?: RefineMode }): EditServerSessionResult
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface EditServerSessionResult {
|
|
46
|
+
token: string
|
|
47
|
+
reused: boolean
|
|
48
|
+
live: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let server: ReturnType<typeof Bun.serve> | undefined
|
|
52
|
+
let baseUrl = ""
|
|
53
|
+
let idleTimer: Timer | undefined
|
|
54
|
+
const sessions = new Map<string, EditSession>()
|
|
55
|
+
|
|
56
|
+
export function startRefineServer(): RefineServerHandle {
|
|
57
|
+
if (!server) {
|
|
58
|
+
server = Bun.serve({
|
|
59
|
+
hostname: "127.0.0.1",
|
|
60
|
+
port: 0,
|
|
61
|
+
fetch: handleRequest,
|
|
62
|
+
})
|
|
63
|
+
baseUrl = `http://127.0.0.1:${server.port}`
|
|
64
|
+
scheduleIdleStop()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
baseUrl,
|
|
69
|
+
getOrCreateSession(input) {
|
|
70
|
+
cleanupExpiredSessions()
|
|
71
|
+
const existing = findSessionForDeck(input.deck.absoluteFile)
|
|
72
|
+
if (existing) {
|
|
73
|
+
existing.session.client = input.client
|
|
74
|
+
existing.session.sessionID = input.sessionID
|
|
75
|
+
existing.session.deck = input.deck.slug
|
|
76
|
+
existing.session.file = input.deck.file
|
|
77
|
+
existing.session.workspaceRoot = resolve(input.workspaceRoot)
|
|
78
|
+
existing.session.defaultMode = input.mode ?? "edit"
|
|
79
|
+
return {
|
|
80
|
+
token: existing.token,
|
|
81
|
+
reused: true,
|
|
82
|
+
live: isSessionLive(existing.session),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const token = randomBytes(TOKEN_BYTES).toString("base64url")
|
|
87
|
+
sessions.set(token, {
|
|
88
|
+
token,
|
|
89
|
+
client: input.client,
|
|
90
|
+
sessionID: input.sessionID,
|
|
91
|
+
deck: input.deck.slug,
|
|
92
|
+
file: input.deck.file,
|
|
93
|
+
absoluteFile: input.deck.absoluteFile,
|
|
94
|
+
workspaceRoot: resolve(input.workspaceRoot),
|
|
95
|
+
assets: new Map(),
|
|
96
|
+
assetKeys: new Map(),
|
|
97
|
+
nextAssetId: 1,
|
|
98
|
+
createdAt: Date.now(),
|
|
99
|
+
lastActiveAt: Date.now(),
|
|
100
|
+
defaultMode: input.mode ?? "edit",
|
|
101
|
+
})
|
|
102
|
+
return { token, reused: false, live: false }
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function hasLiveEditorSession(deck: EditableDeck, maxIdleMs = LIVE_EDITOR_IDLE_MS): boolean {
|
|
108
|
+
cleanupExpiredSessions()
|
|
109
|
+
const existing = findSessionForDeck(deck.absoluteFile)
|
|
110
|
+
return existing ? isSessionLive(existing.session, maxIdleMs) : false
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function hasLiveEditorSessionForFile(workspaceRoot: string, filePath: string, maxIdleMs = LIVE_EDITOR_IDLE_MS): boolean {
|
|
114
|
+
if (!filePath) return false
|
|
115
|
+
const root = resolve(workspaceRoot)
|
|
116
|
+
const absoluteFile = resolve(root, filePath)
|
|
117
|
+
if (absoluteFile !== root && !absoluteFile.startsWith(root.endsWith(sep) ? root : root + sep)) return false
|
|
118
|
+
cleanupExpiredSessions()
|
|
119
|
+
const existing = findSessionForDeck(absoluteFile)
|
|
120
|
+
return existing ? isSessionLive(existing.session, maxIdleMs) : false
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function stopRefineServer(): void {
|
|
124
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
125
|
+
idleTimer = undefined
|
|
126
|
+
sessions.clear()
|
|
127
|
+
server?.stop()
|
|
128
|
+
server = undefined
|
|
129
|
+
baseUrl = ""
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const stopEditServer = stopRefineServer
|
|
133
|
+
|
|
134
|
+
function findSessionForDeck(absoluteFile: string): { token: string; session: EditSession } | undefined {
|
|
135
|
+
for (const [token, session] of sessions) {
|
|
136
|
+
if (session.absoluteFile === absoluteFile) return { token, session }
|
|
137
|
+
}
|
|
138
|
+
return undefined
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isSessionLive(session: EditSession, maxIdleMs = LIVE_EDITOR_IDLE_MS): boolean {
|
|
142
|
+
return Date.now() - session.lastActiveAt <= maxIdleMs
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function handleRequest(req: Request): Promise<Response> {
|
|
146
|
+
cleanupExpiredSessions()
|
|
147
|
+
const url = new URL(req.url)
|
|
148
|
+
|
|
149
|
+
if (url.pathname === "/health") return textResponse("ok")
|
|
150
|
+
|
|
151
|
+
if (url.pathname === "/refine" && req.method === "GET") {
|
|
152
|
+
const session = validateSession(url.searchParams.get("token"))
|
|
153
|
+
if (!session.ok) return session.response
|
|
154
|
+
return htmlResponse(renderRefineShell(session.value.token, session.value.defaultMode))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (url.pathname === "/deck" && req.method === "GET") {
|
|
158
|
+
const session = validateSession(url.searchParams.get("token"))
|
|
159
|
+
if (!session.ok) return session.response
|
|
160
|
+
return handleDeck(session.value)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (url.pathname === "/__revela_asset" && (req.method === "GET" || req.method === "HEAD")) {
|
|
164
|
+
const session = validateSession(url.searchParams.get("token"))
|
|
165
|
+
if (!session.ok) return session.response
|
|
166
|
+
return handleAsset(session.value, url.searchParams.get("id"), req.method)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (url.pathname === "/api/comment" && req.method === "POST") {
|
|
170
|
+
const session = validateSession(url.searchParams.get("token"))
|
|
171
|
+
if (!session.ok) return session.response
|
|
172
|
+
return handleComment(req, session.value)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (url.pathname === "/api/inspect" && req.method === "POST") {
|
|
176
|
+
const session = validateSession(url.searchParams.get("token"))
|
|
177
|
+
if (!session.ok) return session.response
|
|
178
|
+
return handleInspect(req, session.value)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (url.pathname === "/api/inspect-result" && req.method === "GET") {
|
|
182
|
+
const session = validateSession(url.searchParams.get("token"))
|
|
183
|
+
if (!session.ok) return session.response
|
|
184
|
+
return handleInspectResult(url.searchParams.get("requestId"), session.value)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (url.pathname === "/api/deck-version" && req.method === "GET") {
|
|
188
|
+
const session = validateSession(url.searchParams.get("token"))
|
|
189
|
+
if (!session.ok) return session.response
|
|
190
|
+
return handleDeckVersion(session.value)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return textResponse("Not found", 404)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function handleDeck(session: EditSession): Response {
|
|
197
|
+
session.assets.clear()
|
|
198
|
+
session.assetKeys.clear()
|
|
199
|
+
session.nextAssetId = 1
|
|
200
|
+
const html = readFileSync(session.absoluteFile, "utf-8")
|
|
201
|
+
return htmlResponse(rewriteLocalAssetRefs(html, {
|
|
202
|
+
session,
|
|
203
|
+
sourceFile: session.absoluteFile,
|
|
204
|
+
contentType: "html",
|
|
205
|
+
}))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function handleAsset(session: EditSession, id: string | null, method: string): Response {
|
|
209
|
+
if (!id) return textResponse("Missing asset id", 400)
|
|
210
|
+
const asset = session.assets.get(id)
|
|
211
|
+
if (!asset) return textResponse("Asset not found", 404)
|
|
212
|
+
if (!existsSync(asset.absoluteFile)) return textResponse("Asset file not found", 404)
|
|
213
|
+
if (!statSync(asset.absoluteFile).isFile()) return textResponse("Asset is not a file", 404)
|
|
214
|
+
|
|
215
|
+
const mime = mimeTypeForPath(asset.absoluteFile)
|
|
216
|
+
const headers = {
|
|
217
|
+
"content-type": mime,
|
|
218
|
+
"cache-control": "no-store, max-age=0",
|
|
219
|
+
}
|
|
220
|
+
if (method === "HEAD") return new Response(null, { status: 200, headers })
|
|
221
|
+
|
|
222
|
+
if (mime === "text/css") {
|
|
223
|
+
const css = readFileSync(asset.absoluteFile, "utf-8")
|
|
224
|
+
return new Response(rewriteLocalAssetRefs(css, {
|
|
225
|
+
session,
|
|
226
|
+
sourceFile: asset.absoluteFile,
|
|
227
|
+
contentType: "css",
|
|
228
|
+
}), { status: 200, headers })
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return new Response(new Uint8Array(readFileSync(asset.absoluteFile)), { status: 200, headers })
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function rewriteLocalAssetRefs(content: string, input: { session: EditSession; sourceFile: string; contentType: "html" | "css" }): string {
|
|
235
|
+
const baseDir = dirname(input.sourceFile)
|
|
236
|
+
let rewritten = rewriteCssUrls(content, input.session, baseDir)
|
|
237
|
+
if (input.contentType === "css") return rewritten
|
|
238
|
+
|
|
239
|
+
rewritten = rewriteHtmlAssetAttributes(rewritten, input.session, baseDir)
|
|
240
|
+
rewritten = rewriteSrcsetAttributes(rewritten, input.session, baseDir)
|
|
241
|
+
return rewritten
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function rewriteHtmlAssetAttributes(html: string, session: EditSession, baseDir: string): string {
|
|
245
|
+
const attrPattern = /\b(src|href|poster)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/gi
|
|
246
|
+
return html.replace(attrPattern, (match, name: string, raw: string, doubleQuoted?: string, singleQuoted?: string, unquoted?: string) => {
|
|
247
|
+
const value = doubleQuoted ?? singleQuoted ?? unquoted ?? ""
|
|
248
|
+
const assetUrl = assetUrlForRef(value, session, baseDir)
|
|
249
|
+
if (!assetUrl) return match
|
|
250
|
+
const quote = doubleQuoted !== undefined ? '"' : singleQuoted !== undefined ? "'" : ""
|
|
251
|
+
const escaped = quote ? assetUrl.replace(/&/g, "&") : assetUrl
|
|
252
|
+
return `${name}=${quote}${escaped}${quote}`
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function rewriteSrcsetAttributes(html: string, session: EditSession, baseDir: string): string {
|
|
257
|
+
const srcsetPattern = /\bsrcset\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/gi
|
|
258
|
+
return html.replace(srcsetPattern, (match, raw: string, doubleQuoted?: string, singleQuoted?: string, unquoted?: string) => {
|
|
259
|
+
const value = doubleQuoted ?? singleQuoted ?? unquoted ?? ""
|
|
260
|
+
const rewritten = rewriteSrcset(value, session, baseDir)
|
|
261
|
+
if (rewritten === value) return match
|
|
262
|
+
const quote = doubleQuoted !== undefined ? '"' : singleQuoted !== undefined ? "'" : ""
|
|
263
|
+
const escaped = quote ? rewritten.replace(/&/g, "&") : rewritten
|
|
264
|
+
return `srcset=${quote}${escaped}${quote}`
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function rewriteSrcset(value: string, session: EditSession, baseDir: string): string {
|
|
269
|
+
return value.split(",").map((part) => {
|
|
270
|
+
const trimmed = part.trim()
|
|
271
|
+
if (!trimmed) return part
|
|
272
|
+
const pieces = trimmed.split(/\s+/)
|
|
273
|
+
const assetUrl = assetUrlForRef(pieces[0], session, baseDir)
|
|
274
|
+
if (!assetUrl) return part
|
|
275
|
+
return [assetUrl, ...pieces.slice(1)].join(" ")
|
|
276
|
+
}).join(", ")
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function rewriteCssUrls(content: string, session: EditSession, baseDir: string): string {
|
|
280
|
+
const cssUrlPattern = /url\(\s*("([^"]*)"|'([^']*)'|([^\s)]+))\s*\)/gi
|
|
281
|
+
return content.replace(cssUrlPattern, (match, raw: string, doubleQuoted?: string, singleQuoted?: string, unquoted?: string) => {
|
|
282
|
+
const value = doubleQuoted ?? singleQuoted ?? unquoted ?? ""
|
|
283
|
+
const assetUrl = assetUrlForRef(value, session, baseDir)
|
|
284
|
+
if (!assetUrl) return match
|
|
285
|
+
return `url("${assetUrl.replace(/"/g, "%22")}")`
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function assetUrlForRef(ref: string, session: EditSession, baseDir: string): string | null {
|
|
290
|
+
const absoluteFile = resolveLocalAssetRef(ref, session.workspaceRoot, baseDir)
|
|
291
|
+
if (!absoluteFile || !existsSync(absoluteFile) || !statSync(absoluteFile).isFile()) return null
|
|
292
|
+
const key = resolve(absoluteFile)
|
|
293
|
+
let id = session.assetKeys.get(key)
|
|
294
|
+
if (!id) {
|
|
295
|
+
id = String(session.nextAssetId++)
|
|
296
|
+
session.assetKeys.set(key, id)
|
|
297
|
+
session.assets.set(id, { id, absoluteFile: key })
|
|
298
|
+
}
|
|
299
|
+
return `/__revela_asset?token=${encodeURIComponent(session.token)}&id=${encodeURIComponent(id)}`
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function resolveLocalAssetRef(ref: string, workspaceRoot: string, baseDir: string): string | null {
|
|
303
|
+
const trimmed = ref.trim()
|
|
304
|
+
if (!trimmed || isSkippedAssetRef(trimmed)) return null
|
|
305
|
+
|
|
306
|
+
const pathPart = stripQueryAndHash(trimmed)
|
|
307
|
+
if (!pathPart) return null
|
|
308
|
+
|
|
309
|
+
if (pathPart.startsWith("file://")) {
|
|
310
|
+
try {
|
|
311
|
+
return resolve(fileURLToPath(pathPart))
|
|
312
|
+
} catch {
|
|
313
|
+
return null
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const decodedPath = safeDecodeUri(pathPart)
|
|
318
|
+
if (isWindowsAbsolutePath(decodedPath)) return resolve(decodedPath)
|
|
319
|
+
|
|
320
|
+
if (isAbsolute(decodedPath)) {
|
|
321
|
+
const absolute = resolve(decodedPath)
|
|
322
|
+
if (existsSync(absolute)) return absolute
|
|
323
|
+
return resolve(workspaceRoot, `.${decodedPath}`)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return resolve(baseDir, decodedPath)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function stripQueryAndHash(ref: string): string {
|
|
330
|
+
const hashIndex = ref.indexOf("#")
|
|
331
|
+
const withoutHash = hashIndex >= 0 ? ref.slice(0, hashIndex) : ref
|
|
332
|
+
const queryIndex = withoutHash.indexOf("?")
|
|
333
|
+
return queryIndex >= 0 ? withoutHash.slice(0, queryIndex) : withoutHash
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function safeDecodeUri(value: string): string {
|
|
337
|
+
try {
|
|
338
|
+
return decodeURI(value)
|
|
339
|
+
} catch {
|
|
340
|
+
return value
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function isSkippedAssetRef(ref: string): boolean {
|
|
345
|
+
return /^(?:https?:|data:|blob:|mailto:|tel:|javascript:|#)/i.test(ref) || ref.startsWith("//")
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function isWindowsAbsolutePath(value: string): boolean {
|
|
349
|
+
return /^[a-zA-Z]:[\\/]/.test(value)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function mimeTypeForPath(filePath: string): string {
|
|
353
|
+
switch (extname(filePath).toLowerCase()) {
|
|
354
|
+
case ".html":
|
|
355
|
+
case ".htm":
|
|
356
|
+
return "text/html; charset=utf-8"
|
|
357
|
+
case ".css":
|
|
358
|
+
return "text/css"
|
|
359
|
+
case ".js":
|
|
360
|
+
return "application/javascript"
|
|
361
|
+
case ".jpg":
|
|
362
|
+
case ".jpeg":
|
|
363
|
+
return "image/jpeg"
|
|
364
|
+
case ".png":
|
|
365
|
+
return "image/png"
|
|
366
|
+
case ".gif":
|
|
367
|
+
return "image/gif"
|
|
368
|
+
case ".webp":
|
|
369
|
+
return "image/webp"
|
|
370
|
+
case ".svg":
|
|
371
|
+
return "image/svg+xml"
|
|
372
|
+
case ".woff":
|
|
373
|
+
return "font/woff"
|
|
374
|
+
case ".woff2":
|
|
375
|
+
return "font/woff2"
|
|
376
|
+
case ".ttf":
|
|
377
|
+
return "font/ttf"
|
|
378
|
+
case ".otf":
|
|
379
|
+
return "font/otf"
|
|
380
|
+
case ".mp4":
|
|
381
|
+
return "video/mp4"
|
|
382
|
+
case ".webm":
|
|
383
|
+
return "video/webm"
|
|
384
|
+
case ".mp3":
|
|
385
|
+
return "audio/mpeg"
|
|
386
|
+
case ".wav":
|
|
387
|
+
return "audio/wav"
|
|
388
|
+
default:
|
|
389
|
+
return "application/octet-stream"
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function handleDeckVersion(session: EditSession): Response {
|
|
394
|
+
try {
|
|
395
|
+
const version = readDeckVersion(session)
|
|
396
|
+
session.lastActiveAt = Date.now()
|
|
397
|
+
scheduleIdleStop()
|
|
398
|
+
return jsonResponse({ ok: true, ...version })
|
|
399
|
+
} catch (error) {
|
|
400
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
401
|
+
return jsonResponse({ ok: false, error: message }, 404)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function readDeckVersion(session: EditSession): { mtimeMs: number; size: number; version: string } {
|
|
406
|
+
const stat = statSync(session.absoluteFile)
|
|
407
|
+
const version = `${stat.mtimeMs}:${stat.size}`
|
|
408
|
+
return { mtimeMs: stat.mtimeMs, size: stat.size, version }
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function handleComment(req: Request, session: EditSession): Promise<Response> {
|
|
412
|
+
let body: Partial<EditCommentPayload>
|
|
413
|
+
try {
|
|
414
|
+
body = await req.json()
|
|
415
|
+
} catch {
|
|
416
|
+
return jsonResponse({ ok: false, error: "Invalid JSON body" }, 400)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const comments = Array.isArray(body.comments)
|
|
420
|
+
? body.comments
|
|
421
|
+
.map((draft: any) => ({
|
|
422
|
+
comment: typeof draft?.comment === "string" ? draft.comment.trim() : "",
|
|
423
|
+
elements: Array.isArray(draft?.elements) ? draft.elements : [],
|
|
424
|
+
}))
|
|
425
|
+
.filter((draft) => draft.comment && draft.elements.length > 0)
|
|
426
|
+
: []
|
|
427
|
+
const comment = typeof body.comment === "string" ? body.comment.trim() : ""
|
|
428
|
+
const elements = Array.isArray(body.elements) ? body.elements : []
|
|
429
|
+
if (!comment && comments.length === 0) return jsonResponse({ ok: false, error: "Comment is required" }, 400)
|
|
430
|
+
|
|
431
|
+
const prompt = buildEditPrompt({
|
|
432
|
+
...body,
|
|
433
|
+
deck: session.deck,
|
|
434
|
+
file: session.file,
|
|
435
|
+
comment,
|
|
436
|
+
elements,
|
|
437
|
+
comments,
|
|
438
|
+
})
|
|
439
|
+
const deckVersion = readDeckVersion(session).version
|
|
440
|
+
|
|
441
|
+
await session.client.session.prompt({
|
|
442
|
+
path: { id: session.sessionID },
|
|
443
|
+
body: {
|
|
444
|
+
parts: [{ type: "text", text: prompt }],
|
|
445
|
+
},
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
session.lastActiveAt = Date.now()
|
|
449
|
+
scheduleIdleStop()
|
|
450
|
+
return jsonResponse({ ok: true, deckVersion })
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function handleInspect(req: Request, session: EditSession): Promise<Response> {
|
|
454
|
+
let body: any
|
|
455
|
+
try {
|
|
456
|
+
body = await req.json()
|
|
457
|
+
} catch {
|
|
458
|
+
return jsonResponse({ ok: false, error: "Invalid JSON body" }, 400)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const snapshot = normalizeSnapshot(body?.snapshot ?? body)
|
|
462
|
+
const requestId = typeof body?.requestId === "string" && body.requestId.trim() ? body.requestId.trim() : randomBytes(10).toString("base64url")
|
|
463
|
+
const version = readDeckVersion(session).version
|
|
464
|
+
const staleReason = typeof body?.deckVersion === "string" && body.deckVersion !== version
|
|
465
|
+
? "Deck changed after the browser captured this selection. Re-select the element for the freshest inspection."
|
|
466
|
+
: undefined
|
|
467
|
+
|
|
468
|
+
try {
|
|
469
|
+
const { projection, preprocess } = projectWorkspaceElement(session.workspaceRoot, snapshot, { requestId })
|
|
470
|
+
createInspectRequest({ requestId, projection, deckVersion: version })
|
|
471
|
+
|
|
472
|
+
session.lastActiveAt = Date.now()
|
|
473
|
+
scheduleIdleStop()
|
|
474
|
+
|
|
475
|
+
void session.client.session.prompt({
|
|
476
|
+
path: { id: session.sessionID },
|
|
477
|
+
body: {
|
|
478
|
+
parts: [{
|
|
479
|
+
type: "text",
|
|
480
|
+
text: buildInspectionPrompt({
|
|
481
|
+
requestId,
|
|
482
|
+
file: session.file,
|
|
483
|
+
projection: staleReason
|
|
484
|
+
? { ...projection, stale: { stale: true, reason: staleReason } } as any
|
|
485
|
+
: projection,
|
|
486
|
+
}),
|
|
487
|
+
}],
|
|
488
|
+
},
|
|
489
|
+
}).catch((error: unknown) => {
|
|
490
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
491
|
+
failInspectRequest(requestId, message)
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
return jsonResponse({ ok: true, requestId, deckVersion: version, status: "pending", preprocess })
|
|
495
|
+
} catch (error) {
|
|
496
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
497
|
+
failInspectRequest(requestId, message)
|
|
498
|
+
return jsonResponse({ ok: false, requestId, deckVersion: version, error: message }, 400)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function handleInspectResult(requestId: string | null, session: EditSession): Response {
|
|
503
|
+
if (!requestId) return jsonResponse({ ok: false, error: "Missing requestId" }, 400)
|
|
504
|
+
const request = getInspectRequest(requestId)
|
|
505
|
+
if (!request) return jsonResponse({ ok: false, requestId, error: "Inspection request not found" }, 404)
|
|
506
|
+
session.lastActiveAt = Date.now()
|
|
507
|
+
scheduleIdleStop()
|
|
508
|
+
if (request.status === "completed") return jsonResponse({ ok: true, requestId, status: request.status, deckVersion: request.deckVersion, result: request.result })
|
|
509
|
+
if (request.status === "failed" || request.status === "expired") return jsonResponse({ ok: true, requestId, status: request.status, deckVersion: request.deckVersion, error: request.error || "Inspection failed" })
|
|
510
|
+
return jsonResponse({ ok: true, requestId, status: request.status, deckVersion: request.deckVersion })
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function normalizeSnapshot(input: any): InspectionElementSnapshot {
|
|
514
|
+
return {
|
|
515
|
+
scope: input?.scope === "selection" || input?.scope === "slide" || input?.scope === "element" ? input.scope : undefined,
|
|
516
|
+
slideIndex: typeof input?.slideIndex === "number" ? input.slideIndex : undefined,
|
|
517
|
+
text: typeof input?.text === "string" ? input.text : undefined,
|
|
518
|
+
selectedText: typeof input?.selectedText === "string" ? input.selectedText : undefined,
|
|
519
|
+
tagName: typeof input?.tagName === "string" ? input.tagName : undefined,
|
|
520
|
+
slideTitle: typeof input?.slideTitle === "string" ? input.slideTitle : undefined,
|
|
521
|
+
selector: typeof input?.selector === "string" ? input.selector : undefined,
|
|
522
|
+
domPath: typeof input?.domPath === "string" ? input.domPath : undefined,
|
|
523
|
+
id: typeof input?.id === "string" ? input.id : undefined,
|
|
524
|
+
classList: Array.isArray(input?.classList) ? input.classList.filter((item: unknown) => typeof item === "string") : [],
|
|
525
|
+
role: typeof input?.role === "string" ? input.role : undefined,
|
|
526
|
+
outerHTMLExcerpt: typeof input?.outerHTMLExcerpt === "string" ? input.outerHTMLExcerpt : undefined,
|
|
527
|
+
nearbyText: typeof input?.nearbyText === "string" ? input.nearbyText : undefined,
|
|
528
|
+
elements: Array.isArray(input?.elements) ? input.elements.map((item: any) => ({
|
|
529
|
+
text: typeof item?.text === "string" ? item.text : undefined,
|
|
530
|
+
tagName: typeof item?.tagName === "string" ? item.tagName : undefined,
|
|
531
|
+
slideIndex: typeof item?.slideIndex === "number" ? item.slideIndex : undefined,
|
|
532
|
+
slideTitle: typeof item?.slideTitle === "string" ? item.slideTitle : undefined,
|
|
533
|
+
selector: typeof item?.selector === "string" ? item.selector : undefined,
|
|
534
|
+
domPath: typeof item?.domPath === "string" ? item.domPath : undefined,
|
|
535
|
+
id: typeof item?.id === "string" ? item.id : undefined,
|
|
536
|
+
classList: Array.isArray(item?.classList) ? item.classList.filter((className: unknown) => typeof className === "string") : [],
|
|
537
|
+
role: typeof item?.role === "string" ? item.role : undefined,
|
|
538
|
+
outerHTMLExcerpt: typeof item?.outerHTMLExcerpt === "string" ? item.outerHTMLExcerpt : undefined,
|
|
539
|
+
nearbyText: typeof item?.nearbyText === "string" ? item.nearbyText : undefined,
|
|
540
|
+
boundingBox: item?.boundingBox && typeof item.boundingBox === "object" ? item.boundingBox : undefined,
|
|
541
|
+
viewport: item?.viewport && typeof item.viewport === "object" ? item.viewport : undefined,
|
|
542
|
+
})) : undefined,
|
|
543
|
+
boundingBox: input?.boundingBox && typeof input.boundingBox === "object" ? input.boundingBox : undefined,
|
|
544
|
+
viewport: input?.viewport && typeof input.viewport === "object" ? input.viewport : undefined,
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function validateSession(token: string | null): { ok: true; value: EditSession } | { ok: false; response: Response } {
|
|
549
|
+
if (!token) return { ok: false, response: textResponse("Missing token", 401) }
|
|
550
|
+
const session = sessions.get(token)
|
|
551
|
+
if (!session) return { ok: false, response: textResponse("Invalid or expired token", 401) }
|
|
552
|
+
if (Date.now() - session.lastActiveAt > SESSION_TTL_MS) {
|
|
553
|
+
sessions.delete(token)
|
|
554
|
+
return { ok: false, response: textResponse("Expired token", 401) }
|
|
555
|
+
}
|
|
556
|
+
session.lastActiveAt = Date.now()
|
|
557
|
+
return { ok: true, value: session }
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function cleanupExpiredSessions(): void {
|
|
561
|
+
const now = Date.now()
|
|
562
|
+
for (const [token, session] of sessions) {
|
|
563
|
+
if (now - session.lastActiveAt > SESSION_TTL_MS) sessions.delete(token)
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function scheduleIdleStop(): void {
|
|
568
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
569
|
+
idleTimer = setTimeout(() => {
|
|
570
|
+
const now = Date.now()
|
|
571
|
+
const active = [...sessions.values()].some((session) => now - session.lastActiveAt < IDLE_STOP_MS)
|
|
572
|
+
if (active) {
|
|
573
|
+
scheduleIdleStop()
|
|
574
|
+
return
|
|
575
|
+
}
|
|
576
|
+
sessions.clear()
|
|
577
|
+
server?.stop()
|
|
578
|
+
server = undefined
|
|
579
|
+
baseUrl = ""
|
|
580
|
+
idleTimer = undefined
|
|
581
|
+
}, IDLE_STOP_MS)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function htmlResponse(body: string, status = 200): Response {
|
|
585
|
+
return new Response(body, {
|
|
586
|
+
status,
|
|
587
|
+
headers: {
|
|
588
|
+
"content-type": "text/html; charset=utf-8",
|
|
589
|
+
"cache-control": "no-store, max-age=0",
|
|
590
|
+
},
|
|
591
|
+
})
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function textResponse(body: string, status = 200): Response {
|
|
595
|
+
return new Response(body, {
|
|
596
|
+
status,
|
|
597
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
598
|
+
})
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
602
|
+
return new Response(JSON.stringify(body), {
|
|
603
|
+
status,
|
|
604
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
605
|
+
})
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export function renderRefineShell(token: string, defaultMode: RefineMode = "edit"): string {
|
|
609
|
+
const encodedToken = JSON.stringify(token)
|
|
610
|
+
const encodedDefaultMode = JSON.stringify(defaultMode)
|
|
611
|
+
return `<!doctype html>
|
|
612
|
+
<html lang="en">
|
|
613
|
+
<head>
|
|
614
|
+
<meta charset="utf-8" />
|
|
615
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
616
|
+
<title>Revela Refine</title>
|
|
617
|
+
<style>
|
|
618
|
+
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
619
|
+
* { box-sizing: border-box; }
|
|
620
|
+
body { margin: 0; background: #f6f8fb; color: #172033; height: 100vh; overflow: hidden; }
|
|
621
|
+
body.resizing { cursor: col-resize; user-select: none; }
|
|
622
|
+
body.resizing iframe, body.resizing .hitbox { pointer-events: none; }
|
|
623
|
+
.app { --editor-width: 376px; position: relative; display: grid; grid-template-columns: minmax(0, 1fr) var(--editor-width); height: 100vh; }
|
|
624
|
+
.preview { position: relative; min-width: 0; background: #eef3f8; }
|
|
625
|
+
.resize-handle { position: absolute; top: 0; bottom: 0; right: calc(var(--editor-width) - 7px); width: 14px; z-index: 5; cursor: col-resize; background: transparent; }
|
|
626
|
+
.resize-handle::before { content: ""; position: absolute; left: 50%; top: 50%; width: 4px; height: 44px; border-radius: 999px; transform: translate(-50%, -50%); background: rgba(148,163,184,.34); box-shadow: 0 1px 2px rgba(15,23,42,.06); transition: background .16s ease, height .16s ease, box-shadow .16s ease; }
|
|
627
|
+
.resize-handle:hover::before, body.resizing .resize-handle::before { height: 52px; background: #94a3b8; box-shadow: 0 0 0 4px rgba(148,163,184,.16); }
|
|
628
|
+
iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
|
|
629
|
+
.hitbox { position: absolute; inset: 0; z-index: 2; cursor: crosshair; background: transparent; }
|
|
630
|
+
aside { display: flex; flex-direction: column; gap: 16px; padding: 20px; background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); overflow: auto; }
|
|
631
|
+
h1 { margin: 0; font-size: 18px; line-height: 1.2; letter-spacing: -.01em; color: #0f172a; }
|
|
632
|
+
.wordmark { font-family: Garamond, "Iowan Old Style", Georgia, serif; font-size: 21px; letter-spacing: .08em; font-weight: 600; }
|
|
633
|
+
.hint { margin: 0; color: #64748b; font-size: 13px; line-height: 1.5; }
|
|
634
|
+
.panel { display: flex; flex-direction: column; gap: 10px; }
|
|
635
|
+
.tabs { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; padding: 4px; border: 1px solid #dbe4ee; border-radius: 14px; background: #f1f5f9; }
|
|
636
|
+
.tab { padding: 9px 10px; border: 0; border-radius: 10px; background: transparent; color: #475569; box-shadow: none; font-weight: 900; }
|
|
637
|
+
.tab.active { background: #ffffff; color: #0f172a; box-shadow: 0 6px 18px rgba(15,23,42,.08); }
|
|
638
|
+
.tab-panel { display: none; flex-direction: column; gap: 12px; }
|
|
639
|
+
.tab-panel.active { display: flex; }
|
|
640
|
+
.selection-summary { padding: 10px 12px; border: 1px solid #d7e0ea; border-radius: 14px; background: #fff; color: #334155; font-size: 13px; line-height: 1.45; box-shadow: 0 8px 24px rgba(15,23,42,.05); }
|
|
641
|
+
.selection-summary strong { display: block; margin-bottom: 7px; color: #64748b; font-size: 11px; letter-spacing: .09em; text-transform: uppercase; }
|
|
642
|
+
.selection-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
643
|
+
.label { color: #64748b; font-size: 11px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
|
|
644
|
+
.comment-editor { width: 100%; min-height: 164px; max-height: 42vh; overflow: auto; padding: 13px 14px; border: 1px solid #d7e0ea; border-radius: 14px; background: #ffffff; color: #0f172a; font: inherit; line-height: 1.5; outline: none; white-space: pre-wrap; box-shadow: 0 10px 28px rgba(15,23,42,.06); }
|
|
645
|
+
.comment-editor:focus { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37,99,235,.12), 0 10px 28px rgba(15,23,42,.06); }
|
|
646
|
+
.comment-editor:empty::before { content: attr(data-placeholder); color: #94a3b8; pointer-events: none; }
|
|
647
|
+
.ref-chip { display: inline-flex; align-items: center; margin: 0 2px; padding: 1px 7px; border-radius: 999px; background: var(--ref-bg, #e0f2fe); color: var(--ref-text, #075985); border: 1px solid var(--ref-border, #7dd3fc); font-weight: 800; white-space: nowrap; }
|
|
648
|
+
.comment-thread { display: flex; flex-direction: column; gap: 10px; max-height: 30vh; overflow: auto; }
|
|
649
|
+
.comment-bubble { border: 1px solid #dbe4ee; border-radius: 14px; padding: 10px 12px; background: #ffffff; color: #334155; font-size: 13px; line-height: 1.45; box-shadow: 0 8px 24px rgba(15,23,42,.05); }
|
|
650
|
+
.comment-bubble.sending { border-color: #93c5fd; background: #eff6ff; }
|
|
651
|
+
.comment-bubble.updated { border-color: #86efac; background: #f0fdf4; }
|
|
652
|
+
.comment-bubble.stale { border-color: #facc15; background: #fefce8; }
|
|
653
|
+
.comment-bubble.failed { border-color: #fca5a5; background: #fef2f2; }
|
|
654
|
+
.comment-bubble-text { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
655
|
+
.comment-bubble-state { margin-top: 8px; color: #2563eb; font-size: 12px; font-weight: 800; }
|
|
656
|
+
.comment-bubble.updated .comment-bubble-state { color: #15803d; }
|
|
657
|
+
.comment-bubble.stale .comment-bubble-state { color: #a16207; }
|
|
658
|
+
.comment-bubble.failed .comment-bubble-state { color: #b91c1c; }
|
|
659
|
+
.inspect-actions { display: flex; flex-direction: column; gap: 8px; }
|
|
660
|
+
.inspect-cards { display: flex; flex-direction: column; gap: 12px; }
|
|
661
|
+
.inspect-card { border: 1px solid #d7e0ea; border-radius: 16px; background: #fff; padding: 13px; box-shadow: 0 10px 24px rgba(15,23,42,.05); }
|
|
662
|
+
.inspect-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
|
|
663
|
+
.inspect-card h2 { margin: 0; font-size: 13px; color: #0f172a; }
|
|
664
|
+
.badge { border-radius: 999px; padding: 3px 8px; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; background: #e2e8f0; color: #475569; }
|
|
665
|
+
.badge.supported, .badge.found, .badge.present, .badge.known, .badge.suggested { background: #dcfce7; color: #166534; }
|
|
666
|
+
.badge.weak, .badge.missing { background: #fef3c7; color: #92400e; }
|
|
667
|
+
.badge.unsupported { background: #fee2e2; color: #991b1b; }
|
|
668
|
+
.inspect-card p, .inspect-empty, .inspect-loading { margin: 0; color: #475569; font-size: 12px; line-height: 1.5; }
|
|
669
|
+
.inspect-item { margin-top: 7px; padding: 8px; border-radius: 10px; background: #f8fafc; color: #334155; font-size: 12px; line-height: 1.45; }
|
|
670
|
+
.inspect-warning, .inspect-stale { margin-top: 8px; padding: 8px; border-radius: 10px; background: #fff7ed; color: #9a3412; font-size: 12px; line-height: 1.45; }
|
|
671
|
+
button { width: 100%; padding: 12px 14px; border: 0; border-radius: 12px; background: #2563eb; color: #ffffff; font-weight: 800; cursor: pointer; box-shadow: 0 10px 24px rgba(37,99,235,.22); }
|
|
672
|
+
button:disabled { cursor: not-allowed; opacity: .5; }
|
|
673
|
+
.status { min-height: 20px; color: #475569; font-size: 13px; line-height: 1.45; }
|
|
674
|
+
@media (max-width: 900px) { .app { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; } .resize-handle { display: none; } aside { max-height: 48vh; } }
|
|
675
|
+
</style>
|
|
676
|
+
</head>
|
|
677
|
+
<body>
|
|
678
|
+
<main class="app">
|
|
679
|
+
<section class="preview"><iframe id="deck" src="/deck?token=${encodeURIComponent(token)}"></iframe><div id="hitbox" class="hitbox" aria-label="Deck element selection layer"></div></section>
|
|
680
|
+
<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>
|
|
681
|
+
<aside>
|
|
682
|
+
<div>
|
|
683
|
+
<h1><span class="wordmark">REVELA</span> Refine</h1>
|
|
684
|
+
<p class="hint">Cmd/Ctrl-click slide elements once, then use Edit for fast changes or Inspect for Source/Purpose review.</p>
|
|
685
|
+
</div>
|
|
686
|
+
<div id="selectionSummary" class="selection-summary"><strong>Selection</strong><span>No references selected.</span><div id="selectionChips" class="selection-chips"></div></div>
|
|
687
|
+
<div class="tabs" role="tablist" aria-label="Refine mode">
|
|
688
|
+
<button id="editTab" class="tab" type="button" role="tab">Edit</button>
|
|
689
|
+
<button id="inspectTab" class="tab" type="button" role="tab">Inspect</button>
|
|
690
|
+
</div>
|
|
691
|
+
<div id="editPanel" class="tab-panel">
|
|
692
|
+
<div class="panel">
|
|
693
|
+
<div class="label">Comment</div>
|
|
694
|
+
<div id="comment" class="comment-editor" contenteditable="true" role="textbox" aria-multiline="true" data-placeholder="Example: Cmd/Ctrl-click to ref the chart title, then ask to make it shorter and align it with the KPI row."></div>
|
|
695
|
+
</div>
|
|
696
|
+
<div id="commentThread" class="comment-thread" aria-live="polite"></div>
|
|
697
|
+
<button id="send" disabled>Send Edit</button>
|
|
698
|
+
</div>
|
|
699
|
+
<div id="inspectPanel" class="tab-panel">
|
|
700
|
+
<div class="inspect-actions">
|
|
701
|
+
<button id="inspectButton" disabled>Inspect Selection</button>
|
|
702
|
+
<div id="inspectStale"></div>
|
|
703
|
+
</div>
|
|
704
|
+
<div id="inspectCards" class="inspect-cards"><div class="inspect-empty">Select one or more deck elements, then inspect them for Source and Purpose. This does not edit the deck.</div></div>
|
|
705
|
+
</div>
|
|
706
|
+
<div id="status" class="status"></div>
|
|
707
|
+
</aside>
|
|
708
|
+
</main>
|
|
709
|
+
<script>
|
|
710
|
+
(() => {
|
|
711
|
+
const token = ${encodedToken};
|
|
712
|
+
const defaultMode = ${encodedDefaultMode};
|
|
713
|
+
const COMMENT_STALE_MS = 60000;
|
|
714
|
+
const EDITOR_WIDTH_KEY = 'revela-edit-editor-width';
|
|
715
|
+
const DEFAULT_EDITOR_WIDTH = 376;
|
|
716
|
+
const MIN_EDITOR_WIDTH = 320;
|
|
717
|
+
const MAX_EDITOR_WIDTH = 620;
|
|
718
|
+
const REFERENCE_COLORS = [
|
|
719
|
+
{ border: '#7aa6d8', fill: 'rgba(122,166,216,.18)', bg: '#eaf2fb', text: '#244f78' },
|
|
720
|
+
{ border: '#a99bd9', fill: 'rgba(169,155,217,.18)', bg: '#f1eefb', text: '#574985' },
|
|
721
|
+
{ border: '#83b99a', fill: 'rgba(131,185,154,.18)', bg: '#edf7f1', text: '#2f6848' },
|
|
722
|
+
{ border: '#d7a775', fill: 'rgba(215,167,117,.18)', bg: '#fbf1e7', text: '#7a4d22' },
|
|
723
|
+
{ border: '#d493b0', fill: 'rgba(212,147,176,.18)', bg: '#faedf3', text: '#7b3f5b' },
|
|
724
|
+
{ border: '#73b8bd', fill: 'rgba(115,184,189,.18)', bg: '#e8f6f7', text: '#285f64' },
|
|
725
|
+
{ border: '#c7b46e', fill: 'rgba(199,180,110,.18)', bg: '#f8f3df', text: '#6b5b1e' },
|
|
726
|
+
{ border: '#9eb27e', fill: 'rgba(158,178,126,.18)', bg: '#f1f6e9', text: '#4f642e' },
|
|
727
|
+
{ border: '#c08fc8', fill: 'rgba(192,143,200,.18)', bg: '#f7edf8', text: '#6b3f73' },
|
|
728
|
+
{ border: '#8fa7c9', fill: 'rgba(143,167,201,.18)', bg: '#eef3fa', text: '#405a7b' },
|
|
729
|
+
];
|
|
730
|
+
const state = {
|
|
731
|
+
references: [],
|
|
732
|
+
pendingComments: [],
|
|
733
|
+
hoverEl: null,
|
|
734
|
+
hoverOutline: null,
|
|
735
|
+
referenceOutlines: [],
|
|
736
|
+
nextReferenceId: 1,
|
|
737
|
+
nextCommentId: 1,
|
|
738
|
+
initializedDoc: null,
|
|
739
|
+
deckVersion: null,
|
|
740
|
+
pendingRefreshMessage: false,
|
|
741
|
+
bound: false,
|
|
742
|
+
commentRange: null,
|
|
743
|
+
resizeDrag: null,
|
|
744
|
+
mode: defaultMode === 'inspect' ? 'inspect' : 'edit',
|
|
745
|
+
inspecting: false,
|
|
746
|
+
activeInspectRequestId: '',
|
|
747
|
+
};
|
|
748
|
+
const els = {
|
|
749
|
+
frame: null,
|
|
750
|
+
hitbox: null,
|
|
751
|
+
resizeHandle: null,
|
|
752
|
+
selectionSummary: null,
|
|
753
|
+
selectionChips: null,
|
|
754
|
+
editTab: null,
|
|
755
|
+
inspectTab: null,
|
|
756
|
+
editPanel: null,
|
|
757
|
+
inspectPanel: null,
|
|
758
|
+
comment: null,
|
|
759
|
+
commentThread: null,
|
|
760
|
+
send: null,
|
|
761
|
+
inspectButton: null,
|
|
762
|
+
inspectCards: null,
|
|
763
|
+
inspectStale: null,
|
|
764
|
+
status: null,
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
window.addEventListener('error', (event) => reportError(event.error || event.message));
|
|
768
|
+
window.addEventListener('unhandledrejection', (event) => reportError(event.reason));
|
|
769
|
+
|
|
770
|
+
if (document.readyState === 'loading') {
|
|
771
|
+
document.addEventListener('DOMContentLoaded', boot, { once: true });
|
|
772
|
+
} else {
|
|
773
|
+
boot();
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function boot() {
|
|
777
|
+
try {
|
|
778
|
+
els.frame = document.getElementById('deck');
|
|
779
|
+
els.hitbox = document.getElementById('hitbox');
|
|
780
|
+
els.resizeHandle = document.getElementById('resizeHandle');
|
|
781
|
+
els.selectionSummary = document.getElementById('selectionSummary');
|
|
782
|
+
els.selectionChips = document.getElementById('selectionChips');
|
|
783
|
+
els.editTab = document.getElementById('editTab');
|
|
784
|
+
els.inspectTab = document.getElementById('inspectTab');
|
|
785
|
+
els.editPanel = document.getElementById('editPanel');
|
|
786
|
+
els.inspectPanel = document.getElementById('inspectPanel');
|
|
787
|
+
els.comment = document.getElementById('comment');
|
|
788
|
+
els.commentThread = document.getElementById('commentThread');
|
|
789
|
+
els.send = document.getElementById('send');
|
|
790
|
+
els.inspectButton = document.getElementById('inspectButton');
|
|
791
|
+
els.inspectCards = document.getElementById('inspectCards');
|
|
792
|
+
els.inspectStale = document.getElementById('inspectStale');
|
|
793
|
+
els.status = document.getElementById('status');
|
|
794
|
+
|
|
795
|
+
if (!els.frame || !els.hitbox || !els.resizeHandle || !els.selectionSummary || !els.selectionChips || !els.editTab || !els.inspectTab || !els.editPanel || !els.inspectPanel || !els.comment || !els.commentThread || !els.send || !els.inspectButton || !els.inspectCards || !els.inspectStale || !els.status) {
|
|
796
|
+
throw new Error('Editor boot failed: required DOM nodes are missing.');
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
restoreEditorWidth();
|
|
800
|
+
bindEvents();
|
|
801
|
+
setMode(state.mode);
|
|
802
|
+
setStatus('Editor ready. Ctrl/Cmd + click deck elements to reference them.');
|
|
803
|
+
initFrame();
|
|
804
|
+
startDeckVersionPolling();
|
|
805
|
+
} catch (error) {
|
|
806
|
+
reportError(error);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function bindEvents() {
|
|
811
|
+
if (state.bound) return;
|
|
812
|
+
state.bound = true;
|
|
813
|
+
els.frame.addEventListener('load', initFrame);
|
|
814
|
+
document.addEventListener('keydown', (event) => {
|
|
815
|
+
if (event.key === 'Escape') clearHover();
|
|
816
|
+
});
|
|
817
|
+
els.comment.addEventListener('input', () => {
|
|
818
|
+
saveCommentRange();
|
|
819
|
+
syncReferencesFromComment(false);
|
|
820
|
+
updateSendState();
|
|
821
|
+
});
|
|
822
|
+
els.comment.addEventListener('keyup', saveCommentRange);
|
|
823
|
+
els.comment.addEventListener('mouseup', saveCommentRange);
|
|
824
|
+
document.addEventListener('selectionchange', saveCommentRange);
|
|
825
|
+
els.hitbox.addEventListener('pointermove', onHover);
|
|
826
|
+
els.hitbox.addEventListener('pointerdown', onPointerDown);
|
|
827
|
+
els.hitbox.addEventListener('click', onClick);
|
|
828
|
+
els.hitbox.addEventListener('contextmenu', (event) => {
|
|
829
|
+
if (event.ctrlKey || event.metaKey) event.preventDefault();
|
|
830
|
+
});
|
|
831
|
+
els.hitbox.addEventListener('wheel', (event) => {
|
|
832
|
+
const win = els.frame.contentWindow;
|
|
833
|
+
if (!win) return;
|
|
834
|
+
event.preventDefault();
|
|
835
|
+
win.scrollBy({ top: event.deltaY, left: event.deltaX, behavior: 'auto' });
|
|
836
|
+
renderHoverOutline(state.hoverEl);
|
|
837
|
+
renderReferenceOutlines();
|
|
838
|
+
}, { passive: false });
|
|
839
|
+
els.resizeHandle.addEventListener('pointerdown', startEditorResize);
|
|
840
|
+
els.resizeHandle.addEventListener('dblclick', resetEditorWidth);
|
|
841
|
+
els.send.addEventListener('click', sendComment);
|
|
842
|
+
els.inspectButton.addEventListener('click', inspectCurrentSelection);
|
|
843
|
+
els.editTab.addEventListener('click', () => setMode('edit'));
|
|
844
|
+
els.inspectTab.addEventListener('click', () => setMode('inspect'));
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function setMode(mode) {
|
|
848
|
+
state.mode = mode === 'inspect' ? 'inspect' : 'edit';
|
|
849
|
+
els.editTab.classList.toggle('active', state.mode === 'edit');
|
|
850
|
+
els.inspectTab.classList.toggle('active', state.mode === 'inspect');
|
|
851
|
+
els.editPanel.classList.toggle('active', state.mode === 'edit');
|
|
852
|
+
els.inspectPanel.classList.toggle('active', state.mode === 'inspect');
|
|
853
|
+
updateSendState();
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function restoreEditorWidth() {
|
|
857
|
+
try {
|
|
858
|
+
const saved = Number(window.localStorage.getItem(EDITOR_WIDTH_KEY));
|
|
859
|
+
setEditorWidth(Number.isFinite(saved) ? saved : DEFAULT_EDITOR_WIDTH, false);
|
|
860
|
+
} catch {
|
|
861
|
+
setEditorWidth(DEFAULT_EDITOR_WIDTH, false);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function startEditorResize(event) {
|
|
866
|
+
event.preventDefault();
|
|
867
|
+
const currentWidth = Number.parseFloat(getComputedStyle(document.querySelector('.app')).getPropertyValue('--editor-width')) || DEFAULT_EDITOR_WIDTH;
|
|
868
|
+
state.resizeDrag = { startX: event.clientX, startWidth: currentWidth };
|
|
869
|
+
document.body.classList.add('resizing');
|
|
870
|
+
els.resizeHandle.setPointerCapture?.(event.pointerId);
|
|
871
|
+
window.addEventListener('pointermove', resizeEditor);
|
|
872
|
+
window.addEventListener('pointerup', stopEditorResize, { once: true });
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function resizeEditor(event) {
|
|
876
|
+
if (!state.resizeDrag) return;
|
|
877
|
+
const nextWidth = state.resizeDrag.startWidth + state.resizeDrag.startX - event.clientX;
|
|
878
|
+
setEditorWidth(nextWidth, true);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function stopEditorResize() {
|
|
882
|
+
state.resizeDrag = null;
|
|
883
|
+
document.body.classList.remove('resizing');
|
|
884
|
+
window.removeEventListener('pointermove', resizeEditor);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function resetEditorWidth() {
|
|
888
|
+
setEditorWidth(DEFAULT_EDITOR_WIDTH, true);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function setEditorWidth(width, persist) {
|
|
892
|
+
const nextWidth = clampEditorWidth(width);
|
|
893
|
+
document.querySelector('.app')?.style.setProperty('--editor-width', nextWidth + 'px');
|
|
894
|
+
if (!persist) return;
|
|
895
|
+
try {
|
|
896
|
+
window.localStorage.setItem(EDITOR_WIDTH_KEY, String(nextWidth));
|
|
897
|
+
} catch {}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function clampEditorWidth(width) {
|
|
901
|
+
return Math.min(MAX_EDITOR_WIDTH, Math.max(MIN_EDITOR_WIDTH, Math.round(width)));
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function initFrame() {
|
|
905
|
+
try {
|
|
906
|
+
const doc = els.frame.contentDocument;
|
|
907
|
+
if (!doc) {
|
|
908
|
+
setStatus('Unable to access deck iframe.');
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
if (doc === state.initializedDoc) return;
|
|
912
|
+
if (doc.location.href === 'about:blank') return;
|
|
913
|
+
if (doc.readyState === 'loading') return;
|
|
914
|
+
state.initializedDoc = doc;
|
|
915
|
+
clearReferences(false);
|
|
916
|
+
state.hoverEl = null;
|
|
917
|
+
state.hoverOutline = createOutline(doc, '#38bdf8', 'rgba(56,189,248,.12)');
|
|
918
|
+
state.referenceOutlines = [];
|
|
919
|
+
doc.addEventListener('scroll', () => {
|
|
920
|
+
renderHoverOutline(state.hoverEl);
|
|
921
|
+
renderReferenceOutlines();
|
|
922
|
+
}, true);
|
|
923
|
+
const slides = getSlides(doc);
|
|
924
|
+
updateSendState();
|
|
925
|
+
if (state.pendingRefreshMessage) {
|
|
926
|
+
state.pendingRefreshMessage = false;
|
|
927
|
+
setStatus('Deck updated. Preview refreshed. Element references were cleared.');
|
|
928
|
+
} else {
|
|
929
|
+
setStatus(slides.length > 0 ? 'Editor ready. Found ' + slides.length + ' slides. Ctrl/Cmd + click to reference elements.' : 'Editor ready, but no .slide elements were found. Ctrl/Cmd + click to reference elements.');
|
|
930
|
+
}
|
|
931
|
+
} catch (error) {
|
|
932
|
+
reportError(error);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function startDeckVersionPolling() {
|
|
937
|
+
pollDeckVersion();
|
|
938
|
+
window.setInterval(pollDeckVersion, 2000);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async function pollDeckVersion() {
|
|
942
|
+
try {
|
|
943
|
+
const res = await fetch('/api/deck-version?token=' + encodeURIComponent(token), { cache: 'no-store' });
|
|
944
|
+
const body = await res.json().catch(() => ({}));
|
|
945
|
+
if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to check deck version');
|
|
946
|
+
const nextVersion = body.version || (String(body.mtimeMs) + ':' + String(body.size));
|
|
947
|
+
if (!state.deckVersion) {
|
|
948
|
+
state.deckVersion = nextVersion;
|
|
949
|
+
markCommentsUpdatedForVersion(nextVersion);
|
|
950
|
+
markStaleComments();
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
if (state.deckVersion === nextVersion) {
|
|
954
|
+
markStaleComments();
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
state.deckVersion = nextVersion;
|
|
958
|
+
markCommentsUpdatedForVersion(nextVersion);
|
|
959
|
+
refreshDeckPreview(body.mtimeMs);
|
|
960
|
+
} catch (error) {
|
|
961
|
+
reportError(error);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function refreshDeckPreview(version) {
|
|
966
|
+
state.pendingRefreshMessage = true;
|
|
967
|
+
state.initializedDoc = null;
|
|
968
|
+
clearReferences(true);
|
|
969
|
+
state.hoverEl = null;
|
|
970
|
+
if (state.hoverOutline) state.hoverOutline.style.display = 'none';
|
|
971
|
+
state.referenceOutlines.forEach((outline) => outline.style.display = 'none');
|
|
972
|
+
state.referenceOutlines = [];
|
|
973
|
+
updateSendState();
|
|
974
|
+
els.frame.src = '/deck?token=' + encodeURIComponent(token) + '&v=' + encodeURIComponent(String(version));
|
|
975
|
+
setStatus('Deck changed. Refreshing preview...');
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function onHover(event) {
|
|
979
|
+
try {
|
|
980
|
+
initFrame();
|
|
981
|
+
const target = selectable(targetFromPointer(event));
|
|
982
|
+
if (!target || isReferenced(target)) {
|
|
983
|
+
renderHoverOutline(null);
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
state.hoverEl = target;
|
|
987
|
+
renderHoverOutline(target);
|
|
988
|
+
} catch (error) {
|
|
989
|
+
reportError(error);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function onClick(event) {
|
|
994
|
+
try {
|
|
995
|
+
initFrame();
|
|
996
|
+
const target = selectable(targetFromPointer(event));
|
|
997
|
+
if (event.ctrlKey || event.metaKey) {
|
|
998
|
+
event.preventDefault();
|
|
999
|
+
event.stopPropagation();
|
|
1000
|
+
return;
|
|
1001
|
+
} else if (target) {
|
|
1002
|
+
setStatus('Use Ctrl/Cmd + click to reference this element in your comment.');
|
|
1003
|
+
}
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
reportError(error);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function onPointerDown(event) {
|
|
1010
|
+
if (!event.ctrlKey && !event.metaKey) return;
|
|
1011
|
+
try {
|
|
1012
|
+
initFrame();
|
|
1013
|
+
event.preventDefault();
|
|
1014
|
+
event.stopPropagation();
|
|
1015
|
+
toggleReference(selectable(targetFromPointer(event)));
|
|
1016
|
+
} catch (error) {
|
|
1017
|
+
reportError(error);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
async function sendComment() {
|
|
1022
|
+
syncReferencesFromComment(false);
|
|
1023
|
+
const text = getCommentText().trim();
|
|
1024
|
+
if (!text) return;
|
|
1025
|
+
const elements = state.references.map((reference) => reference.payload);
|
|
1026
|
+
const commentId = addPendingComment(text, elements, 'sending');
|
|
1027
|
+
clearReferences(false);
|
|
1028
|
+
els.comment.textContent = '';
|
|
1029
|
+
renderReferenceOutlines();
|
|
1030
|
+
els.send.disabled = true;
|
|
1031
|
+
setStatus('Sending...');
|
|
1032
|
+
try {
|
|
1033
|
+
const res = await fetch('/api/comment?token=' + encodeURIComponent(token), {
|
|
1034
|
+
method: 'POST',
|
|
1035
|
+
headers: { 'content-type': 'application/json' },
|
|
1036
|
+
body: JSON.stringify({ comment: text, elements }),
|
|
1037
|
+
});
|
|
1038
|
+
const body = await res.json().catch(() => ({}));
|
|
1039
|
+
if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to send comment');
|
|
1040
|
+
updatePendingCommentStatus(commentId, 'sent', { baseDeckVersion: body.deckVersion || state.deckVersion });
|
|
1041
|
+
if (pendingCommentStatus(commentId) !== 'updated') setStatus('Comment sent. Waiting for deck update...');
|
|
1042
|
+
updateSendState();
|
|
1043
|
+
} catch (error) {
|
|
1044
|
+
updatePendingCommentStatus(commentId, 'failed');
|
|
1045
|
+
reportError(error);
|
|
1046
|
+
updateSendState();
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function selectable(node) {
|
|
1051
|
+
if (!node || node.nodeType !== 1) return null;
|
|
1052
|
+
if (node === state.hoverOutline || state.referenceOutlines.includes(node)) return null;
|
|
1053
|
+
return findSlide(node) ? node : null;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function toggleReference(target) {
|
|
1057
|
+
if (!target) {
|
|
1058
|
+
setStatus('No selectable deck element found under pointer.');
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
const existing = findReferenceIndex(target);
|
|
1062
|
+
if (existing >= 0) {
|
|
1063
|
+
const label = state.references[existing].label;
|
|
1064
|
+
removeReferenceAt(existing, true);
|
|
1065
|
+
setStatus('Removed @' + label + '.');
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
const payload = collectPayload(target);
|
|
1069
|
+
const color = REFERENCE_COLORS[(state.nextReferenceId - 1) % REFERENCE_COLORS.length];
|
|
1070
|
+
const id = 'ref-' + state.nextReferenceId++;
|
|
1071
|
+
const label = nextReferenceLabel(payload);
|
|
1072
|
+
const reference = { id, target, label, payload, color };
|
|
1073
|
+
state.references.push(reference);
|
|
1074
|
+
insertReferenceChip(reference);
|
|
1075
|
+
renderReferenceOutlines();
|
|
1076
|
+
updateSendState();
|
|
1077
|
+
renderSelectionSummary();
|
|
1078
|
+
resetInspectCards('References ready. Open Inspect and click Inspect Selection when you want Source/Purpose review.');
|
|
1079
|
+
setStatus('Inserted @' + label + '. ' + state.references.length + ' reference' + (state.references.length === 1 ? '' : 's') + ' will be sent.');
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function isReferenced(target) {
|
|
1083
|
+
return findReferenceIndex(target) >= 0;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function findReferenceIndex(target) {
|
|
1087
|
+
return state.references.findIndex((reference) => reference.target === target);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function removeReferenceAt(index, removeToken) {
|
|
1091
|
+
const reference = state.references[index];
|
|
1092
|
+
if (!reference) return;
|
|
1093
|
+
state.references.splice(index, 1);
|
|
1094
|
+
if (removeToken) removeReferenceChip(reference.id);
|
|
1095
|
+
renderReferenceOutlines();
|
|
1096
|
+
renderSelectionSummary();
|
|
1097
|
+
updateSendState();
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function syncReferencesFromComment(showStatus) {
|
|
1101
|
+
const activeIds = new Set(Array.from(els.comment.querySelectorAll('.ref-chip[data-ref-id]')).map((chip) => chip.getAttribute('data-ref-id')));
|
|
1102
|
+
const before = state.references.length;
|
|
1103
|
+
state.references = state.references.filter((reference) => activeIds.has(reference.id));
|
|
1104
|
+
if (state.references.length !== before) {
|
|
1105
|
+
renderReferenceOutlines();
|
|
1106
|
+
renderSelectionSummary();
|
|
1107
|
+
if (showStatus) setStatus('References synced with comment text.');
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function addPendingComment(text, elements, status) {
|
|
1112
|
+
const id = 'comment-' + state.nextCommentId++;
|
|
1113
|
+
state.pendingComments.push({
|
|
1114
|
+
id,
|
|
1115
|
+
text,
|
|
1116
|
+
elements,
|
|
1117
|
+
status,
|
|
1118
|
+
createdAt: Date.now(),
|
|
1119
|
+
baseDeckVersion: state.deckVersion,
|
|
1120
|
+
updatedVersion: null,
|
|
1121
|
+
});
|
|
1122
|
+
renderCommentThread();
|
|
1123
|
+
return id;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function updatePendingCommentStatus(id, status, updates) {
|
|
1127
|
+
const comment = state.pendingComments.find((item) => item.id === id);
|
|
1128
|
+
if (!comment) return;
|
|
1129
|
+
if (comment.status === 'updated' && status !== 'failed') return;
|
|
1130
|
+
comment.status = status;
|
|
1131
|
+
if (updates) Object.assign(comment, updates);
|
|
1132
|
+
renderCommentThread();
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function markCommentsUpdatedForVersion(version) {
|
|
1136
|
+
let changed = false;
|
|
1137
|
+
state.pendingComments.forEach((comment) => {
|
|
1138
|
+
if ((comment.status === 'sent' || comment.status === 'sending' || comment.status === 'stale') && comment.baseDeckVersion !== version) {
|
|
1139
|
+
comment.status = 'updated';
|
|
1140
|
+
comment.updatedVersion = version;
|
|
1141
|
+
changed = true;
|
|
1142
|
+
}
|
|
1143
|
+
});
|
|
1144
|
+
if (changed) {
|
|
1145
|
+
renderCommentThread();
|
|
1146
|
+
setStatus('Deck file updated. Preview will refresh automatically.');
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function markStaleComments() {
|
|
1151
|
+
const now = Date.now();
|
|
1152
|
+
let changed = false;
|
|
1153
|
+
state.pendingComments.forEach((comment) => {
|
|
1154
|
+
if (comment.status !== 'sent' && comment.status !== 'sending') return;
|
|
1155
|
+
if (now - comment.createdAt < COMMENT_STALE_MS) return;
|
|
1156
|
+
comment.status = 'stale';
|
|
1157
|
+
changed = true;
|
|
1158
|
+
});
|
|
1159
|
+
if (changed) {
|
|
1160
|
+
renderCommentThread();
|
|
1161
|
+
setStatus('Still waiting for deck file update. The preview will refresh automatically when the file changes.');
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function pendingCommentStatus(id) {
|
|
1166
|
+
return state.pendingComments.find((comment) => comment.id === id)?.status || '';
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function renderCommentThread() {
|
|
1170
|
+
els.commentThread.textContent = '';
|
|
1171
|
+
state.pendingComments.forEach((comment) => {
|
|
1172
|
+
const bubble = document.createElement('div');
|
|
1173
|
+
bubble.className = 'comment-bubble ' + comment.status;
|
|
1174
|
+
|
|
1175
|
+
const text = document.createElement('div');
|
|
1176
|
+
text.className = 'comment-bubble-text';
|
|
1177
|
+
text.textContent = comment.text;
|
|
1178
|
+
|
|
1179
|
+
const status = document.createElement('div');
|
|
1180
|
+
status.className = 'comment-bubble-state';
|
|
1181
|
+
status.textContent = commentStatusLabel(comment.status);
|
|
1182
|
+
|
|
1183
|
+
bubble.appendChild(text);
|
|
1184
|
+
bubble.appendChild(status);
|
|
1185
|
+
els.commentThread.appendChild(bubble);
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function commentStatusLabel(status) {
|
|
1190
|
+
if (status === 'updated') return 'Deck file updated';
|
|
1191
|
+
if (status === 'stale') return 'Still waiting for deck file update';
|
|
1192
|
+
if (status === 'failed') return 'Failed to send';
|
|
1193
|
+
if (status === 'sending') return 'Sending to OpenCode...';
|
|
1194
|
+
return '⏳ Sent to OpenCode';
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
function targetFromPointer(event) {
|
|
1198
|
+
const doc = els.frame.contentDocument;
|
|
1199
|
+
if (!doc || doc.location.href === 'about:blank') return null;
|
|
1200
|
+
const frameRect = els.frame.getBoundingClientRect();
|
|
1201
|
+
const x = event.clientX - frameRect.left;
|
|
1202
|
+
const y = event.clientY - frameRect.top;
|
|
1203
|
+
if (x < 0 || y < 0 || x > frameRect.width || y > frameRect.height) return null;
|
|
1204
|
+
return doc.elementFromPoint(x, y);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function createOutline(doc, border, fill) {
|
|
1208
|
+
const outline = doc.createElement('div');
|
|
1209
|
+
outline.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;border:2px solid ' + border + ';background:' + fill + ';border-radius:6px;display:none;';
|
|
1210
|
+
doc.body.appendChild(outline);
|
|
1211
|
+
return outline;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function setOutlineColor(outline, color) {
|
|
1215
|
+
if (!outline || !color) return;
|
|
1216
|
+
outline.style.borderColor = color.border;
|
|
1217
|
+
outline.style.background = color.fill;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function renderBox(outline, target) {
|
|
1221
|
+
if (!outline || !target || !target.getBoundingClientRect) {
|
|
1222
|
+
if (outline) outline.style.display = 'none';
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
const rect = target.getBoundingClientRect();
|
|
1226
|
+
outline.style.display = 'block';
|
|
1227
|
+
outline.style.left = rect.left + 'px';
|
|
1228
|
+
outline.style.top = rect.top + 'px';
|
|
1229
|
+
outline.style.width = rect.width + 'px';
|
|
1230
|
+
outline.style.height = rect.height + 'px';
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function renderHoverOutline(target) {
|
|
1234
|
+
renderBox(state.hoverOutline, target);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function renderReferenceOutlines() {
|
|
1238
|
+
const doc = els.frame.contentDocument;
|
|
1239
|
+
if (!doc || doc.location.href === 'about:blank') return;
|
|
1240
|
+
while (state.referenceOutlines.length < state.references.length) state.referenceOutlines.push(createOutline(doc, '#7aa6d8', 'rgba(122,166,216,.18)'));
|
|
1241
|
+
state.referenceOutlines.forEach((outline, index) => {
|
|
1242
|
+
const reference = state.references[index];
|
|
1243
|
+
setOutlineColor(outline, reference?.color);
|
|
1244
|
+
renderBox(outline, reference?.target);
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function clearHover() {
|
|
1249
|
+
state.hoverEl = null;
|
|
1250
|
+
setStatus('Hover cleared. Existing references are kept.');
|
|
1251
|
+
if (state.hoverOutline) state.hoverOutline.style.display = 'none';
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function updateSendState() {
|
|
1255
|
+
els.send.disabled = !getCommentText().trim();
|
|
1256
|
+
els.inspectButton.disabled = state.inspecting || state.references.length === 0;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function renderSelectionSummary() {
|
|
1260
|
+
const label = state.references.length
|
|
1261
|
+
? state.references.length + ' referenced element' + (state.references.length === 1 ? '' : 's') + ' selected.'
|
|
1262
|
+
: 'No references selected.';
|
|
1263
|
+
els.selectionSummary.querySelector('span').textContent = label;
|
|
1264
|
+
els.selectionChips.textContent = '';
|
|
1265
|
+
state.references.forEach((reference) => {
|
|
1266
|
+
const chip = document.createElement('span');
|
|
1267
|
+
chip.className = 'ref-chip';
|
|
1268
|
+
chip.style.setProperty('--ref-bg', reference.color.bg);
|
|
1269
|
+
chip.style.setProperty('--ref-border', reference.color.border);
|
|
1270
|
+
chip.style.setProperty('--ref-text', reference.color.text);
|
|
1271
|
+
chip.textContent = '@' + reference.label;
|
|
1272
|
+
els.selectionChips.appendChild(chip);
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function resetInspectCards(message) {
|
|
1277
|
+
if (!els.inspectCards) return;
|
|
1278
|
+
els.inspectStale.innerHTML = '';
|
|
1279
|
+
els.inspectCards.innerHTML = '<div class="inspect-empty">' + escapeHtml(message) + '</div>';
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
async function inspectCurrentSelection() {
|
|
1283
|
+
if (!state.references.length || state.inspecting) return;
|
|
1284
|
+
const snapshot = collectReferenceSnapshot();
|
|
1285
|
+
state.inspecting = true;
|
|
1286
|
+
updateSendState();
|
|
1287
|
+
setMode('inspect');
|
|
1288
|
+
els.inspectStale.innerHTML = '';
|
|
1289
|
+
els.inspectCards.innerHTML = '<div class="inspect-loading"><b>Preparing inspection...</b><br>Deterministic Source/Purpose appears first; generated cards update lazily.</div>';
|
|
1290
|
+
try {
|
|
1291
|
+
const res = await fetch('/api/inspect?token=' + encodeURIComponent(token), {
|
|
1292
|
+
method: 'POST',
|
|
1293
|
+
headers: { 'content-type': 'application/json' },
|
|
1294
|
+
body: JSON.stringify({ snapshot, deckVersion: state.deckVersion }),
|
|
1295
|
+
});
|
|
1296
|
+
const body = await res.json().catch(() => ({}));
|
|
1297
|
+
if (!res.ok || !body.ok) throw new Error(body.error || 'Inspection failed');
|
|
1298
|
+
state.deckVersion = body.deckVersion || state.deckVersion;
|
|
1299
|
+
state.activeInspectRequestId = body.requestId;
|
|
1300
|
+
if (body.preprocess) renderInspectResult(body.preprocess, 'Preprocessed');
|
|
1301
|
+
els.inspectCards.insertAdjacentHTML('beforeend', '<div class="inspect-loading"><b>Generating lazy inspection...</b><br>Waiting for structured result from OpenCode.</div>');
|
|
1302
|
+
await pollInspectResult(body.requestId);
|
|
1303
|
+
} catch (error) {
|
|
1304
|
+
resetInspectCards(error && error.message ? error.message : String(error));
|
|
1305
|
+
} finally {
|
|
1306
|
+
state.inspecting = false;
|
|
1307
|
+
updateSendState();
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
async function pollInspectResult(requestId) {
|
|
1312
|
+
for (;;) {
|
|
1313
|
+
await delay(900);
|
|
1314
|
+
const res = await fetch('/api/inspect-result?token=' + encodeURIComponent(token) + '&requestId=' + encodeURIComponent(requestId));
|
|
1315
|
+
const body = await res.json().catch(() => ({}));
|
|
1316
|
+
if (!res.ok || !body.ok) throw new Error(body.error || 'Inspection result failed');
|
|
1317
|
+
if (body.status === 'completed') {
|
|
1318
|
+
state.deckVersion = body.deckVersion || state.deckVersion;
|
|
1319
|
+
renderInspectResult(body.result, 'Generated');
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
if (body.status === 'failed' || body.status === 'expired') throw new Error(body.error || 'Inspection failed');
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function collectReferenceSnapshot() {
|
|
1327
|
+
const elements = state.references.map((reference) => reference.payload);
|
|
1328
|
+
const first = elements[0] || {};
|
|
1329
|
+
return {
|
|
1330
|
+
scope: elements.length > 1 ? 'selection' : 'element',
|
|
1331
|
+
slideIndex: first.slideIndex,
|
|
1332
|
+
slideTitle: first.slideTitle,
|
|
1333
|
+
text: elements.map((item) => item.text).filter(Boolean).join(' | ').slice(0, 1200),
|
|
1334
|
+
selectedText: elements.map((item) => item.text).filter(Boolean).join(String.fromCharCode(10)).slice(0, 1600),
|
|
1335
|
+
tagName: elements.length === 1 ? first.tagName : undefined,
|
|
1336
|
+
selector: elements.length === 1 ? first.selector : undefined,
|
|
1337
|
+
domPath: elements.length === 1 ? first.domPath : undefined,
|
|
1338
|
+
id: elements.length === 1 ? first.id : undefined,
|
|
1339
|
+
classList: elements.length === 1 ? first.classList : [],
|
|
1340
|
+
role: elements.length === 1 ? humanElementName(first) : 'Selection',
|
|
1341
|
+
outerHTMLExcerpt: elements.length === 1 ? first.outerHTMLExcerpt : undefined,
|
|
1342
|
+
nearbyText: first.nearbyText,
|
|
1343
|
+
elements,
|
|
1344
|
+
boundingBox: first.boundingBox,
|
|
1345
|
+
viewport: first.viewport,
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function renderInspectResult(result, phase) {
|
|
1350
|
+
if (result.stale?.stale) els.inspectStale.innerHTML = '<div class="inspect-stale">' + escapeHtml(result.stale.reason || 'Inspection may be stale.') + '</div>';
|
|
1351
|
+
else els.inspectStale.innerHTML = '';
|
|
1352
|
+
els.inspectCards.innerHTML = [
|
|
1353
|
+
'<div class="status">' + escapeHtml(phase || 'Inspection') + '</div>',
|
|
1354
|
+
renderInspectCard('Purpose', result.cards.purpose.status, result.cards.purpose.rationale, renderPurpose(result.cards.purpose)),
|
|
1355
|
+
renderInspectCard('Source', result.cards.source.status, result.cards.source.rationale, renderSource(result.cards.source)),
|
|
1356
|
+
].join('');
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function renderInspectCard(title, status, rationale, body) {
|
|
1360
|
+
return '<section class="inspect-card"><div class="inspect-card-head"><h2>' + escapeHtml(title) + '</h2><span class="badge ' + escapeHtml(status) + '">' + escapeHtml(status) + '</span></div><p>' + escapeHtml(rationale || '') + '</p>' + (body || '') + '</section>';
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function renderPurpose(card) {
|
|
1364
|
+
return '<div class="inspect-item">' + field('Role', card.role) + field('Why it matters', card.whyItMatters) + '</div>';
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
function renderSource(card) {
|
|
1368
|
+
return renderSources(card.sources) + renderWarnings(card.warnings) + renderSectionList('Gaps', card.gaps) + renderSectionList('Caveats', card.caveats);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
function renderSources(items) {
|
|
1372
|
+
if (!items || !items.length) return '';
|
|
1373
|
+
return items.map((item) => '<div class="inspect-item"><b>' + escapeHtml(item.source || 'Source') + '</b>' + field('Path', item.sourcePath || item.findingsFile) + field('Location', item.location || item.page) + field('Quote', item.quote) + field('URL', item.url) + field('Caveat', item.caveat) + '</div>').join('');
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function renderWarnings(items) { return items && items.length ? items.map((item) => '<div class="inspect-warning">' + escapeHtml(item) + '</div>').join('') : ''; }
|
|
1377
|
+
function renderList(items) { return items && items.length ? items.map((item) => '<div class="inspect-item">' + escapeHtml(item) + '</div>').join('') : ''; }
|
|
1378
|
+
function renderSectionList(title, items) { return items && items.length ? '<div class="label">' + escapeHtml(title) + '</div>' + renderList(items) : ''; }
|
|
1379
|
+
function field(label, value) { return value ? '<br><b>' + escapeHtml(label) + ':</b> ' + escapeHtml(value) : ''; }
|
|
1380
|
+
function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
|
1381
|
+
function escapeHtml(value) { return String(value || '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); }
|
|
1382
|
+
|
|
1383
|
+
function nextReferenceLabel(payload) {
|
|
1384
|
+
return humanElementName(payload) + ' ' + (state.references.length + 1);
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
function insertReferenceChip(reference) {
|
|
1388
|
+
const chip = document.createElement('span');
|
|
1389
|
+
chip.className = 'ref-chip';
|
|
1390
|
+
chip.contentEditable = 'false';
|
|
1391
|
+
chip.dataset.refId = reference.id;
|
|
1392
|
+
chip.style.setProperty('--ref-bg', reference.color.bg);
|
|
1393
|
+
chip.style.setProperty('--ref-border', reference.color.border);
|
|
1394
|
+
chip.style.setProperty('--ref-text', reference.color.text);
|
|
1395
|
+
chip.textContent = '@' + reference.label;
|
|
1396
|
+
const trailingSpace = document.createTextNode(' ');
|
|
1397
|
+
const range = getCommentInsertRange();
|
|
1398
|
+
if (range) {
|
|
1399
|
+
range.insertNode(trailingSpace);
|
|
1400
|
+
range.insertNode(chip);
|
|
1401
|
+
range.setStartAfter(trailingSpace);
|
|
1402
|
+
range.collapse(true);
|
|
1403
|
+
applyCommentRange(range);
|
|
1404
|
+
} else {
|
|
1405
|
+
if (els.comment.textContent && !/\\s$/.test(els.comment.textContent)) els.comment.appendChild(document.createTextNode(' '));
|
|
1406
|
+
els.comment.appendChild(chip);
|
|
1407
|
+
els.comment.appendChild(trailingSpace);
|
|
1408
|
+
placeCaretAfter(trailingSpace);
|
|
1409
|
+
}
|
|
1410
|
+
els.comment.focus();
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
function removeReferenceChip(id) {
|
|
1414
|
+
const chip = els.comment.querySelector('.ref-chip[data-ref-id="' + cssEscape(id) + '"]');
|
|
1415
|
+
if (!chip) return;
|
|
1416
|
+
const next = chip.nextSibling;
|
|
1417
|
+
chip.remove();
|
|
1418
|
+
if (next && next.nodeType === Node.TEXT_NODE && next.textContent === ' ') next.remove();
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function clearReferences(removeChips) {
|
|
1422
|
+
state.references = [];
|
|
1423
|
+
if (removeChips) els.comment.querySelectorAll('.ref-chip').forEach((chip) => chip.remove());
|
|
1424
|
+
renderSelectionSummary();
|
|
1425
|
+
resetInspectCards('Select one or more deck elements, then inspect them for Source and Purpose. This does not edit the deck.');
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function getCommentText() {
|
|
1429
|
+
return (els.comment.innerText || els.comment.textContent || '').replace(/\\u00a0/g, ' ');
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function placeCaretAfter(node) {
|
|
1433
|
+
const range = document.createRange();
|
|
1434
|
+
range.setStartAfter(node);
|
|
1435
|
+
range.collapse(true);
|
|
1436
|
+
applyCommentRange(range);
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function saveCommentRange() {
|
|
1440
|
+
const selection = window.getSelection();
|
|
1441
|
+
if (!selection || selection.rangeCount === 0) return;
|
|
1442
|
+
if (!els.comment || !els.comment.contains(selection.anchorNode)) return;
|
|
1443
|
+
state.commentRange = selection.getRangeAt(0).cloneRange();
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function getCommentInsertRange() {
|
|
1447
|
+
const selection = window.getSelection();
|
|
1448
|
+
if (selection && selection.rangeCount > 0 && els.comment.contains(selection.anchorNode)) {
|
|
1449
|
+
const range = selection.getRangeAt(0).cloneRange();
|
|
1450
|
+
range.deleteContents();
|
|
1451
|
+
return range;
|
|
1452
|
+
}
|
|
1453
|
+
if (state.commentRange && els.comment.contains(state.commentRange.commonAncestorContainer)) {
|
|
1454
|
+
const range = state.commentRange.cloneRange();
|
|
1455
|
+
range.deleteContents();
|
|
1456
|
+
return range;
|
|
1457
|
+
}
|
|
1458
|
+
return null;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function applyCommentRange(range) {
|
|
1462
|
+
const selection = window.getSelection();
|
|
1463
|
+
if (!selection) return;
|
|
1464
|
+
selection.removeAllRanges();
|
|
1465
|
+
selection.addRange(range);
|
|
1466
|
+
state.commentRange = range.cloneRange();
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
function humanElementName(payload) {
|
|
1470
|
+
const tag = payload.tagName || 'element';
|
|
1471
|
+
const classes = payload.classList || [];
|
|
1472
|
+
if (/^h[1-6]$/.test(tag)) return 'Heading';
|
|
1473
|
+
if (tag === 'p') return 'Text block';
|
|
1474
|
+
if (classes.some((name) => /card/i.test(name))) return 'Card';
|
|
1475
|
+
if (classes.some((name) => /stat|metric|value/i.test(name))) return 'Metric';
|
|
1476
|
+
if (tag === 'img' || tag === 'svg') return 'Visual';
|
|
1477
|
+
return 'Element';
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
function collectPayload(el) {
|
|
1481
|
+
const doc = els.frame.contentDocument;
|
|
1482
|
+
const slides = getSlides(doc);
|
|
1483
|
+
const slide = findSlide(el);
|
|
1484
|
+
const rect = el.getBoundingClientRect();
|
|
1485
|
+
const explicitSlideIndex = slide ? Number(slide.getAttribute('data-slide-index')) : Number.NaN;
|
|
1486
|
+
const slideIndex = slide && Number.isFinite(explicitSlideIndex) && explicitSlideIndex > 0 ? explicitSlideIndex : slide ? slides.indexOf(slide) + 1 : undefined;
|
|
1487
|
+
const win = els.frame.contentWindow;
|
|
1488
|
+
return {
|
|
1489
|
+
slideIndex,
|
|
1490
|
+
slideTitle: slide ? ((slide.querySelector('h1,h2,h3,[data-title]') || {}).textContent || '').trim().slice(0, 160) : undefined,
|
|
1491
|
+
selector: buildSelector(el, slide),
|
|
1492
|
+
domPath: buildDomPath(el, slide),
|
|
1493
|
+
tagName: el.tagName.toLowerCase(),
|
|
1494
|
+
id: el.id || undefined,
|
|
1495
|
+
classList: Array.from(el.classList || []),
|
|
1496
|
+
text: (el.innerText || el.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 600),
|
|
1497
|
+
outerHTMLExcerpt: (el.outerHTML || '').replace(/\\s+/g, ' ').slice(0, 1200),
|
|
1498
|
+
nearbyText: slide ? (slide.innerText || slide.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 1200) : undefined,
|
|
1499
|
+
boundingBox: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
1500
|
+
viewport: { width: win ? win.innerWidth : undefined, height: win ? win.innerHeight : undefined },
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
function buildSelector(el, slide) {
|
|
1505
|
+
if (el.id) return '#' + cssEscape(el.id);
|
|
1506
|
+
const parts = [];
|
|
1507
|
+
let node = el;
|
|
1508
|
+
while (node && node.nodeType === 1 && node !== slide) {
|
|
1509
|
+
let part = node.tagName.toLowerCase();
|
|
1510
|
+
const stable = Array.from(node.attributes || []).find((attr) => attr.name.startsWith('data-'));
|
|
1511
|
+
if (stable) {
|
|
1512
|
+
part += '[' + stable.name + '="' + stable.value.replace(/"/g, '\\"') + '"]';
|
|
1513
|
+
parts.unshift(part);
|
|
1514
|
+
break;
|
|
1515
|
+
}
|
|
1516
|
+
const classes = Array.from(node.classList || []).slice(0, 2).map(cssEscape);
|
|
1517
|
+
if (classes.length) part += '.' + classes.join('.');
|
|
1518
|
+
const siblings = Array.from(node.parentElement ? node.parentElement.children : []).filter((child) => child.tagName === node.tagName);
|
|
1519
|
+
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
|
|
1520
|
+
parts.unshift(part);
|
|
1521
|
+
node = node.parentElement;
|
|
1522
|
+
}
|
|
1523
|
+
const slidePart = slide ? slideSelector(slide) : '.slide';
|
|
1524
|
+
return [slidePart].concat(parts).join(' > ');
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
function findSlide(node) {
|
|
1528
|
+
return node.closest('.slide, [slide-qa], .slide-canvas, .page');
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
function getSlides(doc) {
|
|
1532
|
+
const slides = Array.from(doc.querySelectorAll('.slide'));
|
|
1533
|
+
if (slides.length) return slides;
|
|
1534
|
+
const qaSlides = Array.from(doc.querySelectorAll('[slide-qa]'));
|
|
1535
|
+
if (qaSlides.length) return qaSlides;
|
|
1536
|
+
const canvases = Array.from(doc.querySelectorAll('.slide-canvas'));
|
|
1537
|
+
if (canvases.length) return canvases;
|
|
1538
|
+
return Array.from(doc.querySelectorAll('.page'));
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
function slideSelector(slide) {
|
|
1542
|
+
if (slide.id) return '#' + cssEscape(slide.id);
|
|
1543
|
+
const doc = els.frame.contentDocument;
|
|
1544
|
+
const slides = getSlides(doc);
|
|
1545
|
+
const index = slides.indexOf(slide) + 1;
|
|
1546
|
+
if (slide.classList && slide.classList.contains('slide')) return '.slide:nth-of-type(' + index + ')';
|
|
1547
|
+
if (slide.hasAttribute && slide.hasAttribute('slide-qa')) return '[slide-qa]:nth-of-type(' + index + ')';
|
|
1548
|
+
if (slide.classList && slide.classList.contains('slide-canvas')) return '.slide-canvas:nth-of-type(' + index + ')';
|
|
1549
|
+
return '.page:nth-of-type(' + index + ')';
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
function buildDomPath(el, stop) {
|
|
1553
|
+
const parts = [];
|
|
1554
|
+
let node = el;
|
|
1555
|
+
while (node && node.nodeType === 1 && node !== stop) {
|
|
1556
|
+
const siblings = Array.from(node.parentElement ? node.parentElement.children : []);
|
|
1557
|
+
parts.unshift(node.tagName.toLowerCase() + '[' + (siblings.indexOf(node) + 1) + ']');
|
|
1558
|
+
node = node.parentElement;
|
|
1559
|
+
}
|
|
1560
|
+
return parts.join(' > ');
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
function cssEscape(value) {
|
|
1564
|
+
if (window.CSS && CSS.escape) return CSS.escape(value);
|
|
1565
|
+
return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&');
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
function setStatus(message) {
|
|
1569
|
+
if (els.status) els.status.textContent = message;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
function reportError(error) {
|
|
1573
|
+
const message = error && error.message ? error.message : String(error);
|
|
1574
|
+
setStatus('Editor error: ' + message);
|
|
1575
|
+
console.error('[Revela edit]', error);
|
|
1576
|
+
}
|
|
1577
|
+
})();
|
|
1578
|
+
</script>
|
|
1579
|
+
</body>
|
|
1580
|
+
</html>`
|
|
1581
|
+
}
|