@cyber-dash-tech/revela 0.6.2 → 0.7.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/lib/commands/edit.ts +69 -0
- package/lib/commands/help.ts +1 -0
- package/lib/edit/deck-state.ts +125 -0
- package/lib/edit/prompt.ts +81 -0
- package/lib/edit/resolve-deck.ts +99 -0
- package/lib/edit/server.ts +851 -0
- package/package.json +1 -1
- package/plugin.ts +5 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { existsSync } from "fs"
|
|
2
|
+
import { ctx } from "../ctx"
|
|
3
|
+
import { ACTIVE_PROMPT_FILE } from "../config"
|
|
4
|
+
import { buildPrompt } from "../prompt-builder"
|
|
5
|
+
import { resolveEditableDeck } from "../edit/resolve-deck"
|
|
6
|
+
import { ensureEditableDeckState } from "../edit/deck-state"
|
|
7
|
+
import { startEditServer } from "../edit/server"
|
|
8
|
+
|
|
9
|
+
function openUrl(url: string): void {
|
|
10
|
+
if (process.platform === "darwin") {
|
|
11
|
+
const proc = Bun.spawnSync(["open", url])
|
|
12
|
+
if (proc.exitCode !== 0) throw new Error(proc.stderr.toString() || "Failed to open edit page")
|
|
13
|
+
return
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (process.platform === "win32") {
|
|
17
|
+
const proc = Bun.spawnSync(["cmd", "/c", "start", "", url])
|
|
18
|
+
if (proc.exitCode !== 0) throw new Error(proc.stderr.toString() || "Failed to open edit page")
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const proc = Bun.spawnSync(["xdg-open", url])
|
|
23
|
+
if (proc.exitCode !== 0) throw new Error(proc.stderr.toString() || "Failed to open edit page")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function handleEdit(
|
|
27
|
+
input: string,
|
|
28
|
+
options: { client: any; sessionID: string; workspaceRoot: string },
|
|
29
|
+
send: (text: string) => Promise<void>,
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
const target = input.trim()
|
|
32
|
+
if (!target) {
|
|
33
|
+
await send("**Usage:** `/revela edit <deck-slug|decks/file.html>`\n\nExamples: `/revela edit investor-update`, `/revela edit decks/investor-update.html`")
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const deck = resolveEditableDeck(options.workspaceRoot, target)
|
|
39
|
+
const preflight = ensureEditableDeckState(options.workspaceRoot, deck)
|
|
40
|
+
if (!preflight.readiness.ready) {
|
|
41
|
+
await send(`**Edit blocked:** ${preflight.readiness.blocker || "Deck is not ready for HTML edits."}`)
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
ctx.enabled = true
|
|
46
|
+
if (!existsSync(ACTIVE_PROMPT_FILE)) buildPrompt()
|
|
47
|
+
|
|
48
|
+
const editServer = startEditServer()
|
|
49
|
+
const token = editServer.createSession({
|
|
50
|
+
client: options.client,
|
|
51
|
+
sessionID: options.sessionID,
|
|
52
|
+
deck,
|
|
53
|
+
})
|
|
54
|
+
const url = `${editServer.baseUrl}/edit?token=${encodeURIComponent(token)}`
|
|
55
|
+
openUrl(url)
|
|
56
|
+
|
|
57
|
+
const source = deck.source === "decks-state" ? "DECKS.json" : deck.source === "file-path" ? "file path" : "fallback path"
|
|
58
|
+
const stateNote = preflight.changed ? "Deck state was prepared in DECKS.json before opening the editor.\n" : "Deck state is ready in DECKS.json.\n"
|
|
59
|
+
await send(
|
|
60
|
+
`Opened visual editor for deck \`${deck.slug}\`.\n` +
|
|
61
|
+
`File: \`${deck.file}\` (${source})\n` +
|
|
62
|
+
stateNote +
|
|
63
|
+
`URL: ${url}\n\n` +
|
|
64
|
+
`Use Ctrl/Cmd + click in the browser to reference elements, write a comment, then send comments. Revela mode has been enabled for the edit prompt.`
|
|
65
|
+
)
|
|
66
|
+
} catch (e: any) {
|
|
67
|
+
await send(`**Edit failed:** ${e.message || String(e)}`)
|
|
68
|
+
}
|
|
69
|
+
}
|
package/lib/commands/help.ts
CHANGED
|
@@ -28,6 +28,7 @@ export async function handleHelp(
|
|
|
28
28
|
`\`/revela disable\` — disable slide generation mode\n` +
|
|
29
29
|
`\`/revela init\` — initialize or refresh workspace DECKS.json\n` +
|
|
30
30
|
`\`/revela review [slug]\` — review active deck readiness before writing HTML\n` +
|
|
31
|
+
`\`/revela edit <target>\` — open visual comment editor for a deck slug or decks/*.html\n` +
|
|
31
32
|
`\`/revela remember <text>\` — save an explicit preference to DECKS.json\n` +
|
|
32
33
|
`\`/revela designs\` — list installed designs\n` +
|
|
33
34
|
`\`/revela designs <name>\` — activate a design\n` +
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { readFileSync } from "fs"
|
|
2
|
+
import { activeDesign } from "../design/designs"
|
|
3
|
+
import { activeDomain } from "../domain/domains"
|
|
4
|
+
import {
|
|
5
|
+
defaultRequiredInputs,
|
|
6
|
+
readOrCreateDecksState,
|
|
7
|
+
reviewDeckState,
|
|
8
|
+
upsertDeck,
|
|
9
|
+
upsertSlides,
|
|
10
|
+
writeDecksState,
|
|
11
|
+
type DeckStateReadinessResult,
|
|
12
|
+
type SlideSpec,
|
|
13
|
+
} from "../decks-state"
|
|
14
|
+
import type { EditableDeck } from "./resolve-deck"
|
|
15
|
+
|
|
16
|
+
export interface EditDeckStatePreflightResult {
|
|
17
|
+
changed: boolean
|
|
18
|
+
readiness: DeckStateReadinessResult
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ensureEditableDeckState(workspaceRoot: string, deck: EditableDeck): EditDeckStatePreflightResult {
|
|
22
|
+
let state = readOrCreateDecksState(workspaceRoot)
|
|
23
|
+
const existing = state.decks[deck.slug]
|
|
24
|
+
const existingReady = existing?.writeReadiness?.status === "ready" && existing.writeReadiness.blockers.length === 0
|
|
25
|
+
let changed = !existing || existing.outputPath !== deck.file
|
|
26
|
+
|
|
27
|
+
state = upsertDeck(state, {
|
|
28
|
+
...existing,
|
|
29
|
+
slug: deck.slug,
|
|
30
|
+
goal: existing?.goal || `Edit existing Revela deck ${deck.slug}.`,
|
|
31
|
+
audience: existing?.audience || "Existing deck viewers",
|
|
32
|
+
language: existing?.language || "en",
|
|
33
|
+
slideCount: existing?.slideCount || inferSlideCount(deck.absoluteFile),
|
|
34
|
+
outputPath: deck.file,
|
|
35
|
+
theme: {
|
|
36
|
+
design: existing?.theme?.design || safeActiveDesign(),
|
|
37
|
+
domain: existing?.theme?.domain || safeActiveDomain(),
|
|
38
|
+
},
|
|
39
|
+
requiredInputs: defaultRequiredInputs({
|
|
40
|
+
...existing?.requiredInputs,
|
|
41
|
+
topicClarified: true,
|
|
42
|
+
audienceClarified: true,
|
|
43
|
+
slideCountDecided: true,
|
|
44
|
+
languageDecided: true,
|
|
45
|
+
visualStyleSelected: true,
|
|
46
|
+
sourceMaterialsIdentified: true,
|
|
47
|
+
researchNeedAssessed: true,
|
|
48
|
+
researchFindingsRead: true,
|
|
49
|
+
slidePlanConfirmed: true,
|
|
50
|
+
designLayoutsFetched: true,
|
|
51
|
+
}),
|
|
52
|
+
researchPlan: existing?.researchPlan || [],
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const current = state.decks[deck.slug]
|
|
56
|
+
if (current.slides.length === 0) {
|
|
57
|
+
state = upsertSlides(state, deck.slug, inferSlides(deck.absoluteFile))
|
|
58
|
+
changed = true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const reviewed = reviewDeckState(state, deck.slug)
|
|
62
|
+
writeDecksState(workspaceRoot, reviewed.state)
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
changed: changed || !existingReady,
|
|
66
|
+
readiness: reviewed.result,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function inferSlideCount(filePath: string): number {
|
|
71
|
+
return inferSlides(filePath).length
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function inferSlides(filePath: string): SlideSpec[] {
|
|
75
|
+
const html = readFileSync(filePath, "utf-8")
|
|
76
|
+
const chunks = html.match(/<section\b[\s\S]*?<\/section>/gi) || [html]
|
|
77
|
+
return chunks.map((chunk, index) => {
|
|
78
|
+
const title = extractTitle(chunk) || `Slide ${index + 1}`
|
|
79
|
+
return {
|
|
80
|
+
index: index + 1,
|
|
81
|
+
title,
|
|
82
|
+
purpose: "Existing HTML slide prepared for targeted visual edits.",
|
|
83
|
+
layout: "existing-html",
|
|
84
|
+
qa: /slide-qa=["']true["']/i.test(chunk),
|
|
85
|
+
components: ["existing-html"],
|
|
86
|
+
content: {
|
|
87
|
+
headline: title,
|
|
88
|
+
body: [extractText(chunk) || "Existing HTML slide content."],
|
|
89
|
+
},
|
|
90
|
+
evidence: [],
|
|
91
|
+
visuals: [],
|
|
92
|
+
status: "ready",
|
|
93
|
+
notes: "Inferred automatically by /revela edit preflight.",
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function extractTitle(html: string): string {
|
|
99
|
+
const match = /<(?:h1|h2|h3|title)\b[^>]*>([\s\S]*?)<\/(?:h1|h2|h3|title)>/i.exec(html)
|
|
100
|
+
return normalizeText(match?.[1] || "").slice(0, 160)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function extractText(html: string): string {
|
|
104
|
+
return normalizeText(html.replace(/<script\b[\s\S]*?<\/script>/gi, " ").replace(/<style\b[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " ")).slice(0, 600)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalizeText(value: string): string {
|
|
108
|
+
return value.replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\s+/g, " ").trim()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function safeActiveDesign(): string {
|
|
112
|
+
try {
|
|
113
|
+
return activeDesign()
|
|
114
|
+
} catch {
|
|
115
|
+
return "aurora"
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function safeActiveDomain(): string {
|
|
120
|
+
try {
|
|
121
|
+
return activeDomain()
|
|
122
|
+
} catch {
|
|
123
|
+
return "general"
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export interface EditSelectedElementPayload {
|
|
2
|
+
slideIndex?: number
|
|
3
|
+
slideTitle?: string
|
|
4
|
+
selector?: string
|
|
5
|
+
domPath?: string
|
|
6
|
+
tagName?: string
|
|
7
|
+
id?: string
|
|
8
|
+
classList?: string[]
|
|
9
|
+
text?: string
|
|
10
|
+
outerHTMLExcerpt?: string
|
|
11
|
+
nearbyText?: string
|
|
12
|
+
boundingBox?: Record<string, unknown>
|
|
13
|
+
viewport?: Record<string, unknown>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface EditCommentDraftPayload {
|
|
17
|
+
comment: string
|
|
18
|
+
elements: EditSelectedElementPayload[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface EditCommentPayload extends EditSelectedElementPayload {
|
|
22
|
+
deck: string
|
|
23
|
+
file: string
|
|
24
|
+
comment: string
|
|
25
|
+
elements?: EditSelectedElementPayload[]
|
|
26
|
+
comments?: EditCommentDraftPayload[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildEditPrompt(payload: EditCommentPayload): string {
|
|
30
|
+
const elements = payload.elements?.length
|
|
31
|
+
? payload.elements
|
|
32
|
+
: [{
|
|
33
|
+
slideIndex: payload.slideIndex,
|
|
34
|
+
slideTitle: payload.slideTitle,
|
|
35
|
+
selector: payload.selector,
|
|
36
|
+
domPath: payload.domPath,
|
|
37
|
+
tagName: payload.tagName,
|
|
38
|
+
id: payload.id,
|
|
39
|
+
classList: payload.classList ?? [],
|
|
40
|
+
text: payload.text,
|
|
41
|
+
outerHTMLExcerpt: payload.outerHTMLExcerpt,
|
|
42
|
+
nearbyText: payload.nearbyText,
|
|
43
|
+
boundingBox: payload.boundingBox,
|
|
44
|
+
viewport: payload.viewport,
|
|
45
|
+
}]
|
|
46
|
+
const comments = payload.comments?.length
|
|
47
|
+
? payload.comments
|
|
48
|
+
: [{
|
|
49
|
+
comment: payload.comment,
|
|
50
|
+
elements,
|
|
51
|
+
}]
|
|
52
|
+
|
|
53
|
+
const compact = {
|
|
54
|
+
deck: payload.deck,
|
|
55
|
+
file: payload.file,
|
|
56
|
+
comments,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return `The user left a visual edit comment on a Revela slide deck.
|
|
60
|
+
|
|
61
|
+
Target deck: ${payload.deck}
|
|
62
|
+
Target file: ${payload.file}
|
|
63
|
+
|
|
64
|
+
Structured selection payload:
|
|
65
|
+
|
|
66
|
+
\`\`\`json
|
|
67
|
+
${JSON.stringify(compact, null, 2)}
|
|
68
|
+
\`\`\`
|
|
69
|
+
|
|
70
|
+
Instructions:
|
|
71
|
+
- Make the smallest targeted change that satisfies the user's comment.
|
|
72
|
+
- If there are multiple comments, apply them as one coherent edit pass and avoid changes from one comment overwriting another.
|
|
73
|
+
- Each comment may reference one or more selected elements. Treat the elements in a single comment as a group.
|
|
74
|
+
- Preserve the existing deck structure, active design language, typography, spacing system, animations, and slide count unless the comment explicitly asks otherwise.
|
|
75
|
+
- Do not rewrite unrelated slides or broad sections of the deck.
|
|
76
|
+
- Locate each target primarily with slideIndex, slideTitle, selected text, nearbyText, and outerHTMLExcerpt. Use selector/domPath as hints; they may be approximate.
|
|
77
|
+
- Before patching or writing ${"`decks/*.html`"}, ensure ${"`DECKS.json`"} contains this deck and call ${"`revela-decks`"} with action ${"`review`"}. If ${"`DECKS.json`"} or the deck entry is missing, initialize/upsert the deck state with ${"`revela-decks`"} first. If readiness remains blocked, explain the blockers instead of forcing the edit.
|
|
78
|
+
- Apply the edit to ${payload.file} only after readiness allows deck HTML changes.
|
|
79
|
+
- After editing, run ${"`revela-qa`"} on ${payload.file} and fix any relevant regressions caused by the edit.
|
|
80
|
+
- If the comment is ambiguous, ask one concise clarification question instead of guessing.`
|
|
81
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync } from "fs"
|
|
2
|
+
import { basename, relative, resolve, sep } from "path"
|
|
3
|
+
import { DECKS_STATE_FILE, hasDecksState, isDeckHtmlPath, readDecksState } from "../decks-state"
|
|
4
|
+
|
|
5
|
+
export interface EditableDeck {
|
|
6
|
+
slug: string
|
|
7
|
+
file: string
|
|
8
|
+
absoluteFile: string
|
|
9
|
+
source: "decks-state" | "fallback" | "file-path"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function resolveEditableDeck(workspaceRoot: string, input: string): EditableDeck {
|
|
13
|
+
const requested = input.trim()
|
|
14
|
+
if (!requested) throw new Error("Usage: /revela edit <deck-slug|decks/file.html>")
|
|
15
|
+
|
|
16
|
+
const slug = normalizeSlug(requested)
|
|
17
|
+
|
|
18
|
+
if (hasDecksState(workspaceRoot)) {
|
|
19
|
+
const state = readDecksState(workspaceRoot)
|
|
20
|
+
const deck = state.decks[requested] ?? (slug ? state.decks[slug] : undefined)
|
|
21
|
+
if (deck) {
|
|
22
|
+
return resolveDeckFile(workspaceRoot, deck.slug, deck.outputPath, "decks-state")
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (looksLikePath(requested)) {
|
|
27
|
+
return resolvePathTarget(workspaceRoot, requested)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!slug) throw new Error("Deck target must be a deck slug or decks/*.html path.")
|
|
31
|
+
|
|
32
|
+
return resolveDeckFile(workspaceRoot, slug, `decks/${slug}.html`, "fallback")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolvePathTarget(workspaceRoot: string, requested: string): EditableDeck {
|
|
36
|
+
if (isAbsoluteLike(requested)) {
|
|
37
|
+
throw new Error("/revela edit only accepts workspace-relative decks/*.html paths.")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const normalized = normalizePath(requested).replace(/^\.\//, "")
|
|
41
|
+
if (!isDeckHtmlPath(normalized)) {
|
|
42
|
+
throw new Error("/revela edit file paths must point to decks/*.html.")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const slug = normalizeSlug(basename(normalized, ".html"))
|
|
46
|
+
if (!slug) throw new Error("Deck target must be a deck slug or decks/*.html path.")
|
|
47
|
+
return resolveDeckFile(workspaceRoot, slug, normalized, "file-path")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveDeckFile(
|
|
51
|
+
workspaceRoot: string,
|
|
52
|
+
slug: string,
|
|
53
|
+
file: string,
|
|
54
|
+
source: EditableDeck["source"],
|
|
55
|
+
): EditableDeck {
|
|
56
|
+
if (!isDeckHtmlPath(file)) {
|
|
57
|
+
throw new Error(`${DECKS_STATE_FILE} deck outputPath must be decks/*.html, got ${file || "missing"}.`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const root = resolve(workspaceRoot)
|
|
61
|
+
const absoluteFile = resolve(root, file)
|
|
62
|
+
if (!isInside(root, absoluteFile)) {
|
|
63
|
+
throw new Error(`Resolved deck file is outside the workspace: ${file}`)
|
|
64
|
+
}
|
|
65
|
+
if (!existsSync(absoluteFile)) {
|
|
66
|
+
throw new Error(`Deck HTML not found: ${workspaceRelative(root, absoluteFile)}`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
slug: normalizeSlug(slug),
|
|
71
|
+
file: workspaceRelative(root, absoluteFile),
|
|
72
|
+
absoluteFile,
|
|
73
|
+
source,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isInside(root: string, target: string): boolean {
|
|
78
|
+
return target === root || target.startsWith(root.endsWith(sep) ? root : root + sep)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function workspaceRelative(root: string, target: string): string {
|
|
82
|
+
return relative(root, target).split(sep).join("/")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function looksLikePath(value: string): boolean {
|
|
86
|
+
return value.includes("/") || value.includes("\\") || value.endsWith(".html")
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isAbsoluteLike(value: string): boolean {
|
|
90
|
+
return value.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(value)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizePath(value: string): string {
|
|
94
|
+
return value.replace(/\\/g, "/")
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeSlug(value: string): string {
|
|
98
|
+
return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
|
|
99
|
+
}
|
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
import { randomBytes } from "crypto"
|
|
2
|
+
import { readFileSync, statSync } from "fs"
|
|
3
|
+
import type { EditableDeck } from "./resolve-deck"
|
|
4
|
+
import { buildEditPrompt, type EditCommentPayload } from "./prompt"
|
|
5
|
+
|
|
6
|
+
const TOKEN_BYTES = 24
|
|
7
|
+
const SESSION_TTL_MS = 2 * 60 * 60 * 1000
|
|
8
|
+
const IDLE_STOP_MS = 30 * 60 * 1000
|
|
9
|
+
|
|
10
|
+
interface EditSession {
|
|
11
|
+
token: string
|
|
12
|
+
client: any
|
|
13
|
+
sessionID: string
|
|
14
|
+
deck: string
|
|
15
|
+
file: string
|
|
16
|
+
absoluteFile: string
|
|
17
|
+
createdAt: number
|
|
18
|
+
lastActiveAt: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface EditServerHandle {
|
|
22
|
+
baseUrl: string
|
|
23
|
+
createSession(input: { client: any; sessionID: string; deck: EditableDeck }): string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let server: ReturnType<typeof Bun.serve> | undefined
|
|
27
|
+
let baseUrl = ""
|
|
28
|
+
let idleTimer: Timer | undefined
|
|
29
|
+
const sessions = new Map<string, EditSession>()
|
|
30
|
+
|
|
31
|
+
export function startEditServer(): EditServerHandle {
|
|
32
|
+
if (!server) {
|
|
33
|
+
server = Bun.serve({
|
|
34
|
+
hostname: "127.0.0.1",
|
|
35
|
+
port: 0,
|
|
36
|
+
fetch: handleRequest,
|
|
37
|
+
})
|
|
38
|
+
baseUrl = `http://127.0.0.1:${server.port}`
|
|
39
|
+
scheduleIdleStop()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
baseUrl,
|
|
44
|
+
createSession(input) {
|
|
45
|
+
cleanupExpiredSessions()
|
|
46
|
+
const token = randomBytes(TOKEN_BYTES).toString("base64url")
|
|
47
|
+
sessions.set(token, {
|
|
48
|
+
token,
|
|
49
|
+
client: input.client,
|
|
50
|
+
sessionID: input.sessionID,
|
|
51
|
+
deck: input.deck.slug,
|
|
52
|
+
file: input.deck.file,
|
|
53
|
+
absoluteFile: input.deck.absoluteFile,
|
|
54
|
+
createdAt: Date.now(),
|
|
55
|
+
lastActiveAt: Date.now(),
|
|
56
|
+
})
|
|
57
|
+
return token
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function handleRequest(req: Request): Promise<Response> {
|
|
63
|
+
cleanupExpiredSessions()
|
|
64
|
+
const url = new URL(req.url)
|
|
65
|
+
|
|
66
|
+
if (url.pathname === "/health") return textResponse("ok")
|
|
67
|
+
|
|
68
|
+
if (url.pathname === "/edit" && req.method === "GET") {
|
|
69
|
+
const session = validateSession(url.searchParams.get("token"))
|
|
70
|
+
if (!session.ok) return session.response
|
|
71
|
+
return htmlResponse(renderEditorShell(session.value.token))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (url.pathname === "/deck" && req.method === "GET") {
|
|
75
|
+
const session = validateSession(url.searchParams.get("token"))
|
|
76
|
+
if (!session.ok) return session.response
|
|
77
|
+
return htmlResponse(readFileSync(session.value.absoluteFile, "utf-8"))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (url.pathname === "/api/comment" && req.method === "POST") {
|
|
81
|
+
const session = validateSession(url.searchParams.get("token"))
|
|
82
|
+
if (!session.ok) return session.response
|
|
83
|
+
return handleComment(req, session.value)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (url.pathname === "/api/deck-version" && req.method === "GET") {
|
|
87
|
+
const session = validateSession(url.searchParams.get("token"))
|
|
88
|
+
if (!session.ok) return session.response
|
|
89
|
+
return handleDeckVersion(session.value)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return textResponse("Not found", 404)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function handleDeckVersion(session: EditSession): Response {
|
|
96
|
+
try {
|
|
97
|
+
const stat = statSync(session.absoluteFile)
|
|
98
|
+
session.lastActiveAt = Date.now()
|
|
99
|
+
scheduleIdleStop()
|
|
100
|
+
return jsonResponse({ ok: true, mtimeMs: stat.mtimeMs, size: stat.size })
|
|
101
|
+
} catch (error) {
|
|
102
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
103
|
+
return jsonResponse({ ok: false, error: message }, 404)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function handleComment(req: Request, session: EditSession): Promise<Response> {
|
|
108
|
+
let body: Partial<EditCommentPayload>
|
|
109
|
+
try {
|
|
110
|
+
body = await req.json()
|
|
111
|
+
} catch {
|
|
112
|
+
return jsonResponse({ ok: false, error: "Invalid JSON body" }, 400)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const comments = Array.isArray(body.comments)
|
|
116
|
+
? body.comments
|
|
117
|
+
.map((draft: any) => ({
|
|
118
|
+
comment: typeof draft?.comment === "string" ? draft.comment.trim() : "",
|
|
119
|
+
elements: Array.isArray(draft?.elements) ? draft.elements : [],
|
|
120
|
+
}))
|
|
121
|
+
.filter((draft) => draft.comment && draft.elements.length > 0)
|
|
122
|
+
: []
|
|
123
|
+
const comment = typeof body.comment === "string" ? body.comment.trim() : ""
|
|
124
|
+
const elements = Array.isArray(body.elements) ? body.elements : []
|
|
125
|
+
if (!comment && comments.length === 0) return jsonResponse({ ok: false, error: "Comment is required" }, 400)
|
|
126
|
+
|
|
127
|
+
const prompt = buildEditPrompt({
|
|
128
|
+
...body,
|
|
129
|
+
deck: session.deck,
|
|
130
|
+
file: session.file,
|
|
131
|
+
comment,
|
|
132
|
+
elements,
|
|
133
|
+
comments,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
await session.client.session.prompt({
|
|
137
|
+
path: { id: session.sessionID },
|
|
138
|
+
body: {
|
|
139
|
+
parts: [{ type: "text", text: prompt }],
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
session.lastActiveAt = Date.now()
|
|
144
|
+
scheduleIdleStop()
|
|
145
|
+
return jsonResponse({ ok: true })
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function validateSession(token: string | null): { ok: true; value: EditSession } | { ok: false; response: Response } {
|
|
149
|
+
if (!token) return { ok: false, response: textResponse("Missing token", 401) }
|
|
150
|
+
const session = sessions.get(token)
|
|
151
|
+
if (!session) return { ok: false, response: textResponse("Invalid or expired token", 401) }
|
|
152
|
+
if (Date.now() - session.createdAt > SESSION_TTL_MS) {
|
|
153
|
+
sessions.delete(token)
|
|
154
|
+
return { ok: false, response: textResponse("Expired token", 401) }
|
|
155
|
+
}
|
|
156
|
+
session.lastActiveAt = Date.now()
|
|
157
|
+
return { ok: true, value: session }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function cleanupExpiredSessions(): void {
|
|
161
|
+
const now = Date.now()
|
|
162
|
+
for (const [token, session] of sessions) {
|
|
163
|
+
if (now - session.createdAt > SESSION_TTL_MS) sessions.delete(token)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function scheduleIdleStop(): void {
|
|
168
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
169
|
+
idleTimer = setTimeout(() => {
|
|
170
|
+
const now = Date.now()
|
|
171
|
+
const active = [...sessions.values()].some((session) => now - session.lastActiveAt < IDLE_STOP_MS)
|
|
172
|
+
if (active) {
|
|
173
|
+
scheduleIdleStop()
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
sessions.clear()
|
|
177
|
+
server?.stop()
|
|
178
|
+
server = undefined
|
|
179
|
+
baseUrl = ""
|
|
180
|
+
idleTimer = undefined
|
|
181
|
+
}, IDLE_STOP_MS)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function htmlResponse(body: string, status = 200): Response {
|
|
185
|
+
return new Response(body, {
|
|
186
|
+
status,
|
|
187
|
+
headers: {
|
|
188
|
+
"content-type": "text/html; charset=utf-8",
|
|
189
|
+
"cache-control": "no-store, max-age=0",
|
|
190
|
+
},
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function textResponse(body: string, status = 200): Response {
|
|
195
|
+
return new Response(body, {
|
|
196
|
+
status,
|
|
197
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
202
|
+
return new Response(JSON.stringify(body), {
|
|
203
|
+
status,
|
|
204
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function renderEditorShell(token: string): string {
|
|
209
|
+
const encodedToken = JSON.stringify(token)
|
|
210
|
+
return `<!doctype html>
|
|
211
|
+
<html lang="en">
|
|
212
|
+
<head>
|
|
213
|
+
<meta charset="utf-8" />
|
|
214
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
215
|
+
<title>Revela Edit</title>
|
|
216
|
+
<style>
|
|
217
|
+
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
218
|
+
* { box-sizing: border-box; }
|
|
219
|
+
body { margin: 0; background: #0d1117; color: #f0f6fc; height: 100vh; overflow: hidden; }
|
|
220
|
+
.app { display: grid; grid-template-columns: minmax(0, 1fr) 380px; height: 100vh; }
|
|
221
|
+
.preview { position: relative; min-width: 0; background: #05070a; border-right: 1px solid #30363d; }
|
|
222
|
+
iframe { display: block; width: 100%; height: 100%; border: 0; background: white; }
|
|
223
|
+
.hitbox { position: absolute; inset: 0; z-index: 2; cursor: crosshair; background: transparent; }
|
|
224
|
+
aside { display: flex; flex-direction: column; gap: 16px; padding: 18px; background: #111827; }
|
|
225
|
+
h1 { margin: 0; font-size: 18px; line-height: 1.2; }
|
|
226
|
+
.hint { margin: 0; color: #9ca3af; font-size: 13px; line-height: 1.5; }
|
|
227
|
+
.panel { display: flex; flex-direction: column; gap: 10px; }
|
|
228
|
+
.label { color: #9ca3af; font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
|
229
|
+
.comment-editor { width: 100%; min-height: 160px; max-height: 42vh; overflow: auto; padding: 12px; border: 1px solid #374151; border-radius: 12px; background: #020617; color: #f8fafc; font: inherit; line-height: 1.5; outline: none; white-space: pre-wrap; }
|
|
230
|
+
.comment-editor:focus { border-color: #38bdf8; box-shadow: 0 0 0 2px rgba(56,189,248,.18); }
|
|
231
|
+
.comment-editor:empty::before { content: attr(data-placeholder); color: #64748b; pointer-events: none; }
|
|
232
|
+
.ref-chip { display: inline-flex; align-items: center; margin: 0 2px; padding: 1px 6px; border-radius: 999px; background: rgba(56,189,248,.18); color: #bae6fd; border: 1px solid rgba(56,189,248,.45); font-weight: 700; white-space: nowrap; }
|
|
233
|
+
.comment-thread { display: flex; flex-direction: column; gap: 10px; max-height: 30vh; overflow: auto; }
|
|
234
|
+
.comment-bubble { border: 1px solid #374151; border-radius: 14px; padding: 10px 12px; background: #0f172a; color: #e5e7eb; font-size: 13px; line-height: 1.45; }
|
|
235
|
+
.comment-bubble.sending { border-color: rgba(56,189,248,.5); background: rgba(14,116,144,.14); }
|
|
236
|
+
.comment-bubble.done { border-color: rgba(34,197,94,.55); background: rgba(22,101,52,.18); }
|
|
237
|
+
.comment-bubble.failed { border-color: rgba(248,113,113,.65); background: rgba(127,29,29,.2); }
|
|
238
|
+
.comment-bubble-text { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
239
|
+
.comment-bubble-state { margin-top: 8px; color: #93c5fd; font-size: 12px; font-weight: 700; }
|
|
240
|
+
.comment-bubble.done .comment-bubble-state { color: #86efac; }
|
|
241
|
+
.comment-bubble.failed .comment-bubble-state { color: #fca5a5; }
|
|
242
|
+
button { width: 100%; padding: 12px 14px; border: 0; border-radius: 12px; background: #38bdf8; color: #04111d; font-weight: 700; cursor: pointer; }
|
|
243
|
+
button:disabled { cursor: not-allowed; opacity: .5; }
|
|
244
|
+
.status { min-height: 20px; color: #93c5fd; font-size: 13px; }
|
|
245
|
+
@media (max-width: 900px) { .app { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; } aside { max-height: 48vh; } }
|
|
246
|
+
</style>
|
|
247
|
+
</head>
|
|
248
|
+
<body>
|
|
249
|
+
<main class="app">
|
|
250
|
+
<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>
|
|
251
|
+
<aside>
|
|
252
|
+
<div>
|
|
253
|
+
<h1>Revela Visual Edit</h1>
|
|
254
|
+
<p class="hint">Write one comment. Use Ctrl/Cmd + click on deck elements to insert precise references into the comment.</p>
|
|
255
|
+
</div>
|
|
256
|
+
<div class="panel">
|
|
257
|
+
<div class="label">Comment</div>
|
|
258
|
+
<div id="comment" class="comment-editor" contenteditable="true" role="textbox" aria-multiline="true" data-placeholder="Example: Align @Metric 1 with @Metric 2, and remove @Text block 3."></div>
|
|
259
|
+
</div>
|
|
260
|
+
<div id="commentThread" class="comment-thread" aria-live="polite"></div>
|
|
261
|
+
<button id="send" disabled>Send comments</button>
|
|
262
|
+
<div id="status" class="status"></div>
|
|
263
|
+
</aside>
|
|
264
|
+
</main>
|
|
265
|
+
<script>
|
|
266
|
+
(() => {
|
|
267
|
+
const token = ${encodedToken};
|
|
268
|
+
const state = {
|
|
269
|
+
references: [],
|
|
270
|
+
pendingComments: [],
|
|
271
|
+
hoverEl: null,
|
|
272
|
+
hoverOutline: null,
|
|
273
|
+
referenceOutlines: [],
|
|
274
|
+
nextReferenceId: 1,
|
|
275
|
+
nextCommentId: 1,
|
|
276
|
+
initializedDoc: null,
|
|
277
|
+
deckVersion: null,
|
|
278
|
+
pendingRefreshMessage: false,
|
|
279
|
+
bound: false,
|
|
280
|
+
commentRange: null,
|
|
281
|
+
};
|
|
282
|
+
const els = {
|
|
283
|
+
frame: null,
|
|
284
|
+
hitbox: null,
|
|
285
|
+
comment: null,
|
|
286
|
+
commentThread: null,
|
|
287
|
+
send: null,
|
|
288
|
+
status: null,
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
window.addEventListener('error', (event) => reportError(event.error || event.message));
|
|
292
|
+
window.addEventListener('unhandledrejection', (event) => reportError(event.reason));
|
|
293
|
+
|
|
294
|
+
if (document.readyState === 'loading') {
|
|
295
|
+
document.addEventListener('DOMContentLoaded', boot, { once: true });
|
|
296
|
+
} else {
|
|
297
|
+
boot();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function boot() {
|
|
301
|
+
try {
|
|
302
|
+
els.frame = document.getElementById('deck');
|
|
303
|
+
els.hitbox = document.getElementById('hitbox');
|
|
304
|
+
els.comment = document.getElementById('comment');
|
|
305
|
+
els.commentThread = document.getElementById('commentThread');
|
|
306
|
+
els.send = document.getElementById('send');
|
|
307
|
+
els.status = document.getElementById('status');
|
|
308
|
+
|
|
309
|
+
if (!els.frame || !els.hitbox || !els.comment || !els.commentThread || !els.send || !els.status) {
|
|
310
|
+
throw new Error('Editor boot failed: required DOM nodes are missing.');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
bindEvents();
|
|
314
|
+
setStatus('Editor ready. Ctrl/Cmd + click deck elements to reference them.');
|
|
315
|
+
initFrame();
|
|
316
|
+
startDeckVersionPolling();
|
|
317
|
+
} catch (error) {
|
|
318
|
+
reportError(error);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function bindEvents() {
|
|
323
|
+
if (state.bound) return;
|
|
324
|
+
state.bound = true;
|
|
325
|
+
els.frame.addEventListener('load', initFrame);
|
|
326
|
+
document.addEventListener('keydown', (event) => {
|
|
327
|
+
if (event.key === 'Escape') clearHover();
|
|
328
|
+
});
|
|
329
|
+
els.comment.addEventListener('input', () => {
|
|
330
|
+
saveCommentRange();
|
|
331
|
+
syncReferencesFromComment(false);
|
|
332
|
+
updateSendState();
|
|
333
|
+
});
|
|
334
|
+
els.comment.addEventListener('keyup', saveCommentRange);
|
|
335
|
+
els.comment.addEventListener('mouseup', saveCommentRange);
|
|
336
|
+
document.addEventListener('selectionchange', saveCommentRange);
|
|
337
|
+
els.hitbox.addEventListener('pointermove', onHover);
|
|
338
|
+
els.hitbox.addEventListener('pointerdown', onPointerDown);
|
|
339
|
+
els.hitbox.addEventListener('click', onClick);
|
|
340
|
+
els.hitbox.addEventListener('contextmenu', (event) => {
|
|
341
|
+
if (event.ctrlKey || event.metaKey) event.preventDefault();
|
|
342
|
+
});
|
|
343
|
+
els.hitbox.addEventListener('wheel', (event) => {
|
|
344
|
+
const win = els.frame.contentWindow;
|
|
345
|
+
if (!win) return;
|
|
346
|
+
event.preventDefault();
|
|
347
|
+
win.scrollBy({ top: event.deltaY, left: event.deltaX, behavior: 'auto' });
|
|
348
|
+
renderHoverOutline(state.hoverEl);
|
|
349
|
+
renderReferenceOutlines();
|
|
350
|
+
}, { passive: false });
|
|
351
|
+
els.send.addEventListener('click', sendComment);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function initFrame() {
|
|
355
|
+
try {
|
|
356
|
+
const doc = els.frame.contentDocument;
|
|
357
|
+
if (!doc) {
|
|
358
|
+
setStatus('Unable to access deck iframe.');
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (doc === state.initializedDoc) return;
|
|
362
|
+
if (doc.location.href === 'about:blank') return;
|
|
363
|
+
if (doc.readyState === 'loading') return;
|
|
364
|
+
state.initializedDoc = doc;
|
|
365
|
+
clearReferences(false);
|
|
366
|
+
state.hoverEl = null;
|
|
367
|
+
state.hoverOutline = createOutline(doc, '#38bdf8', 'rgba(56,189,248,.12)');
|
|
368
|
+
state.referenceOutlines = [];
|
|
369
|
+
doc.addEventListener('scroll', () => {
|
|
370
|
+
renderHoverOutline(state.hoverEl);
|
|
371
|
+
renderReferenceOutlines();
|
|
372
|
+
}, true);
|
|
373
|
+
const slides = getSlides(doc);
|
|
374
|
+
updateSendState();
|
|
375
|
+
if (state.pendingRefreshMessage) {
|
|
376
|
+
state.pendingRefreshMessage = false;
|
|
377
|
+
markPendingCommentsDone();
|
|
378
|
+
setStatus('Deck updated. Preview refreshed. Element references were cleared.');
|
|
379
|
+
} else {
|
|
380
|
+
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.');
|
|
381
|
+
}
|
|
382
|
+
} catch (error) {
|
|
383
|
+
reportError(error);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function startDeckVersionPolling() {
|
|
388
|
+
pollDeckVersion();
|
|
389
|
+
window.setInterval(pollDeckVersion, 2000);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function pollDeckVersion() {
|
|
393
|
+
try {
|
|
394
|
+
const res = await fetch('/api/deck-version?token=' + encodeURIComponent(token), { cache: 'no-store' });
|
|
395
|
+
const body = await res.json().catch(() => ({}));
|
|
396
|
+
if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to check deck version');
|
|
397
|
+
const nextVersion = String(body.mtimeMs) + ':' + String(body.size);
|
|
398
|
+
if (!state.deckVersion) {
|
|
399
|
+
state.deckVersion = nextVersion;
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (state.deckVersion === nextVersion) return;
|
|
403
|
+
state.deckVersion = nextVersion;
|
|
404
|
+
refreshDeckPreview(body.mtimeMs);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
reportError(error);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function refreshDeckPreview(version) {
|
|
411
|
+
state.pendingRefreshMessage = true;
|
|
412
|
+
state.initializedDoc = null;
|
|
413
|
+
clearReferences(true);
|
|
414
|
+
state.hoverEl = null;
|
|
415
|
+
if (state.hoverOutline) state.hoverOutline.style.display = 'none';
|
|
416
|
+
state.referenceOutlines.forEach((outline) => outline.style.display = 'none');
|
|
417
|
+
state.referenceOutlines = [];
|
|
418
|
+
updateSendState();
|
|
419
|
+
els.frame.src = '/deck?token=' + encodeURIComponent(token) + '&v=' + encodeURIComponent(String(version));
|
|
420
|
+
setStatus('Deck changed. Refreshing preview...');
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function onHover(event) {
|
|
424
|
+
try {
|
|
425
|
+
initFrame();
|
|
426
|
+
const target = selectable(targetFromPointer(event));
|
|
427
|
+
if (!target || isReferenced(target)) {
|
|
428
|
+
renderHoverOutline(null);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
state.hoverEl = target;
|
|
432
|
+
renderHoverOutline(target);
|
|
433
|
+
} catch (error) {
|
|
434
|
+
reportError(error);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function onClick(event) {
|
|
439
|
+
try {
|
|
440
|
+
initFrame();
|
|
441
|
+
const target = selectable(targetFromPointer(event));
|
|
442
|
+
if (event.ctrlKey || event.metaKey) {
|
|
443
|
+
event.preventDefault();
|
|
444
|
+
event.stopPropagation();
|
|
445
|
+
return;
|
|
446
|
+
} else if (target) {
|
|
447
|
+
setStatus('Use Ctrl/Cmd + click to reference this element in your comment.');
|
|
448
|
+
}
|
|
449
|
+
} catch (error) {
|
|
450
|
+
reportError(error);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function onPointerDown(event) {
|
|
455
|
+
if (!event.ctrlKey && !event.metaKey) return;
|
|
456
|
+
try {
|
|
457
|
+
initFrame();
|
|
458
|
+
event.preventDefault();
|
|
459
|
+
event.stopPropagation();
|
|
460
|
+
toggleReference(selectable(targetFromPointer(event)));
|
|
461
|
+
} catch (error) {
|
|
462
|
+
reportError(error);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function sendComment() {
|
|
467
|
+
syncReferencesFromComment(false);
|
|
468
|
+
const text = getCommentText().trim();
|
|
469
|
+
if (!text) return;
|
|
470
|
+
const elements = state.references.map((reference) => reference.payload);
|
|
471
|
+
const commentId = addPendingComment(text, elements, 'sending');
|
|
472
|
+
clearReferences(false);
|
|
473
|
+
els.comment.textContent = '';
|
|
474
|
+
renderReferenceOutlines();
|
|
475
|
+
els.send.disabled = true;
|
|
476
|
+
setStatus('Sending...');
|
|
477
|
+
try {
|
|
478
|
+
const res = await fetch('/api/comment?token=' + encodeURIComponent(token), {
|
|
479
|
+
method: 'POST',
|
|
480
|
+
headers: { 'content-type': 'application/json' },
|
|
481
|
+
body: JSON.stringify({ comment: text, elements }),
|
|
482
|
+
});
|
|
483
|
+
const body = await res.json().catch(() => ({}));
|
|
484
|
+
if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to send comment');
|
|
485
|
+
updatePendingCommentStatus(commentId, 'sent');
|
|
486
|
+
setStatus('Comment sent. Waiting for deck update...');
|
|
487
|
+
updateSendState();
|
|
488
|
+
} catch (error) {
|
|
489
|
+
updatePendingCommentStatus(commentId, 'failed');
|
|
490
|
+
reportError(error);
|
|
491
|
+
updateSendState();
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function selectable(node) {
|
|
496
|
+
if (!node || node.nodeType !== 1) return null;
|
|
497
|
+
if (node === state.hoverOutline || state.referenceOutlines.includes(node)) return null;
|
|
498
|
+
return findSlide(node) ? node : null;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function toggleReference(target) {
|
|
502
|
+
if (!target) {
|
|
503
|
+
setStatus('No selectable deck element found under pointer.');
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const existing = findReferenceIndex(target);
|
|
507
|
+
if (existing >= 0) {
|
|
508
|
+
const label = state.references[existing].label;
|
|
509
|
+
removeReferenceAt(existing, true);
|
|
510
|
+
setStatus('Removed @' + label + '.');
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const payload = collectPayload(target);
|
|
514
|
+
const id = 'ref-' + state.nextReferenceId++;
|
|
515
|
+
const label = nextReferenceLabel(payload);
|
|
516
|
+
const reference = { id, target, label, payload };
|
|
517
|
+
state.references.push(reference);
|
|
518
|
+
insertReferenceChip(reference);
|
|
519
|
+
renderReferenceOutlines();
|
|
520
|
+
updateSendState();
|
|
521
|
+
setStatus('Inserted @' + label + '. ' + state.references.length + ' reference' + (state.references.length === 1 ? '' : 's') + ' will be sent.');
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function isReferenced(target) {
|
|
525
|
+
return findReferenceIndex(target) >= 0;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function findReferenceIndex(target) {
|
|
529
|
+
return state.references.findIndex((reference) => reference.target === target);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function removeReferenceAt(index, removeToken) {
|
|
533
|
+
const reference = state.references[index];
|
|
534
|
+
if (!reference) return;
|
|
535
|
+
state.references.splice(index, 1);
|
|
536
|
+
if (removeToken) removeReferenceChip(reference.id);
|
|
537
|
+
renderReferenceOutlines();
|
|
538
|
+
updateSendState();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function syncReferencesFromComment(showStatus) {
|
|
542
|
+
const activeIds = new Set(Array.from(els.comment.querySelectorAll('.ref-chip[data-ref-id]')).map((chip) => chip.getAttribute('data-ref-id')));
|
|
543
|
+
const before = state.references.length;
|
|
544
|
+
state.references = state.references.filter((reference) => activeIds.has(reference.id));
|
|
545
|
+
if (state.references.length !== before) {
|
|
546
|
+
renderReferenceOutlines();
|
|
547
|
+
if (showStatus) setStatus('References synced with comment text.');
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function addPendingComment(text, elements, status) {
|
|
552
|
+
const id = 'comment-' + state.nextCommentId++;
|
|
553
|
+
state.pendingComments.push({
|
|
554
|
+
id,
|
|
555
|
+
text,
|
|
556
|
+
elements,
|
|
557
|
+
status,
|
|
558
|
+
});
|
|
559
|
+
renderCommentThread();
|
|
560
|
+
return id;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function updatePendingCommentStatus(id, status) {
|
|
564
|
+
const comment = state.pendingComments.find((item) => item.id === id);
|
|
565
|
+
if (!comment) return;
|
|
566
|
+
comment.status = status;
|
|
567
|
+
renderCommentThread();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function markPendingCommentsDone() {
|
|
571
|
+
let changed = false;
|
|
572
|
+
state.pendingComments.forEach((comment) => {
|
|
573
|
+
if (comment.status === 'sent' || comment.status === 'sending') {
|
|
574
|
+
comment.status = 'done';
|
|
575
|
+
changed = true;
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
if (changed) renderCommentThread();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function renderCommentThread() {
|
|
582
|
+
els.commentThread.textContent = '';
|
|
583
|
+
state.pendingComments.forEach((comment) => {
|
|
584
|
+
const bubble = document.createElement('div');
|
|
585
|
+
bubble.className = 'comment-bubble ' + comment.status;
|
|
586
|
+
|
|
587
|
+
const text = document.createElement('div');
|
|
588
|
+
text.className = 'comment-bubble-text';
|
|
589
|
+
text.textContent = comment.text;
|
|
590
|
+
|
|
591
|
+
const status = document.createElement('div');
|
|
592
|
+
status.className = 'comment-bubble-state';
|
|
593
|
+
status.textContent = commentStatusLabel(comment.status);
|
|
594
|
+
|
|
595
|
+
bubble.appendChild(text);
|
|
596
|
+
bubble.appendChild(status);
|
|
597
|
+
els.commentThread.appendChild(bubble);
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function commentStatusLabel(status) {
|
|
602
|
+
if (status === 'done') return '✅ Applied';
|
|
603
|
+
if (status === 'failed') return 'Failed to send';
|
|
604
|
+
if (status === 'sending') return 'Sending to OpenCode...';
|
|
605
|
+
return '⏳ Sent to OpenCode';
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function targetFromPointer(event) {
|
|
609
|
+
const doc = els.frame.contentDocument;
|
|
610
|
+
if (!doc || doc.location.href === 'about:blank') return null;
|
|
611
|
+
const frameRect = els.frame.getBoundingClientRect();
|
|
612
|
+
const x = event.clientX - frameRect.left;
|
|
613
|
+
const y = event.clientY - frameRect.top;
|
|
614
|
+
if (x < 0 || y < 0 || x > frameRect.width || y > frameRect.height) return null;
|
|
615
|
+
return doc.elementFromPoint(x, y);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function createOutline(doc, border, fill) {
|
|
619
|
+
const outline = doc.createElement('div');
|
|
620
|
+
outline.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;border:2px solid ' + border + ';background:' + fill + ';border-radius:6px;display:none;';
|
|
621
|
+
doc.body.appendChild(outline);
|
|
622
|
+
return outline;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function renderBox(outline, target) {
|
|
626
|
+
if (!outline || !target || !target.getBoundingClientRect) {
|
|
627
|
+
if (outline) outline.style.display = 'none';
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
const rect = target.getBoundingClientRect();
|
|
631
|
+
outline.style.display = 'block';
|
|
632
|
+
outline.style.left = rect.left + 'px';
|
|
633
|
+
outline.style.top = rect.top + 'px';
|
|
634
|
+
outline.style.width = rect.width + 'px';
|
|
635
|
+
outline.style.height = rect.height + 'px';
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function renderHoverOutline(target) {
|
|
639
|
+
renderBox(state.hoverOutline, target);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function renderReferenceOutlines() {
|
|
643
|
+
const doc = els.frame.contentDocument;
|
|
644
|
+
if (!doc || doc.location.href === 'about:blank') return;
|
|
645
|
+
while (state.referenceOutlines.length < state.references.length) state.referenceOutlines.push(createOutline(doc, '#f59e0b', 'rgba(245,158,11,.16)'));
|
|
646
|
+
state.referenceOutlines.forEach((outline, index) => renderBox(outline, state.references[index]?.target));
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function clearHover() {
|
|
650
|
+
state.hoverEl = null;
|
|
651
|
+
setStatus('Hover cleared. Existing references are kept.');
|
|
652
|
+
if (state.hoverOutline) state.hoverOutline.style.display = 'none';
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function updateSendState() {
|
|
656
|
+
els.send.disabled = !getCommentText().trim();
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function nextReferenceLabel(payload) {
|
|
660
|
+
return humanElementName(payload) + ' ' + (state.references.length + 1);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function insertReferenceChip(reference) {
|
|
664
|
+
const chip = document.createElement('span');
|
|
665
|
+
chip.className = 'ref-chip';
|
|
666
|
+
chip.contentEditable = 'false';
|
|
667
|
+
chip.dataset.refId = reference.id;
|
|
668
|
+
chip.textContent = '@' + reference.label;
|
|
669
|
+
const trailingSpace = document.createTextNode(' ');
|
|
670
|
+
const range = getCommentInsertRange();
|
|
671
|
+
if (range) {
|
|
672
|
+
range.insertNode(trailingSpace);
|
|
673
|
+
range.insertNode(chip);
|
|
674
|
+
range.setStartAfter(trailingSpace);
|
|
675
|
+
range.collapse(true);
|
|
676
|
+
applyCommentRange(range);
|
|
677
|
+
} else {
|
|
678
|
+
if (els.comment.textContent && !/\\s$/.test(els.comment.textContent)) els.comment.appendChild(document.createTextNode(' '));
|
|
679
|
+
els.comment.appendChild(chip);
|
|
680
|
+
els.comment.appendChild(trailingSpace);
|
|
681
|
+
placeCaretAfter(trailingSpace);
|
|
682
|
+
}
|
|
683
|
+
els.comment.focus();
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function removeReferenceChip(id) {
|
|
687
|
+
const chip = els.comment.querySelector('.ref-chip[data-ref-id="' + cssEscape(id) + '"]');
|
|
688
|
+
if (!chip) return;
|
|
689
|
+
const next = chip.nextSibling;
|
|
690
|
+
chip.remove();
|
|
691
|
+
if (next && next.nodeType === Node.TEXT_NODE && next.textContent === ' ') next.remove();
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function clearReferences(removeChips) {
|
|
695
|
+
state.references = [];
|
|
696
|
+
if (removeChips) els.comment.querySelectorAll('.ref-chip').forEach((chip) => chip.remove());
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function getCommentText() {
|
|
700
|
+
return (els.comment.innerText || els.comment.textContent || '').replace(/\\u00a0/g, ' ');
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function placeCaretAfter(node) {
|
|
704
|
+
const range = document.createRange();
|
|
705
|
+
range.setStartAfter(node);
|
|
706
|
+
range.collapse(true);
|
|
707
|
+
applyCommentRange(range);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function saveCommentRange() {
|
|
711
|
+
const selection = window.getSelection();
|
|
712
|
+
if (!selection || selection.rangeCount === 0) return;
|
|
713
|
+
if (!els.comment || !els.comment.contains(selection.anchorNode)) return;
|
|
714
|
+
state.commentRange = selection.getRangeAt(0).cloneRange();
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function getCommentInsertRange() {
|
|
718
|
+
const selection = window.getSelection();
|
|
719
|
+
if (selection && selection.rangeCount > 0 && els.comment.contains(selection.anchorNode)) {
|
|
720
|
+
const range = selection.getRangeAt(0).cloneRange();
|
|
721
|
+
range.deleteContents();
|
|
722
|
+
return range;
|
|
723
|
+
}
|
|
724
|
+
if (state.commentRange && els.comment.contains(state.commentRange.commonAncestorContainer)) {
|
|
725
|
+
const range = state.commentRange.cloneRange();
|
|
726
|
+
range.deleteContents();
|
|
727
|
+
return range;
|
|
728
|
+
}
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function applyCommentRange(range) {
|
|
733
|
+
const selection = window.getSelection();
|
|
734
|
+
if (!selection) return;
|
|
735
|
+
selection.removeAllRanges();
|
|
736
|
+
selection.addRange(range);
|
|
737
|
+
state.commentRange = range.cloneRange();
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function humanElementName(payload) {
|
|
741
|
+
const tag = payload.tagName || 'element';
|
|
742
|
+
const classes = payload.classList || [];
|
|
743
|
+
if (/^h[1-6]$/.test(tag)) return 'Heading';
|
|
744
|
+
if (tag === 'p') return 'Text block';
|
|
745
|
+
if (classes.some((name) => /card/i.test(name))) return 'Card';
|
|
746
|
+
if (classes.some((name) => /stat|metric|value/i.test(name))) return 'Metric';
|
|
747
|
+
if (tag === 'img' || tag === 'svg') return 'Visual';
|
|
748
|
+
return 'Element';
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function collectPayload(el) {
|
|
752
|
+
const doc = els.frame.contentDocument;
|
|
753
|
+
const slides = getSlides(doc);
|
|
754
|
+
const slide = findSlide(el);
|
|
755
|
+
const rect = el.getBoundingClientRect();
|
|
756
|
+
const slideIndex = slide ? slides.indexOf(slide) + 1 : undefined;
|
|
757
|
+
const win = els.frame.contentWindow;
|
|
758
|
+
return {
|
|
759
|
+
slideIndex,
|
|
760
|
+
slideTitle: slide ? ((slide.querySelector('h1,h2,h3,[data-title]') || {}).textContent || '').trim().slice(0, 160) : undefined,
|
|
761
|
+
selector: buildSelector(el, slide),
|
|
762
|
+
domPath: buildDomPath(el, slide),
|
|
763
|
+
tagName: el.tagName.toLowerCase(),
|
|
764
|
+
id: el.id || undefined,
|
|
765
|
+
classList: Array.from(el.classList || []),
|
|
766
|
+
text: (el.innerText || el.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 600),
|
|
767
|
+
outerHTMLExcerpt: (el.outerHTML || '').replace(/\\s+/g, ' ').slice(0, 1200),
|
|
768
|
+
nearbyText: slide ? (slide.innerText || slide.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 1200) : undefined,
|
|
769
|
+
boundingBox: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
770
|
+
viewport: { width: win ? win.innerWidth : undefined, height: win ? win.innerHeight : undefined },
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function buildSelector(el, slide) {
|
|
775
|
+
if (el.id) return '#' + cssEscape(el.id);
|
|
776
|
+
const parts = [];
|
|
777
|
+
let node = el;
|
|
778
|
+
while (node && node.nodeType === 1 && node !== slide) {
|
|
779
|
+
let part = node.tagName.toLowerCase();
|
|
780
|
+
const stable = Array.from(node.attributes || []).find((attr) => attr.name.startsWith('data-'));
|
|
781
|
+
if (stable) {
|
|
782
|
+
part += '[' + stable.name + '="' + stable.value.replace(/"/g, '\\"') + '"]';
|
|
783
|
+
parts.unshift(part);
|
|
784
|
+
break;
|
|
785
|
+
}
|
|
786
|
+
const classes = Array.from(node.classList || []).slice(0, 2).map(cssEscape);
|
|
787
|
+
if (classes.length) part += '.' + classes.join('.');
|
|
788
|
+
const siblings = Array.from(node.parentElement ? node.parentElement.children : []).filter((child) => child.tagName === node.tagName);
|
|
789
|
+
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
|
|
790
|
+
parts.unshift(part);
|
|
791
|
+
node = node.parentElement;
|
|
792
|
+
}
|
|
793
|
+
const slidePart = slide ? slideSelector(slide) : '.slide';
|
|
794
|
+
return [slidePart].concat(parts).join(' > ');
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function findSlide(node) {
|
|
798
|
+
return node.closest('.slide, [slide-qa], .slide-canvas, .page');
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function getSlides(doc) {
|
|
802
|
+
const slides = Array.from(doc.querySelectorAll('.slide'));
|
|
803
|
+
if (slides.length) return slides;
|
|
804
|
+
const qaSlides = Array.from(doc.querySelectorAll('[slide-qa]'));
|
|
805
|
+
if (qaSlides.length) return qaSlides;
|
|
806
|
+
const canvases = Array.from(doc.querySelectorAll('.slide-canvas'));
|
|
807
|
+
if (canvases.length) return canvases;
|
|
808
|
+
return Array.from(doc.querySelectorAll('.page'));
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function slideSelector(slide) {
|
|
812
|
+
if (slide.id) return '#' + cssEscape(slide.id);
|
|
813
|
+
const doc = els.frame.contentDocument;
|
|
814
|
+
const slides = getSlides(doc);
|
|
815
|
+
const index = slides.indexOf(slide) + 1;
|
|
816
|
+
if (slide.classList && slide.classList.contains('slide')) return '.slide:nth-of-type(' + index + ')';
|
|
817
|
+
if (slide.hasAttribute && slide.hasAttribute('slide-qa')) return '[slide-qa]:nth-of-type(' + index + ')';
|
|
818
|
+
if (slide.classList && slide.classList.contains('slide-canvas')) return '.slide-canvas:nth-of-type(' + index + ')';
|
|
819
|
+
return '.page:nth-of-type(' + index + ')';
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function buildDomPath(el, stop) {
|
|
823
|
+
const parts = [];
|
|
824
|
+
let node = el;
|
|
825
|
+
while (node && node.nodeType === 1 && node !== stop) {
|
|
826
|
+
const siblings = Array.from(node.parentElement ? node.parentElement.children : []);
|
|
827
|
+
parts.unshift(node.tagName.toLowerCase() + '[' + (siblings.indexOf(node) + 1) + ']');
|
|
828
|
+
node = node.parentElement;
|
|
829
|
+
}
|
|
830
|
+
return parts.join(' > ');
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function cssEscape(value) {
|
|
834
|
+
if (window.CSS && CSS.escape) return CSS.escape(value);
|
|
835
|
+
return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&');
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function setStatus(message) {
|
|
839
|
+
if (els.status) els.status.textContent = message;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function reportError(error) {
|
|
843
|
+
const message = error && error.message ? error.message : String(error);
|
|
844
|
+
setStatus('Editor error: ' + message);
|
|
845
|
+
console.error('[Revela edit]', error);
|
|
846
|
+
}
|
|
847
|
+
})();
|
|
848
|
+
</script>
|
|
849
|
+
</body>
|
|
850
|
+
</html>`
|
|
851
|
+
}
|
package/package.json
CHANGED
package/plugin.ts
CHANGED
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
} from "./lib/commands/domains"
|
|
46
46
|
import { handlePdf } from "./lib/commands/pdf"
|
|
47
47
|
import { handlePptx } from "./lib/commands/pptx"
|
|
48
|
+
import { handleEdit } from "./lib/commands/edit"
|
|
48
49
|
import { handleDesignsPreview } from "./lib/commands/designs-preview"
|
|
49
50
|
import {
|
|
50
51
|
parseDesignsNewArgs,
|
|
@@ -243,6 +244,10 @@ const server: Plugin = (async (pluginCtx) => {
|
|
|
243
244
|
} as any)
|
|
244
245
|
return
|
|
245
246
|
}
|
|
247
|
+
if (sub === "edit") {
|
|
248
|
+
await handleEdit(param, { client, sessionID, workspaceRoot }, send)
|
|
249
|
+
throw new Error("__REVELA_EDIT_HANDLED__")
|
|
250
|
+
}
|
|
246
251
|
if (sub === "designs" && !param) {
|
|
247
252
|
await handleDesignsList(send)
|
|
248
253
|
throw new Error("__REVELA_DESIGNS_LIST_HANDLED__")
|