@cyber-dash-tech/revela 0.7.9 → 0.8.1
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 +8 -2
- package/lib/edit/open.ts +31 -3
- package/lib/edit/resolve-deck.ts +20 -75
- package/lib/edit/server.ts +196 -54
- package/package.json +1 -1
- package/plugin.ts +37 -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
|
@@ -6,6 +6,7 @@ import { buildEditPrompt, type EditCommentPayload } from "./prompt"
|
|
|
6
6
|
const TOKEN_BYTES = 24
|
|
7
7
|
const SESSION_TTL_MS = 2 * 60 * 60 * 1000
|
|
8
8
|
const IDLE_STOP_MS = 30 * 60 * 1000
|
|
9
|
+
export const LIVE_EDITOR_IDLE_MS = 10 * 1000
|
|
9
10
|
|
|
10
11
|
interface EditSession {
|
|
11
12
|
token: string
|
|
@@ -20,7 +21,13 @@ interface EditSession {
|
|
|
20
21
|
|
|
21
22
|
export interface EditServerHandle {
|
|
22
23
|
baseUrl: string
|
|
23
|
-
|
|
24
|
+
getOrCreateSession(input: { client: any; sessionID: string; deck: EditableDeck }): EditServerSessionResult
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface EditServerSessionResult {
|
|
28
|
+
token: string
|
|
29
|
+
reused: boolean
|
|
30
|
+
live: boolean
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
let server: ReturnType<typeof Bun.serve> | undefined
|
|
@@ -41,8 +48,21 @@ export function startEditServer(): EditServerHandle {
|
|
|
41
48
|
|
|
42
49
|
return {
|
|
43
50
|
baseUrl,
|
|
44
|
-
|
|
51
|
+
getOrCreateSession(input) {
|
|
45
52
|
cleanupExpiredSessions()
|
|
53
|
+
const existing = findSessionForDeck(input.deck.absoluteFile)
|
|
54
|
+
if (existing) {
|
|
55
|
+
existing.session.client = input.client
|
|
56
|
+
existing.session.sessionID = input.sessionID
|
|
57
|
+
existing.session.deck = input.deck.slug
|
|
58
|
+
existing.session.file = input.deck.file
|
|
59
|
+
return {
|
|
60
|
+
token: existing.token,
|
|
61
|
+
reused: true,
|
|
62
|
+
live: isSessionLive(existing.session),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
46
66
|
const token = randomBytes(TOKEN_BYTES).toString("base64url")
|
|
47
67
|
sessions.set(token, {
|
|
48
68
|
token,
|
|
@@ -54,11 +74,37 @@ export function startEditServer(): EditServerHandle {
|
|
|
54
74
|
createdAt: Date.now(),
|
|
55
75
|
lastActiveAt: Date.now(),
|
|
56
76
|
})
|
|
57
|
-
return token
|
|
77
|
+
return { token, reused: false, live: false }
|
|
58
78
|
},
|
|
59
79
|
}
|
|
60
80
|
}
|
|
61
81
|
|
|
82
|
+
export function hasLiveEditorSession(deck: EditableDeck, maxIdleMs = LIVE_EDITOR_IDLE_MS): boolean {
|
|
83
|
+
cleanupExpiredSessions()
|
|
84
|
+
const existing = findSessionForDeck(deck.absoluteFile)
|
|
85
|
+
return existing ? isSessionLive(existing.session, maxIdleMs) : false
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function stopEditServer(): void {
|
|
89
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
90
|
+
idleTimer = undefined
|
|
91
|
+
sessions.clear()
|
|
92
|
+
server?.stop()
|
|
93
|
+
server = undefined
|
|
94
|
+
baseUrl = ""
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function findSessionForDeck(absoluteFile: string): { token: string; session: EditSession } | undefined {
|
|
98
|
+
for (const [token, session] of sessions) {
|
|
99
|
+
if (session.absoluteFile === absoluteFile) return { token, session }
|
|
100
|
+
}
|
|
101
|
+
return undefined
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isSessionLive(session: EditSession, maxIdleMs = LIVE_EDITOR_IDLE_MS): boolean {
|
|
105
|
+
return Date.now() - session.lastActiveAt <= maxIdleMs
|
|
106
|
+
}
|
|
107
|
+
|
|
62
108
|
async function handleRequest(req: Request): Promise<Response> {
|
|
63
109
|
cleanupExpiredSessions()
|
|
64
110
|
const url = new URL(req.url)
|
|
@@ -156,7 +202,7 @@ function validateSession(token: string | null): { ok: true; value: EditSession }
|
|
|
156
202
|
if (!token) return { ok: false, response: textResponse("Missing token", 401) }
|
|
157
203
|
const session = sessions.get(token)
|
|
158
204
|
if (!session) return { ok: false, response: textResponse("Invalid or expired token", 401) }
|
|
159
|
-
if (Date.now() - session.
|
|
205
|
+
if (Date.now() - session.lastActiveAt > SESSION_TTL_MS) {
|
|
160
206
|
sessions.delete(token)
|
|
161
207
|
return { ok: false, response: textResponse("Expired token", 401) }
|
|
162
208
|
}
|
|
@@ -167,7 +213,7 @@ function validateSession(token: string | null): { ok: true; value: EditSession }
|
|
|
167
213
|
function cleanupExpiredSessions(): void {
|
|
168
214
|
const now = Date.now()
|
|
169
215
|
for (const [token, session] of sessions) {
|
|
170
|
-
if (now - session.
|
|
216
|
+
if (now - session.lastActiveAt > SESSION_TTL_MS) sessions.delete(token)
|
|
171
217
|
}
|
|
172
218
|
}
|
|
173
219
|
|
|
@@ -212,7 +258,7 @@ function jsonResponse(body: unknown, status = 200): Response {
|
|
|
212
258
|
})
|
|
213
259
|
}
|
|
214
260
|
|
|
215
|
-
function renderEditorShell(token: string): string {
|
|
261
|
+
export function renderEditorShell(token: string): string {
|
|
216
262
|
const encodedToken = JSON.stringify(token)
|
|
217
263
|
return `<!doctype html>
|
|
218
264
|
<html lang="en">
|
|
@@ -221,50 +267,57 @@ function renderEditorShell(token: string): string {
|
|
|
221
267
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
222
268
|
<title>Revela Edit</title>
|
|
223
269
|
<style>
|
|
224
|
-
:root { color-scheme:
|
|
270
|
+
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
225
271
|
* { box-sizing: border-box; }
|
|
226
|
-
body { margin: 0; background: #
|
|
227
|
-
.
|
|
228
|
-
.
|
|
229
|
-
|
|
272
|
+
body { margin: 0; background: #f6f8fb; color: #172033; height: 100vh; overflow: hidden; }
|
|
273
|
+
body.resizing { cursor: col-resize; user-select: none; }
|
|
274
|
+
body.resizing iframe, body.resizing .hitbox { pointer-events: none; }
|
|
275
|
+
.app { --editor-width: 376px; position: relative; display: grid; grid-template-columns: minmax(0, 1fr) var(--editor-width); height: 100vh; }
|
|
276
|
+
.preview { position: relative; min-width: 0; background: #eef3f8; }
|
|
277
|
+
.resize-handle { position: absolute; top: 0; bottom: 0; right: calc(var(--editor-width) - 7px); width: 14px; z-index: 5; cursor: col-resize; background: transparent; }
|
|
278
|
+
.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; }
|
|
279
|
+
.resize-handle:hover::before, body.resizing .resize-handle::before { height: 52px; background: #94a3b8; box-shadow: 0 0 0 4px rgba(148,163,184,.16); }
|
|
280
|
+
iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
|
|
230
281
|
.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
|
-
.
|
|
282
|
+
aside { display: flex; flex-direction: column; gap: 16px; padding: 20px; background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); }
|
|
283
|
+
h1 { margin: 0; font-size: 18px; line-height: 1.2; letter-spacing: -.01em; color: #0f172a; }
|
|
284
|
+
.wordmark { font-family: Garamond, "Iowan Old Style", Georgia, serif; font-size: 21px; letter-spacing: .08em; font-weight: 600; }
|
|
285
|
+
.hint { margin: 0; color: #64748b; font-size: 13px; line-height: 1.5; }
|
|
234
286
|
.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
|
|
287
|
+
.label { color: #64748b; font-size: 11px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
|
|
288
|
+
.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); }
|
|
289
|
+
.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); }
|
|
290
|
+
.comment-editor:empty::before { content: attr(data-placeholder); color: #94a3b8; pointer-events: none; }
|
|
291
|
+
.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
292
|
.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:
|
|
293
|
+
.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); }
|
|
294
|
+
.comment-bubble.sending { border-color: #93c5fd; background: #eff6ff; }
|
|
295
|
+
.comment-bubble.updated { border-color: #86efac; background: #f0fdf4; }
|
|
296
|
+
.comment-bubble.stale { border-color: #facc15; background: #fefce8; }
|
|
297
|
+
.comment-bubble.failed { border-color: #fca5a5; background: #fef2f2; }
|
|
246
298
|
.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: #
|
|
299
|
+
.comment-bubble-state { margin-top: 8px; color: #2563eb; font-size: 12px; font-weight: 800; }
|
|
300
|
+
.comment-bubble.updated .comment-bubble-state { color: #15803d; }
|
|
301
|
+
.comment-bubble.stale .comment-bubble-state { color: #a16207; }
|
|
302
|
+
.comment-bubble.failed .comment-bubble-state { color: #b91c1c; }
|
|
303
|
+
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
304
|
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; } }
|
|
305
|
+
.status { min-height: 20px; color: #475569; font-size: 13px; line-height: 1.45; }
|
|
306
|
+
@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
307
|
</style>
|
|
256
308
|
</head>
|
|
257
309
|
<body>
|
|
258
310
|
<main class="app">
|
|
259
311
|
<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>
|
|
312
|
+
<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
313
|
<aside>
|
|
261
314
|
<div>
|
|
262
|
-
<h1>
|
|
263
|
-
<p class="hint">
|
|
315
|
+
<h1><span class="wordmark">REVELA</span> Editor</h1>
|
|
316
|
+
<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
317
|
</div>
|
|
265
318
|
<div class="panel">
|
|
266
319
|
<div class="label">Comment</div>
|
|
267
|
-
<div id="comment" class="comment-editor" contenteditable="true" role="textbox" aria-multiline="true" data-placeholder="Example:
|
|
320
|
+
<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
321
|
</div>
|
|
269
322
|
<div id="commentThread" class="comment-thread" aria-live="polite"></div>
|
|
270
323
|
<button id="send" disabled>Send comments</button>
|
|
@@ -275,6 +328,22 @@ function renderEditorShell(token: string): string {
|
|
|
275
328
|
(() => {
|
|
276
329
|
const token = ${encodedToken};
|
|
277
330
|
const COMMENT_STALE_MS = 60000;
|
|
331
|
+
const EDITOR_WIDTH_KEY = 'revela-edit-editor-width';
|
|
332
|
+
const DEFAULT_EDITOR_WIDTH = 376;
|
|
333
|
+
const MIN_EDITOR_WIDTH = 320;
|
|
334
|
+
const MAX_EDITOR_WIDTH = 620;
|
|
335
|
+
const REFERENCE_COLORS = [
|
|
336
|
+
{ border: '#7aa6d8', fill: 'rgba(122,166,216,.18)', bg: '#eaf2fb', text: '#244f78' },
|
|
337
|
+
{ border: '#a99bd9', fill: 'rgba(169,155,217,.18)', bg: '#f1eefb', text: '#574985' },
|
|
338
|
+
{ border: '#83b99a', fill: 'rgba(131,185,154,.18)', bg: '#edf7f1', text: '#2f6848' },
|
|
339
|
+
{ border: '#d7a775', fill: 'rgba(215,167,117,.18)', bg: '#fbf1e7', text: '#7a4d22' },
|
|
340
|
+
{ border: '#d493b0', fill: 'rgba(212,147,176,.18)', bg: '#faedf3', text: '#7b3f5b' },
|
|
341
|
+
{ border: '#73b8bd', fill: 'rgba(115,184,189,.18)', bg: '#e8f6f7', text: '#285f64' },
|
|
342
|
+
{ border: '#c7b46e', fill: 'rgba(199,180,110,.18)', bg: '#f8f3df', text: '#6b5b1e' },
|
|
343
|
+
{ border: '#9eb27e', fill: 'rgba(158,178,126,.18)', bg: '#f1f6e9', text: '#4f642e' },
|
|
344
|
+
{ border: '#c08fc8', fill: 'rgba(192,143,200,.18)', bg: '#f7edf8', text: '#6b3f73' },
|
|
345
|
+
{ border: '#8fa7c9', fill: 'rgba(143,167,201,.18)', bg: '#eef3fa', text: '#405a7b' },
|
|
346
|
+
];
|
|
278
347
|
const state = {
|
|
279
348
|
references: [],
|
|
280
349
|
pendingComments: [],
|
|
@@ -288,10 +357,12 @@ function renderEditorShell(token: string): string {
|
|
|
288
357
|
pendingRefreshMessage: false,
|
|
289
358
|
bound: false,
|
|
290
359
|
commentRange: null,
|
|
360
|
+
resizeDrag: null,
|
|
291
361
|
};
|
|
292
362
|
const els = {
|
|
293
363
|
frame: null,
|
|
294
364
|
hitbox: null,
|
|
365
|
+
resizeHandle: null,
|
|
295
366
|
comment: null,
|
|
296
367
|
commentThread: null,
|
|
297
368
|
send: null,
|
|
@@ -311,15 +382,17 @@ function renderEditorShell(token: string): string {
|
|
|
311
382
|
try {
|
|
312
383
|
els.frame = document.getElementById('deck');
|
|
313
384
|
els.hitbox = document.getElementById('hitbox');
|
|
385
|
+
els.resizeHandle = document.getElementById('resizeHandle');
|
|
314
386
|
els.comment = document.getElementById('comment');
|
|
315
387
|
els.commentThread = document.getElementById('commentThread');
|
|
316
388
|
els.send = document.getElementById('send');
|
|
317
389
|
els.status = document.getElementById('status');
|
|
318
390
|
|
|
319
|
-
if (!els.frame || !els.hitbox || !els.comment || !els.commentThread || !els.send || !els.status) {
|
|
391
|
+
if (!els.frame || !els.hitbox || !els.resizeHandle || !els.comment || !els.commentThread || !els.send || !els.status) {
|
|
320
392
|
throw new Error('Editor boot failed: required DOM nodes are missing.');
|
|
321
393
|
}
|
|
322
394
|
|
|
395
|
+
restoreEditorWidth();
|
|
323
396
|
bindEvents();
|
|
324
397
|
setStatus('Editor ready. Ctrl/Cmd + click deck elements to reference them.');
|
|
325
398
|
initFrame();
|
|
@@ -358,9 +431,59 @@ function renderEditorShell(token: string): string {
|
|
|
358
431
|
renderHoverOutline(state.hoverEl);
|
|
359
432
|
renderReferenceOutlines();
|
|
360
433
|
}, { passive: false });
|
|
434
|
+
els.resizeHandle.addEventListener('pointerdown', startEditorResize);
|
|
435
|
+
els.resizeHandle.addEventListener('dblclick', resetEditorWidth);
|
|
361
436
|
els.send.addEventListener('click', sendComment);
|
|
362
437
|
}
|
|
363
438
|
|
|
439
|
+
function restoreEditorWidth() {
|
|
440
|
+
try {
|
|
441
|
+
const saved = Number(window.localStorage.getItem(EDITOR_WIDTH_KEY));
|
|
442
|
+
setEditorWidth(Number.isFinite(saved) ? saved : DEFAULT_EDITOR_WIDTH, false);
|
|
443
|
+
} catch {
|
|
444
|
+
setEditorWidth(DEFAULT_EDITOR_WIDTH, false);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function startEditorResize(event) {
|
|
449
|
+
event.preventDefault();
|
|
450
|
+
const currentWidth = Number.parseFloat(getComputedStyle(document.querySelector('.app')).getPropertyValue('--editor-width')) || DEFAULT_EDITOR_WIDTH;
|
|
451
|
+
state.resizeDrag = { startX: event.clientX, startWidth: currentWidth };
|
|
452
|
+
document.body.classList.add('resizing');
|
|
453
|
+
els.resizeHandle.setPointerCapture?.(event.pointerId);
|
|
454
|
+
window.addEventListener('pointermove', resizeEditor);
|
|
455
|
+
window.addEventListener('pointerup', stopEditorResize, { once: true });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function resizeEditor(event) {
|
|
459
|
+
if (!state.resizeDrag) return;
|
|
460
|
+
const nextWidth = state.resizeDrag.startWidth + state.resizeDrag.startX - event.clientX;
|
|
461
|
+
setEditorWidth(nextWidth, true);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function stopEditorResize() {
|
|
465
|
+
state.resizeDrag = null;
|
|
466
|
+
document.body.classList.remove('resizing');
|
|
467
|
+
window.removeEventListener('pointermove', resizeEditor);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function resetEditorWidth() {
|
|
471
|
+
setEditorWidth(DEFAULT_EDITOR_WIDTH, true);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function setEditorWidth(width, persist) {
|
|
475
|
+
const nextWidth = clampEditorWidth(width);
|
|
476
|
+
document.querySelector('.app')?.style.setProperty('--editor-width', nextWidth + 'px');
|
|
477
|
+
if (!persist) return;
|
|
478
|
+
try {
|
|
479
|
+
window.localStorage.setItem(EDITOR_WIDTH_KEY, String(nextWidth));
|
|
480
|
+
} catch {}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function clampEditorWidth(width) {
|
|
484
|
+
return Math.min(MAX_EDITOR_WIDTH, Math.max(MIN_EDITOR_WIDTH, Math.round(width)));
|
|
485
|
+
}
|
|
486
|
+
|
|
364
487
|
function initFrame() {
|
|
365
488
|
try {
|
|
366
489
|
const doc = els.frame.contentDocument;
|
|
@@ -406,6 +529,7 @@ function renderEditorShell(token: string): string {
|
|
|
406
529
|
const nextVersion = body.version || (String(body.mtimeMs) + ':' + String(body.size));
|
|
407
530
|
if (!state.deckVersion) {
|
|
408
531
|
state.deckVersion = nextVersion;
|
|
532
|
+
markCommentsUpdatedForVersion(nextVersion);
|
|
409
533
|
markStaleComments();
|
|
410
534
|
return;
|
|
411
535
|
}
|
|
@@ -414,7 +538,7 @@ function renderEditorShell(token: string): string {
|
|
|
414
538
|
return;
|
|
415
539
|
}
|
|
416
540
|
state.deckVersion = nextVersion;
|
|
417
|
-
|
|
541
|
+
markCommentsUpdatedForVersion(nextVersion);
|
|
418
542
|
refreshDeckPreview(body.mtimeMs);
|
|
419
543
|
} catch (error) {
|
|
420
544
|
reportError(error);
|
|
@@ -497,7 +621,7 @@ function renderEditorShell(token: string): string {
|
|
|
497
621
|
const body = await res.json().catch(() => ({}));
|
|
498
622
|
if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to send comment');
|
|
499
623
|
updatePendingCommentStatus(commentId, 'sent', { baseDeckVersion: body.deckVersion || state.deckVersion });
|
|
500
|
-
setStatus('Comment sent. Waiting for deck update...');
|
|
624
|
+
if (pendingCommentStatus(commentId) !== 'updated') setStatus('Comment sent. Waiting for deck update...');
|
|
501
625
|
updateSendState();
|
|
502
626
|
} catch (error) {
|
|
503
627
|
updatePendingCommentStatus(commentId, 'failed');
|
|
@@ -525,9 +649,10 @@ function renderEditorShell(token: string): string {
|
|
|
525
649
|
return;
|
|
526
650
|
}
|
|
527
651
|
const payload = collectPayload(target);
|
|
652
|
+
const color = REFERENCE_COLORS[(state.nextReferenceId - 1) % REFERENCE_COLORS.length];
|
|
528
653
|
const id = 'ref-' + state.nextReferenceId++;
|
|
529
654
|
const label = nextReferenceLabel(payload);
|
|
530
|
-
const reference = { id, target, label, payload };
|
|
655
|
+
const reference = { id, target, label, payload, color };
|
|
531
656
|
state.references.push(reference);
|
|
532
657
|
insertReferenceChip(reference);
|
|
533
658
|
renderReferenceOutlines();
|
|
@@ -571,7 +696,7 @@ function renderEditorShell(token: string): string {
|
|
|
571
696
|
status,
|
|
572
697
|
createdAt: Date.now(),
|
|
573
698
|
baseDeckVersion: state.deckVersion,
|
|
574
|
-
|
|
699
|
+
updatedVersion: null,
|
|
575
700
|
});
|
|
576
701
|
renderCommentThread();
|
|
577
702
|
return id;
|
|
@@ -580,42 +705,46 @@ function renderEditorShell(token: string): string {
|
|
|
580
705
|
function updatePendingCommentStatus(id, status, updates) {
|
|
581
706
|
const comment = state.pendingComments.find((item) => item.id === id);
|
|
582
707
|
if (!comment) return;
|
|
583
|
-
if (comment.status === '
|
|
708
|
+
if (comment.status === 'updated' && status !== 'failed') return;
|
|
584
709
|
comment.status = status;
|
|
585
710
|
if (updates) Object.assign(comment, updates);
|
|
586
711
|
renderCommentThread();
|
|
587
712
|
}
|
|
588
713
|
|
|
589
|
-
function
|
|
714
|
+
function markCommentsUpdatedForVersion(version) {
|
|
590
715
|
let changed = false;
|
|
591
716
|
state.pendingComments.forEach((comment) => {
|
|
592
717
|
if ((comment.status === 'sent' || comment.status === 'sending' || comment.status === 'stale') && comment.baseDeckVersion !== version) {
|
|
593
|
-
comment.status = '
|
|
594
|
-
comment.
|
|
718
|
+
comment.status = 'updated';
|
|
719
|
+
comment.updatedVersion = version;
|
|
595
720
|
changed = true;
|
|
596
721
|
}
|
|
597
722
|
});
|
|
598
|
-
if (changed)
|
|
723
|
+
if (changed) {
|
|
724
|
+
renderCommentThread();
|
|
725
|
+
setStatus('Deck file updated. Preview will refresh automatically.');
|
|
726
|
+
}
|
|
599
727
|
}
|
|
600
728
|
|
|
601
729
|
function markStaleComments() {
|
|
602
730
|
const now = Date.now();
|
|
603
731
|
let changed = false;
|
|
604
|
-
|
|
605
|
-
if (comment.status !== 'sent' && comment.status !== 'sending') return
|
|
606
|
-
if (now - comment.createdAt < COMMENT_STALE_MS) return
|
|
732
|
+
state.pendingComments.forEach((comment) => {
|
|
733
|
+
if (comment.status !== 'sent' && comment.status !== 'sending') return;
|
|
734
|
+
if (now - comment.createdAt < COMMENT_STALE_MS) return;
|
|
607
735
|
comment.status = 'stale';
|
|
608
736
|
changed = true;
|
|
609
|
-
return true;
|
|
610
737
|
});
|
|
611
738
|
if (changed) {
|
|
612
739
|
renderCommentThread();
|
|
613
|
-
setStatus('Still waiting for deck file update.
|
|
614
|
-
} else if (hasWaiting) {
|
|
615
|
-
setStatus('Comment sent. Waiting for deck update...');
|
|
740
|
+
setStatus('Still waiting for deck file update. The preview will refresh automatically when the file changes.');
|
|
616
741
|
}
|
|
617
742
|
}
|
|
618
743
|
|
|
744
|
+
function pendingCommentStatus(id) {
|
|
745
|
+
return state.pendingComments.find((comment) => comment.id === id)?.status || '';
|
|
746
|
+
}
|
|
747
|
+
|
|
619
748
|
function renderCommentThread() {
|
|
620
749
|
els.commentThread.textContent = '';
|
|
621
750
|
state.pendingComments.forEach((comment) => {
|
|
@@ -637,7 +766,7 @@ function renderEditorShell(token: string): string {
|
|
|
637
766
|
}
|
|
638
767
|
|
|
639
768
|
function commentStatusLabel(status) {
|
|
640
|
-
if (status === '
|
|
769
|
+
if (status === 'updated') return 'Deck file updated';
|
|
641
770
|
if (status === 'stale') return 'Still waiting for deck file update';
|
|
642
771
|
if (status === 'failed') return 'Failed to send';
|
|
643
772
|
if (status === 'sending') return 'Sending to OpenCode...';
|
|
@@ -661,6 +790,12 @@ function renderEditorShell(token: string): string {
|
|
|
661
790
|
return outline;
|
|
662
791
|
}
|
|
663
792
|
|
|
793
|
+
function setOutlineColor(outline, color) {
|
|
794
|
+
if (!outline || !color) return;
|
|
795
|
+
outline.style.borderColor = color.border;
|
|
796
|
+
outline.style.background = color.fill;
|
|
797
|
+
}
|
|
798
|
+
|
|
664
799
|
function renderBox(outline, target) {
|
|
665
800
|
if (!outline || !target || !target.getBoundingClientRect) {
|
|
666
801
|
if (outline) outline.style.display = 'none';
|
|
@@ -681,8 +816,12 @@ function renderEditorShell(token: string): string {
|
|
|
681
816
|
function renderReferenceOutlines() {
|
|
682
817
|
const doc = els.frame.contentDocument;
|
|
683
818
|
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) =>
|
|
819
|
+
while (state.referenceOutlines.length < state.references.length) state.referenceOutlines.push(createOutline(doc, '#7aa6d8', 'rgba(122,166,216,.18)'));
|
|
820
|
+
state.referenceOutlines.forEach((outline, index) => {
|
|
821
|
+
const reference = state.references[index];
|
|
822
|
+
setOutlineColor(outline, reference?.color);
|
|
823
|
+
renderBox(outline, reference?.target);
|
|
824
|
+
});
|
|
686
825
|
}
|
|
687
826
|
|
|
688
827
|
function clearHover() {
|
|
@@ -704,6 +843,9 @@ function renderEditorShell(token: string): string {
|
|
|
704
843
|
chip.className = 'ref-chip';
|
|
705
844
|
chip.contentEditable = 'false';
|
|
706
845
|
chip.dataset.refId = reference.id;
|
|
846
|
+
chip.style.setProperty('--ref-bg', reference.color.bg);
|
|
847
|
+
chip.style.setProperty('--ref-border', reference.color.border);
|
|
848
|
+
chip.style.setProperty('--ref-text', reference.color.text);
|
|
707
849
|
chip.textContent = '@' + reference.label;
|
|
708
850
|
const trailingSpace = document.createTextNode(' ');
|
|
709
851
|
const range = getCommentInsertRange();
|