@cyber-dash-tech/revela 0.7.9 → 0.8.2
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 +9 -11
- package/README.zh-CN.md +9 -11
- package/lib/agents/research-prompt.ts +4 -4
- package/lib/commands/edit.ts +3 -6
- package/lib/commands/help.ts +2 -2
- package/lib/commands/init.ts +6 -5
- package/lib/commands/review.ts +5 -8
- package/lib/decks-state.ts +58 -12
- package/lib/edit/deck-state.ts +10 -10
- package/lib/edit/open.ts +32 -7
- package/lib/edit/resolve-deck.ts +20 -75
- package/lib/edit/server.ts +207 -54
- package/package.json +1 -1
- package/plugin.ts +40 -3
- package/skill/SKILL.md +31 -26
- package/tools/decks.ts +13 -11
- package/tools/edit.ts +6 -10
- package/tools/media-batch-save.ts +1 -1
- package/tools/media-save.ts +1 -1
- package/tools/research-images-list.ts +1 -1
- package/tools/research-save.ts +10 -10
package/lib/edit/resolve-deck.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { existsSync } from "fs"
|
|
2
|
-
import {
|
|
3
|
-
import { DECKS_STATE_FILE,
|
|
1
|
+
import { existsSync, readdirSync } from "fs"
|
|
2
|
+
import { relative, resolve, sep } from "path"
|
|
3
|
+
import { DECKS_STATE_FILE, isDeckHtmlPath, workspaceDeckSlug } from "../decks-state"
|
|
4
4
|
|
|
5
5
|
export interface EditableDeck {
|
|
6
6
|
slug: string
|
|
@@ -9,68 +9,29 @@ export interface EditableDeck {
|
|
|
9
9
|
source: "decks-state" | "fallback" | "file-path"
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export function resolveEditableDeck(workspaceRoot: string, input
|
|
13
|
-
|
|
14
|
-
|
|
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 resolveDefaultDeck(workspaceRoot: string): EditableDeck {
|
|
36
|
-
if (!hasDecksState(workspaceRoot)) {
|
|
37
|
-
throw new Error(`No ${DECKS_STATE_FILE} found. Use /revela edit <deck-slug|decks/file.html>.`)
|
|
12
|
+
export function resolveEditableDeck(workspaceRoot: string, input = ""): EditableDeck {
|
|
13
|
+
if (input.trim()) {
|
|
14
|
+
throw new Error("/revela edit no longer accepts a target. It opens the only HTML deck in decks/.")
|
|
38
15
|
}
|
|
39
16
|
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const deck = state.decks[activeSlug]
|
|
44
|
-
if (!deck) throw new Error(`Active deck ${activeSlug} does not exist in ${DECKS_STATE_FILE}. Use /revela edit <target>.`)
|
|
45
|
-
return resolveDeckFile(workspaceRoot, deck.slug, deck.outputPath, "decks-state")
|
|
17
|
+
const htmlFiles = listDeckHtmlFiles(workspaceRoot)
|
|
18
|
+
if (htmlFiles.length === 0) {
|
|
19
|
+
throw new Error("No deck HTML found in decks/. Generate a deck first.")
|
|
46
20
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (decks.length === 1) {
|
|
50
|
-
const deck = decks[0]
|
|
51
|
-
return resolveDeckFile(workspaceRoot, deck.slug, deck.outputPath, "decks-state")
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (decks.length === 0) {
|
|
55
|
-
throw new Error(`${DECKS_STATE_FILE} has no decks. Use /revela edit <deck-slug|decks/file.html>.`)
|
|
21
|
+
if (htmlFiles.length > 1) {
|
|
22
|
+
throw new Error("This workspace contains multiple deck HTML files. Revela 0.8 expects one deck per workspace. Move extra decks to separate workspaces.")
|
|
56
23
|
}
|
|
57
24
|
|
|
58
|
-
|
|
25
|
+
return resolveDeckFile(workspaceRoot, workspaceDeckSlug(workspaceRoot), htmlFiles[0], "file-path")
|
|
59
26
|
}
|
|
60
27
|
|
|
61
|
-
function
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
throw new Error("/revela edit file paths must point to decks/*.html.")
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const slug = normalizeSlug(basename(normalized, ".html"))
|
|
72
|
-
if (!slug) throw new Error("Deck target must be a deck slug or decks/*.html path.")
|
|
73
|
-
return resolveDeckFile(workspaceRoot, slug, normalized, "file-path")
|
|
28
|
+
function listDeckHtmlFiles(workspaceRoot: string): string[] {
|
|
29
|
+
const dir = resolve(workspaceRoot, "decks")
|
|
30
|
+
if (!existsSync(dir)) return []
|
|
31
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
32
|
+
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".html"))
|
|
33
|
+
.map((entry) => `decks/${entry.name}`)
|
|
34
|
+
.sort((a, b) => a.localeCompare(b))
|
|
74
35
|
}
|
|
75
36
|
|
|
76
37
|
function resolveDeckFile(
|
|
@@ -93,7 +54,7 @@ function resolveDeckFile(
|
|
|
93
54
|
}
|
|
94
55
|
|
|
95
56
|
return {
|
|
96
|
-
slug
|
|
57
|
+
slug,
|
|
97
58
|
file: workspaceRelative(root, absoluteFile),
|
|
98
59
|
absoluteFile,
|
|
99
60
|
source,
|
|
@@ -107,19 +68,3 @@ function isInside(root: string, target: string): boolean {
|
|
|
107
68
|
function workspaceRelative(root: string, target: string): string {
|
|
108
69
|
return relative(root, target).split(sep).join("/")
|
|
109
70
|
}
|
|
110
|
-
|
|
111
|
-
function looksLikePath(value: string): boolean {
|
|
112
|
-
return value.includes("/") || value.includes("\\") || value.endsWith(".html")
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function isAbsoluteLike(value: string): boolean {
|
|
116
|
-
return value.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(value)
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function normalizePath(value: string): string {
|
|
120
|
-
return value.replace(/\\/g, "/")
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function normalizeSlug(value: string): string {
|
|
124
|
-
return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
|
|
125
|
-
}
|
package/lib/edit/server.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { randomBytes } from "crypto"
|
|
2
2
|
import { readFileSync, statSync } from "fs"
|
|
3
|
+
import { resolve, sep } from "path"
|
|
3
4
|
import type { EditableDeck } from "./resolve-deck"
|
|
4
5
|
import { buildEditPrompt, type EditCommentPayload } from "./prompt"
|
|
5
6
|
|
|
6
7
|
const TOKEN_BYTES = 24
|
|
7
8
|
const SESSION_TTL_MS = 2 * 60 * 60 * 1000
|
|
8
9
|
const IDLE_STOP_MS = 30 * 60 * 1000
|
|
10
|
+
export const LIVE_EDITOR_IDLE_MS = 10 * 1000
|
|
9
11
|
|
|
10
12
|
interface EditSession {
|
|
11
13
|
token: string
|
|
@@ -20,7 +22,13 @@ interface EditSession {
|
|
|
20
22
|
|
|
21
23
|
export interface EditServerHandle {
|
|
22
24
|
baseUrl: string
|
|
23
|
-
|
|
25
|
+
getOrCreateSession(input: { client: any; sessionID: string; deck: EditableDeck }): EditServerSessionResult
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface EditServerSessionResult {
|
|
29
|
+
token: string
|
|
30
|
+
reused: boolean
|
|
31
|
+
live: boolean
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
let server: ReturnType<typeof Bun.serve> | undefined
|
|
@@ -41,8 +49,21 @@ export function startEditServer(): EditServerHandle {
|
|
|
41
49
|
|
|
42
50
|
return {
|
|
43
51
|
baseUrl,
|
|
44
|
-
|
|
52
|
+
getOrCreateSession(input) {
|
|
45
53
|
cleanupExpiredSessions()
|
|
54
|
+
const existing = findSessionForDeck(input.deck.absoluteFile)
|
|
55
|
+
if (existing) {
|
|
56
|
+
existing.session.client = input.client
|
|
57
|
+
existing.session.sessionID = input.sessionID
|
|
58
|
+
existing.session.deck = input.deck.slug
|
|
59
|
+
existing.session.file = input.deck.file
|
|
60
|
+
return {
|
|
61
|
+
token: existing.token,
|
|
62
|
+
reused: true,
|
|
63
|
+
live: isSessionLive(existing.session),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
46
67
|
const token = randomBytes(TOKEN_BYTES).toString("base64url")
|
|
47
68
|
sessions.set(token, {
|
|
48
69
|
token,
|
|
@@ -54,11 +75,47 @@ export function startEditServer(): EditServerHandle {
|
|
|
54
75
|
createdAt: Date.now(),
|
|
55
76
|
lastActiveAt: Date.now(),
|
|
56
77
|
})
|
|
57
|
-
return token
|
|
78
|
+
return { token, reused: false, live: false }
|
|
58
79
|
},
|
|
59
80
|
}
|
|
60
81
|
}
|
|
61
82
|
|
|
83
|
+
export function hasLiveEditorSession(deck: EditableDeck, maxIdleMs = LIVE_EDITOR_IDLE_MS): boolean {
|
|
84
|
+
cleanupExpiredSessions()
|
|
85
|
+
const existing = findSessionForDeck(deck.absoluteFile)
|
|
86
|
+
return existing ? isSessionLive(existing.session, maxIdleMs) : false
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function hasLiveEditorSessionForFile(workspaceRoot: string, filePath: string, maxIdleMs = LIVE_EDITOR_IDLE_MS): boolean {
|
|
90
|
+
if (!filePath) return false
|
|
91
|
+
const root = resolve(workspaceRoot)
|
|
92
|
+
const absoluteFile = resolve(root, filePath)
|
|
93
|
+
if (absoluteFile !== root && !absoluteFile.startsWith(root.endsWith(sep) ? root : root + sep)) return false
|
|
94
|
+
cleanupExpiredSessions()
|
|
95
|
+
const existing = findSessionForDeck(absoluteFile)
|
|
96
|
+
return existing ? isSessionLive(existing.session, maxIdleMs) : false
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function stopEditServer(): void {
|
|
100
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
101
|
+
idleTimer = undefined
|
|
102
|
+
sessions.clear()
|
|
103
|
+
server?.stop()
|
|
104
|
+
server = undefined
|
|
105
|
+
baseUrl = ""
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function findSessionForDeck(absoluteFile: string): { token: string; session: EditSession } | undefined {
|
|
109
|
+
for (const [token, session] of sessions) {
|
|
110
|
+
if (session.absoluteFile === absoluteFile) return { token, session }
|
|
111
|
+
}
|
|
112
|
+
return undefined
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isSessionLive(session: EditSession, maxIdleMs = LIVE_EDITOR_IDLE_MS): boolean {
|
|
116
|
+
return Date.now() - session.lastActiveAt <= maxIdleMs
|
|
117
|
+
}
|
|
118
|
+
|
|
62
119
|
async function handleRequest(req: Request): Promise<Response> {
|
|
63
120
|
cleanupExpiredSessions()
|
|
64
121
|
const url = new URL(req.url)
|
|
@@ -156,7 +213,7 @@ function validateSession(token: string | null): { ok: true; value: EditSession }
|
|
|
156
213
|
if (!token) return { ok: false, response: textResponse("Missing token", 401) }
|
|
157
214
|
const session = sessions.get(token)
|
|
158
215
|
if (!session) return { ok: false, response: textResponse("Invalid or expired token", 401) }
|
|
159
|
-
if (Date.now() - session.
|
|
216
|
+
if (Date.now() - session.lastActiveAt > SESSION_TTL_MS) {
|
|
160
217
|
sessions.delete(token)
|
|
161
218
|
return { ok: false, response: textResponse("Expired token", 401) }
|
|
162
219
|
}
|
|
@@ -167,7 +224,7 @@ function validateSession(token: string | null): { ok: true; value: EditSession }
|
|
|
167
224
|
function cleanupExpiredSessions(): void {
|
|
168
225
|
const now = Date.now()
|
|
169
226
|
for (const [token, session] of sessions) {
|
|
170
|
-
if (now - session.
|
|
227
|
+
if (now - session.lastActiveAt > SESSION_TTL_MS) sessions.delete(token)
|
|
171
228
|
}
|
|
172
229
|
}
|
|
173
230
|
|
|
@@ -212,7 +269,7 @@ function jsonResponse(body: unknown, status = 200): Response {
|
|
|
212
269
|
})
|
|
213
270
|
}
|
|
214
271
|
|
|
215
|
-
function renderEditorShell(token: string): string {
|
|
272
|
+
export function renderEditorShell(token: string): string {
|
|
216
273
|
const encodedToken = JSON.stringify(token)
|
|
217
274
|
return `<!doctype html>
|
|
218
275
|
<html lang="en">
|
|
@@ -221,50 +278,57 @@ function renderEditorShell(token: string): string {
|
|
|
221
278
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
222
279
|
<title>Revela Edit</title>
|
|
223
280
|
<style>
|
|
224
|
-
:root { color-scheme:
|
|
281
|
+
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
225
282
|
* { box-sizing: border-box; }
|
|
226
|
-
body { margin: 0; background: #
|
|
227
|
-
.
|
|
228
|
-
.
|
|
229
|
-
|
|
283
|
+
body { margin: 0; background: #f6f8fb; color: #172033; height: 100vh; overflow: hidden; }
|
|
284
|
+
body.resizing { cursor: col-resize; user-select: none; }
|
|
285
|
+
body.resizing iframe, body.resizing .hitbox { pointer-events: none; }
|
|
286
|
+
.app { --editor-width: 376px; position: relative; display: grid; grid-template-columns: minmax(0, 1fr) var(--editor-width); height: 100vh; }
|
|
287
|
+
.preview { position: relative; min-width: 0; background: #eef3f8; }
|
|
288
|
+
.resize-handle { position: absolute; top: 0; bottom: 0; right: calc(var(--editor-width) - 7px); width: 14px; z-index: 5; cursor: col-resize; background: transparent; }
|
|
289
|
+
.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; }
|
|
290
|
+
.resize-handle:hover::before, body.resizing .resize-handle::before { height: 52px; background: #94a3b8; box-shadow: 0 0 0 4px rgba(148,163,184,.16); }
|
|
291
|
+
iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
|
|
230
292
|
.hitbox { position: absolute; inset: 0; z-index: 2; cursor: crosshair; background: transparent; }
|
|
231
|
-
aside { display: flex; flex-direction: column; gap: 16px; padding:
|
|
232
|
-
h1 { margin: 0; font-size: 18px; line-height: 1.2; }
|
|
233
|
-
.
|
|
293
|
+
aside { display: flex; flex-direction: column; gap: 16px; padding: 20px; background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); }
|
|
294
|
+
h1 { margin: 0; font-size: 18px; line-height: 1.2; letter-spacing: -.01em; color: #0f172a; }
|
|
295
|
+
.wordmark { font-family: Garamond, "Iowan Old Style", Georgia, serif; font-size: 21px; letter-spacing: .08em; font-weight: 600; }
|
|
296
|
+
.hint { margin: 0; color: #64748b; font-size: 13px; line-height: 1.5; }
|
|
234
297
|
.panel { display: flex; flex-direction: column; gap: 10px; }
|
|
235
|
-
.label { color: #
|
|
236
|
-
.comment-editor { width: 100%; min-height:
|
|
237
|
-
.comment-editor:focus { border-color: #
|
|
238
|
-
.comment-editor:empty::before { content: attr(data-placeholder); color: #
|
|
239
|
-
.ref-chip { display: inline-flex; align-items: center; margin: 0 2px; padding: 1px
|
|
298
|
+
.label { color: #64748b; font-size: 11px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
|
|
299
|
+
.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); }
|
|
300
|
+
.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); }
|
|
301
|
+
.comment-editor:empty::before { content: attr(data-placeholder); color: #94a3b8; pointer-events: none; }
|
|
302
|
+
.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; }
|
|
240
303
|
.comment-thread { display: flex; flex-direction: column; gap: 10px; max-height: 30vh; overflow: auto; }
|
|
241
|
-
.comment-bubble { border: 1px solid #
|
|
242
|
-
.comment-bubble.sending { border-color:
|
|
243
|
-
.comment-bubble.
|
|
244
|
-
.comment-bubble.stale { border-color:
|
|
245
|
-
.comment-bubble.failed { border-color:
|
|
304
|
+
.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); }
|
|
305
|
+
.comment-bubble.sending { border-color: #93c5fd; background: #eff6ff; }
|
|
306
|
+
.comment-bubble.updated { border-color: #86efac; background: #f0fdf4; }
|
|
307
|
+
.comment-bubble.stale { border-color: #facc15; background: #fefce8; }
|
|
308
|
+
.comment-bubble.failed { border-color: #fca5a5; background: #fef2f2; }
|
|
246
309
|
.comment-bubble-text { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
247
|
-
.comment-bubble-state { margin-top: 8px; color: #
|
|
248
|
-
.comment-bubble.
|
|
249
|
-
.comment-bubble.stale .comment-bubble-state { color: #
|
|
250
|
-
.comment-bubble.failed .comment-bubble-state { color: #
|
|
251
|
-
button { width: 100%; padding: 12px 14px; border: 0; border-radius: 12px; background: #
|
|
310
|
+
.comment-bubble-state { margin-top: 8px; color: #2563eb; font-size: 12px; font-weight: 800; }
|
|
311
|
+
.comment-bubble.updated .comment-bubble-state { color: #15803d; }
|
|
312
|
+
.comment-bubble.stale .comment-bubble-state { color: #a16207; }
|
|
313
|
+
.comment-bubble.failed .comment-bubble-state { color: #b91c1c; }
|
|
314
|
+
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); }
|
|
252
315
|
button:disabled { cursor: not-allowed; opacity: .5; }
|
|
253
|
-
.status { min-height: 20px; color: #
|
|
254
|
-
@media (max-width: 900px) { .app { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; } aside { max-height: 48vh; } }
|
|
316
|
+
.status { min-height: 20px; color: #475569; font-size: 13px; line-height: 1.45; }
|
|
317
|
+
@media (max-width: 900px) { .app { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; } .resize-handle { display: none; } aside { max-height: 48vh; } }
|
|
255
318
|
</style>
|
|
256
319
|
</head>
|
|
257
320
|
<body>
|
|
258
321
|
<main class="app">
|
|
259
322
|
<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>
|
|
323
|
+
<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>
|
|
260
324
|
<aside>
|
|
261
325
|
<div>
|
|
262
|
-
<h1>
|
|
263
|
-
<p class="hint">
|
|
326
|
+
<h1><span class="wordmark">REVELA</span> Editor</h1>
|
|
327
|
+
<p class="hint">Refine your deck with precise visual comments. Cmd/Ctrl-click any slide element to attach it as a reference, then describe the change you want.</p>
|
|
264
328
|
</div>
|
|
265
329
|
<div class="panel">
|
|
266
330
|
<div class="label">Comment</div>
|
|
267
|
-
<div id="comment" class="comment-editor" contenteditable="true" role="textbox" aria-multiline="true" data-placeholder="Example:
|
|
331
|
+
<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>
|
|
268
332
|
</div>
|
|
269
333
|
<div id="commentThread" class="comment-thread" aria-live="polite"></div>
|
|
270
334
|
<button id="send" disabled>Send comments</button>
|
|
@@ -275,6 +339,22 @@ function renderEditorShell(token: string): string {
|
|
|
275
339
|
(() => {
|
|
276
340
|
const token = ${encodedToken};
|
|
277
341
|
const COMMENT_STALE_MS = 60000;
|
|
342
|
+
const EDITOR_WIDTH_KEY = 'revela-edit-editor-width';
|
|
343
|
+
const DEFAULT_EDITOR_WIDTH = 376;
|
|
344
|
+
const MIN_EDITOR_WIDTH = 320;
|
|
345
|
+
const MAX_EDITOR_WIDTH = 620;
|
|
346
|
+
const REFERENCE_COLORS = [
|
|
347
|
+
{ border: '#7aa6d8', fill: 'rgba(122,166,216,.18)', bg: '#eaf2fb', text: '#244f78' },
|
|
348
|
+
{ border: '#a99bd9', fill: 'rgba(169,155,217,.18)', bg: '#f1eefb', text: '#574985' },
|
|
349
|
+
{ border: '#83b99a', fill: 'rgba(131,185,154,.18)', bg: '#edf7f1', text: '#2f6848' },
|
|
350
|
+
{ border: '#d7a775', fill: 'rgba(215,167,117,.18)', bg: '#fbf1e7', text: '#7a4d22' },
|
|
351
|
+
{ border: '#d493b0', fill: 'rgba(212,147,176,.18)', bg: '#faedf3', text: '#7b3f5b' },
|
|
352
|
+
{ border: '#73b8bd', fill: 'rgba(115,184,189,.18)', bg: '#e8f6f7', text: '#285f64' },
|
|
353
|
+
{ border: '#c7b46e', fill: 'rgba(199,180,110,.18)', bg: '#f8f3df', text: '#6b5b1e' },
|
|
354
|
+
{ border: '#9eb27e', fill: 'rgba(158,178,126,.18)', bg: '#f1f6e9', text: '#4f642e' },
|
|
355
|
+
{ border: '#c08fc8', fill: 'rgba(192,143,200,.18)', bg: '#f7edf8', text: '#6b3f73' },
|
|
356
|
+
{ border: '#8fa7c9', fill: 'rgba(143,167,201,.18)', bg: '#eef3fa', text: '#405a7b' },
|
|
357
|
+
];
|
|
278
358
|
const state = {
|
|
279
359
|
references: [],
|
|
280
360
|
pendingComments: [],
|
|
@@ -288,10 +368,12 @@ function renderEditorShell(token: string): string {
|
|
|
288
368
|
pendingRefreshMessage: false,
|
|
289
369
|
bound: false,
|
|
290
370
|
commentRange: null,
|
|
371
|
+
resizeDrag: null,
|
|
291
372
|
};
|
|
292
373
|
const els = {
|
|
293
374
|
frame: null,
|
|
294
375
|
hitbox: null,
|
|
376
|
+
resizeHandle: null,
|
|
295
377
|
comment: null,
|
|
296
378
|
commentThread: null,
|
|
297
379
|
send: null,
|
|
@@ -311,15 +393,17 @@ function renderEditorShell(token: string): string {
|
|
|
311
393
|
try {
|
|
312
394
|
els.frame = document.getElementById('deck');
|
|
313
395
|
els.hitbox = document.getElementById('hitbox');
|
|
396
|
+
els.resizeHandle = document.getElementById('resizeHandle');
|
|
314
397
|
els.comment = document.getElementById('comment');
|
|
315
398
|
els.commentThread = document.getElementById('commentThread');
|
|
316
399
|
els.send = document.getElementById('send');
|
|
317
400
|
els.status = document.getElementById('status');
|
|
318
401
|
|
|
319
|
-
if (!els.frame || !els.hitbox || !els.comment || !els.commentThread || !els.send || !els.status) {
|
|
402
|
+
if (!els.frame || !els.hitbox || !els.resizeHandle || !els.comment || !els.commentThread || !els.send || !els.status) {
|
|
320
403
|
throw new Error('Editor boot failed: required DOM nodes are missing.');
|
|
321
404
|
}
|
|
322
405
|
|
|
406
|
+
restoreEditorWidth();
|
|
323
407
|
bindEvents();
|
|
324
408
|
setStatus('Editor ready. Ctrl/Cmd + click deck elements to reference them.');
|
|
325
409
|
initFrame();
|
|
@@ -358,9 +442,59 @@ function renderEditorShell(token: string): string {
|
|
|
358
442
|
renderHoverOutline(state.hoverEl);
|
|
359
443
|
renderReferenceOutlines();
|
|
360
444
|
}, { passive: false });
|
|
445
|
+
els.resizeHandle.addEventListener('pointerdown', startEditorResize);
|
|
446
|
+
els.resizeHandle.addEventListener('dblclick', resetEditorWidth);
|
|
361
447
|
els.send.addEventListener('click', sendComment);
|
|
362
448
|
}
|
|
363
449
|
|
|
450
|
+
function restoreEditorWidth() {
|
|
451
|
+
try {
|
|
452
|
+
const saved = Number(window.localStorage.getItem(EDITOR_WIDTH_KEY));
|
|
453
|
+
setEditorWidth(Number.isFinite(saved) ? saved : DEFAULT_EDITOR_WIDTH, false);
|
|
454
|
+
} catch {
|
|
455
|
+
setEditorWidth(DEFAULT_EDITOR_WIDTH, false);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function startEditorResize(event) {
|
|
460
|
+
event.preventDefault();
|
|
461
|
+
const currentWidth = Number.parseFloat(getComputedStyle(document.querySelector('.app')).getPropertyValue('--editor-width')) || DEFAULT_EDITOR_WIDTH;
|
|
462
|
+
state.resizeDrag = { startX: event.clientX, startWidth: currentWidth };
|
|
463
|
+
document.body.classList.add('resizing');
|
|
464
|
+
els.resizeHandle.setPointerCapture?.(event.pointerId);
|
|
465
|
+
window.addEventListener('pointermove', resizeEditor);
|
|
466
|
+
window.addEventListener('pointerup', stopEditorResize, { once: true });
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function resizeEditor(event) {
|
|
470
|
+
if (!state.resizeDrag) return;
|
|
471
|
+
const nextWidth = state.resizeDrag.startWidth + state.resizeDrag.startX - event.clientX;
|
|
472
|
+
setEditorWidth(nextWidth, true);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function stopEditorResize() {
|
|
476
|
+
state.resizeDrag = null;
|
|
477
|
+
document.body.classList.remove('resizing');
|
|
478
|
+
window.removeEventListener('pointermove', resizeEditor);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function resetEditorWidth() {
|
|
482
|
+
setEditorWidth(DEFAULT_EDITOR_WIDTH, true);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function setEditorWidth(width, persist) {
|
|
486
|
+
const nextWidth = clampEditorWidth(width);
|
|
487
|
+
document.querySelector('.app')?.style.setProperty('--editor-width', nextWidth + 'px');
|
|
488
|
+
if (!persist) return;
|
|
489
|
+
try {
|
|
490
|
+
window.localStorage.setItem(EDITOR_WIDTH_KEY, String(nextWidth));
|
|
491
|
+
} catch {}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function clampEditorWidth(width) {
|
|
495
|
+
return Math.min(MAX_EDITOR_WIDTH, Math.max(MIN_EDITOR_WIDTH, Math.round(width)));
|
|
496
|
+
}
|
|
497
|
+
|
|
364
498
|
function initFrame() {
|
|
365
499
|
try {
|
|
366
500
|
const doc = els.frame.contentDocument;
|
|
@@ -406,6 +540,7 @@ function renderEditorShell(token: string): string {
|
|
|
406
540
|
const nextVersion = body.version || (String(body.mtimeMs) + ':' + String(body.size));
|
|
407
541
|
if (!state.deckVersion) {
|
|
408
542
|
state.deckVersion = nextVersion;
|
|
543
|
+
markCommentsUpdatedForVersion(nextVersion);
|
|
409
544
|
markStaleComments();
|
|
410
545
|
return;
|
|
411
546
|
}
|
|
@@ -414,7 +549,7 @@ function renderEditorShell(token: string): string {
|
|
|
414
549
|
return;
|
|
415
550
|
}
|
|
416
551
|
state.deckVersion = nextVersion;
|
|
417
|
-
|
|
552
|
+
markCommentsUpdatedForVersion(nextVersion);
|
|
418
553
|
refreshDeckPreview(body.mtimeMs);
|
|
419
554
|
} catch (error) {
|
|
420
555
|
reportError(error);
|
|
@@ -497,7 +632,7 @@ function renderEditorShell(token: string): string {
|
|
|
497
632
|
const body = await res.json().catch(() => ({}));
|
|
498
633
|
if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to send comment');
|
|
499
634
|
updatePendingCommentStatus(commentId, 'sent', { baseDeckVersion: body.deckVersion || state.deckVersion });
|
|
500
|
-
setStatus('Comment sent. Waiting for deck update...');
|
|
635
|
+
if (pendingCommentStatus(commentId) !== 'updated') setStatus('Comment sent. Waiting for deck update...');
|
|
501
636
|
updateSendState();
|
|
502
637
|
} catch (error) {
|
|
503
638
|
updatePendingCommentStatus(commentId, 'failed');
|
|
@@ -525,9 +660,10 @@ function renderEditorShell(token: string): string {
|
|
|
525
660
|
return;
|
|
526
661
|
}
|
|
527
662
|
const payload = collectPayload(target);
|
|
663
|
+
const color = REFERENCE_COLORS[(state.nextReferenceId - 1) % REFERENCE_COLORS.length];
|
|
528
664
|
const id = 'ref-' + state.nextReferenceId++;
|
|
529
665
|
const label = nextReferenceLabel(payload);
|
|
530
|
-
const reference = { id, target, label, payload };
|
|
666
|
+
const reference = { id, target, label, payload, color };
|
|
531
667
|
state.references.push(reference);
|
|
532
668
|
insertReferenceChip(reference);
|
|
533
669
|
renderReferenceOutlines();
|
|
@@ -571,7 +707,7 @@ function renderEditorShell(token: string): string {
|
|
|
571
707
|
status,
|
|
572
708
|
createdAt: Date.now(),
|
|
573
709
|
baseDeckVersion: state.deckVersion,
|
|
574
|
-
|
|
710
|
+
updatedVersion: null,
|
|
575
711
|
});
|
|
576
712
|
renderCommentThread();
|
|
577
713
|
return id;
|
|
@@ -580,42 +716,46 @@ function renderEditorShell(token: string): string {
|
|
|
580
716
|
function updatePendingCommentStatus(id, status, updates) {
|
|
581
717
|
const comment = state.pendingComments.find((item) => item.id === id);
|
|
582
718
|
if (!comment) return;
|
|
583
|
-
if (comment.status === '
|
|
719
|
+
if (comment.status === 'updated' && status !== 'failed') return;
|
|
584
720
|
comment.status = status;
|
|
585
721
|
if (updates) Object.assign(comment, updates);
|
|
586
722
|
renderCommentThread();
|
|
587
723
|
}
|
|
588
724
|
|
|
589
|
-
function
|
|
725
|
+
function markCommentsUpdatedForVersion(version) {
|
|
590
726
|
let changed = false;
|
|
591
727
|
state.pendingComments.forEach((comment) => {
|
|
592
728
|
if ((comment.status === 'sent' || comment.status === 'sending' || comment.status === 'stale') && comment.baseDeckVersion !== version) {
|
|
593
|
-
comment.status = '
|
|
594
|
-
comment.
|
|
729
|
+
comment.status = 'updated';
|
|
730
|
+
comment.updatedVersion = version;
|
|
595
731
|
changed = true;
|
|
596
732
|
}
|
|
597
733
|
});
|
|
598
|
-
if (changed)
|
|
734
|
+
if (changed) {
|
|
735
|
+
renderCommentThread();
|
|
736
|
+
setStatus('Deck file updated. Preview will refresh automatically.');
|
|
737
|
+
}
|
|
599
738
|
}
|
|
600
739
|
|
|
601
740
|
function markStaleComments() {
|
|
602
741
|
const now = Date.now();
|
|
603
742
|
let changed = false;
|
|
604
|
-
|
|
605
|
-
if (comment.status !== 'sent' && comment.status !== 'sending') return
|
|
606
|
-
if (now - comment.createdAt < COMMENT_STALE_MS) return
|
|
743
|
+
state.pendingComments.forEach((comment) => {
|
|
744
|
+
if (comment.status !== 'sent' && comment.status !== 'sending') return;
|
|
745
|
+
if (now - comment.createdAt < COMMENT_STALE_MS) return;
|
|
607
746
|
comment.status = 'stale';
|
|
608
747
|
changed = true;
|
|
609
|
-
return true;
|
|
610
748
|
});
|
|
611
749
|
if (changed) {
|
|
612
750
|
renderCommentThread();
|
|
613
|
-
setStatus('Still waiting for deck file update.
|
|
614
|
-
} else if (hasWaiting) {
|
|
615
|
-
setStatus('Comment sent. Waiting for deck update...');
|
|
751
|
+
setStatus('Still waiting for deck file update. The preview will refresh automatically when the file changes.');
|
|
616
752
|
}
|
|
617
753
|
}
|
|
618
754
|
|
|
755
|
+
function pendingCommentStatus(id) {
|
|
756
|
+
return state.pendingComments.find((comment) => comment.id === id)?.status || '';
|
|
757
|
+
}
|
|
758
|
+
|
|
619
759
|
function renderCommentThread() {
|
|
620
760
|
els.commentThread.textContent = '';
|
|
621
761
|
state.pendingComments.forEach((comment) => {
|
|
@@ -637,7 +777,7 @@ function renderEditorShell(token: string): string {
|
|
|
637
777
|
}
|
|
638
778
|
|
|
639
779
|
function commentStatusLabel(status) {
|
|
640
|
-
if (status === '
|
|
780
|
+
if (status === 'updated') return 'Deck file updated';
|
|
641
781
|
if (status === 'stale') return 'Still waiting for deck file update';
|
|
642
782
|
if (status === 'failed') return 'Failed to send';
|
|
643
783
|
if (status === 'sending') return 'Sending to OpenCode...';
|
|
@@ -661,6 +801,12 @@ function renderEditorShell(token: string): string {
|
|
|
661
801
|
return outline;
|
|
662
802
|
}
|
|
663
803
|
|
|
804
|
+
function setOutlineColor(outline, color) {
|
|
805
|
+
if (!outline || !color) return;
|
|
806
|
+
outline.style.borderColor = color.border;
|
|
807
|
+
outline.style.background = color.fill;
|
|
808
|
+
}
|
|
809
|
+
|
|
664
810
|
function renderBox(outline, target) {
|
|
665
811
|
if (!outline || !target || !target.getBoundingClientRect) {
|
|
666
812
|
if (outline) outline.style.display = 'none';
|
|
@@ -681,8 +827,12 @@ function renderEditorShell(token: string): string {
|
|
|
681
827
|
function renderReferenceOutlines() {
|
|
682
828
|
const doc = els.frame.contentDocument;
|
|
683
829
|
if (!doc || doc.location.href === 'about:blank') return;
|
|
684
|
-
while (state.referenceOutlines.length < state.references.length) state.referenceOutlines.push(createOutline(doc, '#
|
|
685
|
-
state.referenceOutlines.forEach((outline, index) =>
|
|
830
|
+
while (state.referenceOutlines.length < state.references.length) state.referenceOutlines.push(createOutline(doc, '#7aa6d8', 'rgba(122,166,216,.18)'));
|
|
831
|
+
state.referenceOutlines.forEach((outline, index) => {
|
|
832
|
+
const reference = state.references[index];
|
|
833
|
+
setOutlineColor(outline, reference?.color);
|
|
834
|
+
renderBox(outline, reference?.target);
|
|
835
|
+
});
|
|
686
836
|
}
|
|
687
837
|
|
|
688
838
|
function clearHover() {
|
|
@@ -704,6 +854,9 @@ function renderEditorShell(token: string): string {
|
|
|
704
854
|
chip.className = 'ref-chip';
|
|
705
855
|
chip.contentEditable = 'false';
|
|
706
856
|
chip.dataset.refId = reference.id;
|
|
857
|
+
chip.style.setProperty('--ref-bg', reference.color.bg);
|
|
858
|
+
chip.style.setProperty('--ref-border', reference.color.border);
|
|
859
|
+
chip.style.setProperty('--ref-text', reference.color.text);
|
|
707
860
|
chip.textContent = '@' + reference.label;
|
|
708
861
|
const trailingSpace = document.createTextNode(' ');
|
|
709
862
|
const range = getCommentInsertRange();
|