@cyber-dash-tech/revela 0.15.1 → 0.15.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/lib/edit/prompt.ts +12 -0
- package/lib/inspect/prompt.ts +6 -1
- package/lib/media/download.ts +36 -11
- package/lib/media/save.ts +24 -0
- package/lib/media/search.ts +385 -0
- package/lib/media/types.ts +12 -0
- package/lib/qa/checks.ts +2 -1
- package/lib/qa/index.ts +73 -2
- package/lib/refine/server.ts +758 -68
- package/package.json +1 -1
- package/tools/media-save.ts +6 -0
package/lib/refine/server.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { randomBytes } from "crypto"
|
|
2
2
|
import { existsSync, readFileSync, statSync } from "fs"
|
|
3
3
|
import { fileURLToPath } from "url"
|
|
4
|
-
import { dirname, extname, isAbsolute, resolve, sep } from "path"
|
|
4
|
+
import { dirname, extname, isAbsolute, relative, resolve, sep } from "path"
|
|
5
5
|
import type { EditableDeck } from "../edit/resolve-deck"
|
|
6
6
|
import { buildEditPrompt, type EditCommentPayload } from "../edit/prompt"
|
|
7
7
|
import type { InspectionElementSnapshot } from "../inspection-context/match"
|
|
8
8
|
import { buildInspectionPrompt } from "../inspect/prompt"
|
|
9
9
|
import { projectWorkspaceElement } from "../inspect/request"
|
|
10
10
|
import { createInspectRequest, failInspectRequest, getInspectRequest } from "../inspect/requests"
|
|
11
|
+
import { saveMediaAsset } from "../media/save"
|
|
12
|
+
import { searchRemoteImages, type ImageCandidate } from "../media/search"
|
|
13
|
+
import type { MediaAssetRecord, MediaPurpose } from "../media/types"
|
|
11
14
|
|
|
12
15
|
const TOKEN_BYTES = 24
|
|
13
16
|
const SESSION_TTL_MS = 2 * 60 * 60 * 1000
|
|
@@ -190,9 +193,148 @@ async function handleRequest(req: Request): Promise<Response> {
|
|
|
190
193
|
return handleDeckVersion(session.value)
|
|
191
194
|
}
|
|
192
195
|
|
|
196
|
+
if (url.pathname === "/api/assets/search" && req.method === "GET") {
|
|
197
|
+
const session = validateSession(url.searchParams.get("token"))
|
|
198
|
+
if (!session.ok) return session.response
|
|
199
|
+
return handleAssetSearch(url, session.value)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (url.pathname === "/api/assets/save" && req.method === "POST") {
|
|
203
|
+
const session = validateSession(url.searchParams.get("token"))
|
|
204
|
+
if (!session.ok) return session.response
|
|
205
|
+
return handleAssetSave(req, session.value)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (url.pathname === "/api/assets/list" && req.method === "GET") {
|
|
209
|
+
const session = validateSession(url.searchParams.get("token"))
|
|
210
|
+
if (!session.ok) return session.response
|
|
211
|
+
return handleAssetList(session.value)
|
|
212
|
+
}
|
|
213
|
+
|
|
193
214
|
return textResponse("Not found", 404)
|
|
194
215
|
}
|
|
195
216
|
|
|
217
|
+
async function handleAssetSearch(url: URL, session: EditSession): Promise<Response> {
|
|
218
|
+
const query = (url.searchParams.get("query") || "").trim()
|
|
219
|
+
if (!query) return jsonResponse({ ok: false, error: "query is required" }, 400)
|
|
220
|
+
const purpose = normalizeMediaPurpose(url.searchParams.get("purpose"))
|
|
221
|
+
const limit = Number(url.searchParams.get("limit") || 12)
|
|
222
|
+
const page = Number(url.searchParams.get("page") || 1)
|
|
223
|
+
try {
|
|
224
|
+
const candidates = await searchRemoteImages({ query, purpose, limit, page })
|
|
225
|
+
session.lastActiveAt = Date.now()
|
|
226
|
+
scheduleIdleStop()
|
|
227
|
+
return jsonResponse({ ok: true, candidates })
|
|
228
|
+
} catch (error) {
|
|
229
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
230
|
+
return jsonResponse({ ok: false, error: message }, 502)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function handleAssetSave(req: Request, session: EditSession): Promise<Response> {
|
|
235
|
+
let body: any
|
|
236
|
+
try {
|
|
237
|
+
body = await req.json()
|
|
238
|
+
} catch {
|
|
239
|
+
return jsonResponse({ ok: false, error: "Invalid JSON body" }, 400)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const candidate = normalizeImageCandidate(body?.candidate ?? body)
|
|
243
|
+
if (!candidate) return jsonResponse({ ok: false, error: "Valid image candidate is required" }, 400)
|
|
244
|
+
const purpose = normalizeMediaPurpose(body?.purpose) || candidate.purpose || "illustration"
|
|
245
|
+
const result = await saveMediaAsset({
|
|
246
|
+
topic: session.deck,
|
|
247
|
+
id: body?.id || candidate.candidateId,
|
|
248
|
+
type: "image",
|
|
249
|
+
purpose,
|
|
250
|
+
brief: body?.brief || `Saved from ${candidate.provider} for Refine asset placement.`,
|
|
251
|
+
status: "success",
|
|
252
|
+
sourceUrl: candidate.imageUrl,
|
|
253
|
+
alt: body?.alt || candidate.alt || candidate.title,
|
|
254
|
+
notes: body?.notes,
|
|
255
|
+
provider: candidate.provider,
|
|
256
|
+
sourcePageUrl: candidate.sourcePageUrl,
|
|
257
|
+
license: candidate.license,
|
|
258
|
+
attribution: candidate.attribution,
|
|
259
|
+
width: candidate.width,
|
|
260
|
+
height: candidate.height,
|
|
261
|
+
}, session.workspaceRoot)
|
|
262
|
+
|
|
263
|
+
session.lastActiveAt = Date.now()
|
|
264
|
+
scheduleIdleStop()
|
|
265
|
+
if (!result.ok) return jsonResponse({ ok: false, error: result.error }, 400)
|
|
266
|
+
if (result.status !== "success" || !result.path) {
|
|
267
|
+
return jsonResponse({ ok: false, error: `Failed to save asset: ${result.status}` }, 400)
|
|
268
|
+
}
|
|
269
|
+
const asset = savedAssetForResult(session, result.assetId)
|
|
270
|
+
if (!asset) return jsonResponse({ ok: false, error: "Saved asset was not found in workspace assets." }, 500)
|
|
271
|
+
return jsonResponse({ ok: true, asset, result })
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function handleAssetList(session: EditSession): Response {
|
|
275
|
+
session.lastActiveAt = Date.now()
|
|
276
|
+
scheduleIdleStop()
|
|
277
|
+
return jsonResponse({ ok: true, assets: listSavedAssets(session) })
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function savedAssetForResult(session: EditSession, assetId: string): (MediaAssetRecord & { previewUrl?: string; deckPath?: string }) | null {
|
|
281
|
+
return listSavedAssets(session).find((asset) => asset.id === assetId) ?? null
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function listSavedAssets(session: EditSession): Array<MediaAssetRecord & { previewUrl?: string; deckPath?: string }> {
|
|
285
|
+
const manifestPath = resolve(session.workspaceRoot, "assets", slugify(session.deck), "media-manifest.json")
|
|
286
|
+
if (!existsSync(manifestPath)) return []
|
|
287
|
+
try {
|
|
288
|
+
const parsed = JSON.parse(readFileSync(manifestPath, "utf-8")) as { assets?: MediaAssetRecord[] }
|
|
289
|
+
return (Array.isArray(parsed.assets) ? parsed.assets : [])
|
|
290
|
+
.filter((asset) => asset.status === "success" && asset.path)
|
|
291
|
+
.map((asset) => ({
|
|
292
|
+
...asset,
|
|
293
|
+
previewUrl: asset.path ? assetUrlForRef(asset.path, session, session.workspaceRoot) ?? undefined : undefined,
|
|
294
|
+
deckPath: asset.path ? relative(dirname(session.absoluteFile), resolve(session.workspaceRoot, asset.path)).replace(/\\/g, "/") : undefined,
|
|
295
|
+
}))
|
|
296
|
+
} catch {
|
|
297
|
+
return []
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function normalizeImageCandidate(input: any): ImageCandidate | null {
|
|
302
|
+
if (!input || typeof input !== "object") return null
|
|
303
|
+
const candidateId = typeof input.candidateId === "string" ? input.candidateId.trim() : ""
|
|
304
|
+
const provider = typeof input.provider === "string" ? input.provider.trim() : ""
|
|
305
|
+
const title = typeof input.title === "string" ? input.title.trim() : ""
|
|
306
|
+
const imageUrl = typeof input.imageUrl === "string" ? input.imageUrl.trim() : ""
|
|
307
|
+
const thumbnailUrl = typeof input.thumbnailUrl === "string" ? input.thumbnailUrl.trim() : imageUrl
|
|
308
|
+
if (!candidateId || !provider || !title || !imageUrl) return null
|
|
309
|
+
return {
|
|
310
|
+
candidateId,
|
|
311
|
+
provider,
|
|
312
|
+
title,
|
|
313
|
+
thumbnailUrl,
|
|
314
|
+
imageUrl,
|
|
315
|
+
sourcePageUrl: typeof input.sourcePageUrl === "string" ? input.sourcePageUrl : undefined,
|
|
316
|
+
width: typeof input.width === "number" ? input.width : undefined,
|
|
317
|
+
height: typeof input.height === "number" ? input.height : undefined,
|
|
318
|
+
alt: typeof input.alt === "string" ? input.alt : undefined,
|
|
319
|
+
license: typeof input.license === "string" ? input.license : undefined,
|
|
320
|
+
attribution: typeof input.attribution === "string" ? input.attribution : undefined,
|
|
321
|
+
purpose: normalizeMediaPurpose(input.purpose),
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function normalizeMediaPurpose(input: unknown): MediaPurpose | undefined {
|
|
326
|
+
return input === "hero" || input === "illustration" || input === "portrait" || input === "logo" || input === "screenshot"
|
|
327
|
+
? input
|
|
328
|
+
: undefined
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function slugify(value: string): string {
|
|
332
|
+
return value
|
|
333
|
+
.toLowerCase()
|
|
334
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
335
|
+
.replace(/^-+|-+$/g, "")
|
|
336
|
+
}
|
|
337
|
+
|
|
196
338
|
function handleDeck(session: EditSession): Response {
|
|
197
339
|
session.assets.clear()
|
|
198
340
|
session.assetKeys.clear()
|
|
@@ -460,6 +602,7 @@ async function handleInspect(req: Request, session: EditSession): Promise<Respon
|
|
|
460
602
|
|
|
461
603
|
const snapshot = normalizeSnapshot(body?.snapshot ?? body)
|
|
462
604
|
const language = normalizeInspectLanguage(body?.language)
|
|
605
|
+
const comment = typeof body?.comment === "string" ? body.comment.trim().slice(0, 2000) : ""
|
|
463
606
|
const requestId = typeof body?.requestId === "string" && body.requestId.trim() ? body.requestId.trim() : randomBytes(10).toString("base64url")
|
|
464
607
|
const version = readDeckVersion(session).version
|
|
465
608
|
const staleReason = typeof body?.deckVersion === "string" && body.deckVersion !== version
|
|
@@ -482,6 +625,7 @@ async function handleInspect(req: Request, session: EditSession): Promise<Respon
|
|
|
482
625
|
requestId,
|
|
483
626
|
file: session.file,
|
|
484
627
|
language,
|
|
628
|
+
comment,
|
|
485
629
|
projection: staleReason
|
|
486
630
|
? { ...projection, stale: { stale: true, reason: staleReason } } as any
|
|
487
631
|
: projection,
|
|
@@ -624,11 +768,11 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
|
|
|
624
768
|
<style>
|
|
625
769
|
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
626
770
|
* { box-sizing: border-box; }
|
|
627
|
-
body { margin: 0; background: #
|
|
771
|
+
body { margin: 0; background: #eee8dc; color: #1f2933; height: 100vh; overflow: hidden; }
|
|
628
772
|
body.resizing { cursor: col-resize; user-select: none; }
|
|
629
773
|
body.resizing iframe, body.resizing .hitbox { pointer-events: none; }
|
|
630
774
|
.app { --editor-width: 376px; position: relative; display: grid; grid-template-columns: minmax(0, 1fr) var(--editor-width); height: 100vh; }
|
|
631
|
-
.preview { position: relative; min-width: 0; background: #
|
|
775
|
+
.preview { position: relative; min-width: 0; background: #e7dfd1; }
|
|
632
776
|
.resize-handle { position: absolute; top: 0; bottom: 0; right: calc(var(--editor-width) - 7px); width: 14px; z-index: 5; cursor: col-resize; background: transparent; }
|
|
633
777
|
.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; }
|
|
634
778
|
.resize-handle:hover::before, body.resizing .resize-handle::before { height: 52px; background: #94a3b8; box-shadow: 0 0 0 4px rgba(148,163,184,.16); }
|
|
@@ -639,53 +783,99 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
|
|
|
639
783
|
.deck-nav button:hover:not(:disabled) { background: rgba(255,255,255,.22); }
|
|
640
784
|
.deck-nav button:disabled { opacity: .38; }
|
|
641
785
|
.deck-nav-status { min-width: 76px; color: #e2e8f0; font-size: 12px; font-weight: 900; text-align: center; font-variant-numeric: tabular-nums; }
|
|
642
|
-
aside { display: flex; flex-direction: column; gap: 16px; padding: 20px; background: linear-gradient(180deg, #
|
|
786
|
+
aside { position: relative; display: flex; flex-direction: column; gap: 16px; padding: 20px; background: linear-gradient(180deg, #fbfaf7 0%, #f2eee6 100%); overflow: auto; border-left: 1px solid #d8d2c6; }
|
|
643
787
|
h1 { margin: 0; font-size: 18px; line-height: 1.2; letter-spacing: -.01em; color: #0f172a; }
|
|
644
788
|
.wordmark { font-family: Garamond, "Iowan Old Style", Georgia, serif; font-size: 21px; letter-spacing: .08em; font-weight: 600; }
|
|
645
|
-
.hint { margin: 0; color: #
|
|
789
|
+
.hint { margin: 0; color: #756f66; font-size: 13px; line-height: 1.5; }
|
|
646
790
|
.panel { display: flex; flex-direction: column; gap: 10px; }
|
|
647
|
-
.tabs { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; padding: 4px; border: 1px solid #
|
|
648
|
-
.tab { padding: 9px 10px; border: 0; border-radius: 10px; background: transparent; color: #
|
|
649
|
-
.tab.active { background: #
|
|
791
|
+
.tabs { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; padding: 4px; border: 1px solid #d8d2c6; border-radius: 14px; background: #ebe4d8; }
|
|
792
|
+
.tab { padding: 9px 10px; border: 0; border-radius: 10px; background: transparent; color: #5f594f; box-shadow: none; font-weight: 900; }
|
|
793
|
+
.tab.active { background: #fbfaf7; color: #111827; box-shadow: 0 6px 16px rgba(31,41,51,.1); }
|
|
650
794
|
.tab-panel { display: none; flex-direction: column; gap: 12px; }
|
|
651
795
|
.tab-panel.active { display: flex; }
|
|
652
|
-
.
|
|
653
|
-
.selection-summary
|
|
796
|
+
.sr-only { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0,0,0,0) !important; white-space: nowrap !important; border: 0 !important; }
|
|
797
|
+
.selection-summary { padding: 10px 12px; border: 1px solid #d8d2c6; border-radius: 14px; background: #fbfaf7; color: #3f3a33; font-size: 13px; line-height: 1.45; box-shadow: 0 8px 22px rgba(31,41,51,.05); }
|
|
798
|
+
.selection-summary strong { display: block; margin-bottom: 7px; color: #756f66; font-size: 11px; letter-spacing: .09em; text-transform: uppercase; }
|
|
654
799
|
.selection-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
655
|
-
.label { color: #
|
|
656
|
-
.comment-editor { width: 100%; min-height: 164px; max-height: 42vh; overflow: auto; padding: 13px 14px; border: 1px solid #
|
|
657
|
-
.comment-editor:focus { border-color: #
|
|
658
|
-
.comment-editor:empty::before { content: attr(data-placeholder); color: #
|
|
800
|
+
.label { color: #756f66; font-size: 11px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
|
|
801
|
+
.comment-editor { width: 100%; min-height: 164px; max-height: 42vh; overflow: auto; padding: 13px 14px; border: 1px solid #d8d2c6; border-radius: 14px; background: #fffdf8; color: #111827; font: inherit; line-height: 1.5; outline: none; white-space: pre-wrap; box-shadow: 0 10px 24px rgba(31,41,51,.06); }
|
|
802
|
+
.comment-editor:focus { border-color: #a9793f; box-shadow: 0 0 0 3px rgba(169,121,63,.14), 0 10px 24px rgba(31,41,51,.07); }
|
|
803
|
+
.comment-editor:empty::before { content: attr(data-placeholder); color: #a79d8e; pointer-events: none; }
|
|
659
804
|
.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; }
|
|
660
|
-
.
|
|
661
|
-
.comment-
|
|
662
|
-
.comment-
|
|
663
|
-
.comment-bubble
|
|
664
|
-
.comment-bubble.
|
|
665
|
-
.comment-bubble.
|
|
805
|
+
.activity-panel { display: flex; flex-direction: column; gap: 8px; padding-top: 2px; }
|
|
806
|
+
.comment-thread { display: flex; flex-direction: column; gap: 8px; max-height: 24vh; overflow: auto; }
|
|
807
|
+
.comment-thread:empty { display: none; }
|
|
808
|
+
.comment-bubble { border: 1px solid #d8d2c6; border-radius: 14px; padding: 10px 12px; background: #fffdf8; color: #3f3a33; font-size: 13px; line-height: 1.45; box-shadow: 0 8px 22px rgba(31,41,51,.05); }
|
|
809
|
+
.comment-bubble.sending { border-color: #c8b88f; background: #f7f0df; }
|
|
810
|
+
.comment-bubble.updated { border-color: #9dac8a; background: #f0f2e8; }
|
|
811
|
+
.comment-bubble.stale { border-color: #c6a96a; background: #f8efd7; }
|
|
812
|
+
.comment-bubble.failed { border-color: #c58f82; background: #f7eae5; }
|
|
666
813
|
.comment-bubble-text { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
667
|
-
.comment-bubble-state { margin-top: 8px; color: #
|
|
668
|
-
.comment-bubble.updated .comment-bubble-state { color: #
|
|
669
|
-
.comment-bubble.stale .comment-bubble-state { color: #
|
|
670
|
-
.comment-bubble.failed .comment-bubble-state { color: #
|
|
814
|
+
.comment-bubble-state { margin-top: 8px; color: #8a6231; font-size: 12px; font-weight: 800; }
|
|
815
|
+
.comment-bubble.updated .comment-bubble-state { color: #556b3f; }
|
|
816
|
+
.comment-bubble.stale .comment-bubble-state { color: #8a6231; }
|
|
817
|
+
.comment-bubble.failed .comment-bubble-state { color: #8f4638; }
|
|
671
818
|
.inspect-actions { display: flex; flex-direction: column; gap: 8px; }
|
|
672
819
|
.inspect-options { display: flex; flex-direction: column; gap: 5px; }
|
|
673
|
-
.inspect-options label { color: #
|
|
674
|
-
.inspect-select { width: 100%; padding: 10px 11px; border: 1px solid #
|
|
820
|
+
.inspect-options label { color: #756f66; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .05em; }
|
|
821
|
+
.inspect-select { width: 100%; padding: 10px 11px; border: 1px solid #d8d2c6; border-radius: 12px; background: #fffdf8; color: #111827; font-weight: 700; }
|
|
675
822
|
.inspect-cards { display: flex; flex-direction: column; gap: 12px; }
|
|
676
|
-
.inspect-card { border: 1px solid #
|
|
823
|
+
.inspect-card { border: 1px solid #d8d2c6; border-radius: 16px; background: #fffdf8; padding: 13px; box-shadow: 0 10px 22px rgba(31,41,51,.05); }
|
|
677
824
|
.inspect-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
|
|
678
825
|
.inspect-card h2 { margin: 0; font-size: 13px; color: #0f172a; }
|
|
679
|
-
.badge { border-radius: 999px; padding: 3px 8px; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; background: #
|
|
680
|
-
.badge.supported, .badge.found, .badge.present, .badge.known, .badge.suggested { background: #
|
|
681
|
-
.badge.weak, .badge.missing { background: #
|
|
682
|
-
.badge.unsupported { background: #
|
|
683
|
-
.inspect-card p, .inspect-empty, .inspect-loading { margin: 0; color: #
|
|
684
|
-
.inspect-item { margin-top: 7px; padding: 8px; border-radius: 10px; background: #
|
|
826
|
+
.badge { border-radius: 999px; padding: 3px 8px; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; background: #e8e1d4; color: #5f594f; }
|
|
827
|
+
.badge.supported, .badge.found, .badge.present, .badge.known, .badge.suggested { background: #e7ecdc; color: #4d6138; }
|
|
828
|
+
.badge.weak, .badge.missing { background: #f1e5c8; color: #765326; }
|
|
829
|
+
.badge.unsupported { background: #f0d9d1; color: #7f3d31; }
|
|
830
|
+
.inspect-card p, .inspect-empty, .inspect-loading { margin: 0; color: #5f594f; font-size: 12px; line-height: 1.5; }
|
|
831
|
+
.inspect-item { margin-top: 7px; padding: 8px; border-radius: 10px; background: #f7f3ea; color: #3f3a33; font-size: 12px; line-height: 1.45; }
|
|
685
832
|
.inspect-warning, .inspect-stale { margin-top: 8px; padding: 8px; border-radius: 10px; background: #fff7ed; color: #9a3412; font-size: 12px; line-height: 1.45; }
|
|
686
|
-
|
|
833
|
+
.loading-row { display: inline-flex; align-items: center; gap: 8px; }
|
|
834
|
+
.spinner { width: 16px; height: 16px; border: 2px solid rgba(169,121,63,.22); border-top-color: currentColor; border-radius: 999px; animation: spin .8s linear infinite; }
|
|
835
|
+
button .spinner { width: 15px; height: 15px; border-color: rgba(255,255,255,.36); border-top-color: #fff; }
|
|
836
|
+
.skeleton-card { border: 1px solid #d8d2c6; border-radius: 16px; background: #fffdf8; padding: 13px; box-shadow: 0 10px 22px rgba(31,41,51,.05); }
|
|
837
|
+
.skeleton-line { height: 10px; margin: 8px 0; border-radius: 999px; background: linear-gradient(90deg, #ded5c6 0%, #fbfaf7 48%, #ded5c6 100%); background-size: 200% 100%; animation: shimmer 1.2s ease-in-out infinite; }
|
|
838
|
+
.skeleton-line.short { width: 42%; }
|
|
839
|
+
.skeleton-line.medium { width: 68%; }
|
|
840
|
+
.skeleton-line.long { width: 92%; }
|
|
841
|
+
.asset-card.is-saving::after { content: ""; position: absolute; inset: 0; background: rgba(15,23,42,.32); }
|
|
842
|
+
.asset-card.is-saving .asset-save { z-index: 1; }
|
|
843
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
844
|
+
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
|
845
|
+
.asset-search { display: grid; grid-template-columns: minmax(0, 1fr) 118px; gap: 8px; }
|
|
846
|
+
.asset-search input, .asset-search select { min-width: 0; padding: 10px 11px; border: 1px solid #d8d2c6; border-radius: 12px; background: #fffdf8; color: #111827; font: inherit; font-size: 12px; font-weight: 700; outline: none; }
|
|
847
|
+
.asset-search input:focus, .asset-search select:focus { border-color: #a9793f; box-shadow: 0 0 0 3px rgba(169,121,63,.14); }
|
|
848
|
+
.asset-actions { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; }
|
|
849
|
+
.asset-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
|
|
850
|
+
.asset-card { position: relative; min-width: 0; aspect-ratio: 1 / 1; overflow: hidden; border: 1px solid #d8d2c6; border-radius: 14px; background: #fffdf8; box-shadow: 0 8px 18px rgba(31,41,51,.05); }
|
|
851
|
+
.asset-card.saved { width: 64px; height: 64px; aspect-ratio: auto; border-radius: 12px; }
|
|
852
|
+
.asset-card[draggable="true"] { cursor: grab; }
|
|
853
|
+
.asset-card[draggable="true"]:active { cursor: grabbing; }
|
|
854
|
+
.asset-thumb { width: 100%; height: 100%; display: block; background: #eee8dc; object-fit: contain; }
|
|
855
|
+
.asset-tools { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
856
|
+
.asset-search-toggle { width: auto; min-width: 32px; height: 28px; padding: 0 9px; border-radius: 999px; box-shadow: 0 6px 14px rgba(31,41,51,.08); font-size: 16px; line-height: 1; }
|
|
857
|
+
.asset-search-view { position: absolute; inset: 0; z-index: 12; display: flex; flex-direction: column; gap: 14px; padding: 20px; background: linear-gradient(180deg, #fbfaf7 0%, #f2eee6 100%); overflow: auto; transform: translateX(105%); transition: transform .2s ease; box-shadow: -18px 0 44px rgba(31,41,51,.16); }
|
|
858
|
+
.asset-search-view.open { transform: translateX(0); }
|
|
859
|
+
.asset-search-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
|
860
|
+
.asset-search-title { display: flex; flex-direction: column; gap: 2px; }
|
|
861
|
+
.asset-search-title h2 { margin: 0; color: #0f172a; font-size: 16px; letter-spacing: -.01em; }
|
|
862
|
+
.asset-search-title span { color: #756f66; font-size: 12px; line-height: 1.35; }
|
|
863
|
+
.asset-back { width: auto; padding: 9px 11px; border-radius: 999px; background: #ebe4d8; color: #111827; box-shadow: none; }
|
|
864
|
+
.asset-save { position: absolute; left: 7px; right: 7px; bottom: 7px; width: auto; padding: 7px 8px; border-radius: 10px; font-size: 11px; background: rgba(17,24,39,.9); color: #fbfaf7; box-shadow: 0 8px 16px rgba(31,41,51,.2); opacity: .96; }
|
|
865
|
+
.asset-empty { grid-column: 1 / -1; margin: 0; color: #756f66; font-size: 12px; line-height: 1.45; }
|
|
866
|
+
.edit-assets { padding: 10px; border: 1px solid #d8d2c6; border-radius: 16px; background: #f7f3ea; }
|
|
867
|
+
.edit-assets .panel { gap: 8px; }
|
|
868
|
+
.edit-assets .asset-grid { grid-template-columns: repeat(auto-fill, 64px); align-items: start; max-height: 176px; overflow: auto; }
|
|
869
|
+
.edit-assets .asset-thumb { width: 64px; height: 64px; }
|
|
870
|
+
.drop-active .hitbox { background: rgba(169,121,63,.1); outline: 2px dashed rgba(169,121,63,.48); outline-offset: -10px; }
|
|
871
|
+
button { width: 100%; padding: 12px 14px; border: 1px solid #d8d2c6; border-radius: 12px; background: #ebe4d8; color: #111827; font-weight: 800; cursor: pointer; box-shadow: 0 8px 16px rgba(31,41,51,.08); }
|
|
872
|
+
button:hover:not(:disabled) { background: #e3dacb; }
|
|
687
873
|
button:disabled { cursor: not-allowed; opacity: .5; }
|
|
688
|
-
.
|
|
874
|
+
.primary-action { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 46px; border-radius: 14px; border-color: #111827; background: linear-gradient(135deg, #111827 0%, #1f2937 100%); color: #fbfaf7; font-size: 14px; letter-spacing: .01em; box-shadow: 0 12px 24px rgba(31,41,51,.24); transition: transform .14s ease, box-shadow .14s ease, filter .14s ease; }
|
|
875
|
+
.primary-action:hover:not(:disabled) { background: linear-gradient(135deg, #0f1720 0%, #283241 100%); transform: translateY(-1px); box-shadow: 0 16px 30px rgba(31,41,51,.28); filter: saturate(1.02); }
|
|
876
|
+
.primary-action:active:not(:disabled) { transform: translateY(0); box-shadow: 0 9px 20px rgba(31,41,51,.22); }
|
|
877
|
+
.send-icon { width: 17px; height: 17px; stroke: currentColor; fill: none; stroke-width: 2.25; stroke-linecap: round; stroke-linejoin: round; }
|
|
878
|
+
.status { min-height: 20px; color: #5f594f; font-size: 13px; line-height: 1.45; }
|
|
689
879
|
@media (max-width: 900px) { .app { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; } .resize-handle { display: none; } aside { max-height: 48vh; } .deck-nav { bottom: 10px; } }
|
|
690
880
|
</style>
|
|
691
881
|
</head>
|
|
@@ -696,28 +886,49 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
|
|
|
696
886
|
<aside>
|
|
697
887
|
<div>
|
|
698
888
|
<h1><span class="wordmark">REVELA</span> Refine</h1>
|
|
699
|
-
<p class="hint">
|
|
889
|
+
<p class="hint">Select refs, describe the change, then send. Use Inspect only when you need source or purpose context.</p>
|
|
700
890
|
</div>
|
|
701
|
-
<div id="selectionSummary" class="selection-summary"><strong>Selection</strong><span>No references selected.</span><div id="selectionChips" class="selection-chips"></div></div>
|
|
891
|
+
<div id="selectionSummary" class="selection-summary sr-only" aria-live="polite"><strong>Selection</strong><span>No references selected.</span><div id="selectionChips" class="selection-chips"></div></div>
|
|
702
892
|
<div class="tabs" role="tablist" aria-label="Refine mode">
|
|
703
893
|
<button id="editTab" class="tab" type="button" role="tab">Edit</button>
|
|
704
894
|
<button id="inspectTab" class="tab" type="button" role="tab">Inspect</button>
|
|
705
895
|
</div>
|
|
706
896
|
<div id="editPanel" class="tab-panel">
|
|
707
897
|
<div class="panel">
|
|
708
|
-
<div class="label">
|
|
709
|
-
<div id="comment" class="comment-editor" contenteditable="true" role="textbox" aria-multiline="true" data-placeholder="
|
|
898
|
+
<div class="label">Describe the change</div>
|
|
899
|
+
<div id="comment" class="comment-editor" contenteditable="true" role="textbox" aria-multiline="true" data-placeholder="Cmd/Ctrl-click slide elements to add @refs, then describe the exact edit."></div>
|
|
900
|
+
</div>
|
|
901
|
+
<div class="edit-assets" aria-label="Edit assets">
|
|
902
|
+
<div class="panel">
|
|
903
|
+
<div class="asset-tools"><div class="label">Local Assets</div><button id="assetSearchToggle" class="asset-search-toggle" type="button" aria-expanded="false" aria-controls="assetSearchView" title="Search assets">+</button></div>
|
|
904
|
+
<div id="editSavedAssets" class="asset-grid"><p class="asset-empty">No local assets yet. Click + to search assets.</p></div>
|
|
905
|
+
</div>
|
|
710
906
|
</div>
|
|
711
|
-
<
|
|
712
|
-
<
|
|
907
|
+
<button id="send" class="primary-action" disabled><svg class="send-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94L14.7 6.3z"/></svg><span>Apply Fix</span></button>
|
|
908
|
+
<div class="activity-panel"><div class="label">Activity</div><div id="commentThread" class="comment-thread" aria-live="polite"></div></div>
|
|
713
909
|
</div>
|
|
714
910
|
<div id="inspectPanel" class="tab-panel">
|
|
911
|
+
<div class="panel">
|
|
912
|
+
<label class="label" for="inspectComment">Inspect comment</label>
|
|
913
|
+
<div id="inspectComment" class="comment-editor" contenteditable="true" role="textbox" aria-multiline="true" data-placeholder="Cmd/Ctrl-click slide elements to add @refs, then ask about purpose or source."></div>
|
|
914
|
+
</div>
|
|
715
915
|
<div class="inspect-actions">
|
|
716
916
|
<div class="inspect-options"><label for="inspectLanguage">Display Language</label><select id="inspectLanguage" class="inspect-select"><option>Auto</option><option>English</option><option>简体中文</option><option>繁體中文</option><option>日本語</option><option>Deutsch</option><option>Français</option><option>Español</option><option>Português</option><option>Arabic</option></select></div>
|
|
717
|
-
<button id="inspectButton" disabled>Inspect
|
|
917
|
+
<button id="inspectButton" disabled>Inspect Reference</button>
|
|
718
918
|
<div id="inspectStale"></div>
|
|
719
919
|
</div>
|
|
720
|
-
<div id="inspectCards" class="inspect-cards"><div class="inspect-empty">Select
|
|
920
|
+
<div id="inspectCards" class="inspect-cards"><div class="inspect-empty">Select a deck element to create an @ref, optionally ask a question, then Inspect. This does not edit the deck.</div></div>
|
|
921
|
+
</div>
|
|
922
|
+
<div id="assetSearchView" class="asset-search-view" aria-hidden="true">
|
|
923
|
+
<div class="asset-search-head">
|
|
924
|
+
<button id="assetSearchBack" class="asset-back" type="button">← Back</button>
|
|
925
|
+
<div class="asset-search-title"><h2>Search Assets</h2><span>Save images to Local Assets, then use them from Edit.</span></div>
|
|
926
|
+
</div>
|
|
927
|
+
<div class="panel">
|
|
928
|
+
<div class="asset-search"><input id="assetQuery" type="search" placeholder="Company logo, product photo, portrait..." /><select id="assetPurpose"><option value="logo" selected>logo</option><option value="illustration">photo</option><option value="hero">hero</option><option value="portrait">portrait</option><option value="screenshot">screenshot</option></select></div>
|
|
929
|
+
<div class="asset-actions"><button id="assetSearchButton" type="button">Search Assets</button><button id="assetShuffleButton" type="button" disabled>Refresh</button></div>
|
|
930
|
+
<div id="assetResults" class="asset-grid"><p class="asset-empty">Search image candidates, then save one to the workspace.</p></div>
|
|
931
|
+
</div>
|
|
721
932
|
</div>
|
|
722
933
|
<div id="status" class="status"></div>
|
|
723
934
|
</aside>
|
|
@@ -764,6 +975,19 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
|
|
|
764
975
|
activeInspectRequestId: '',
|
|
765
976
|
inspectLanguage: 'Auto',
|
|
766
977
|
inspectFallback: null,
|
|
978
|
+
sendingEdit: false,
|
|
979
|
+
assetCandidates: [],
|
|
980
|
+
savedAssets: [],
|
|
981
|
+
selectedAsset: null,
|
|
982
|
+
draggingAsset: null,
|
|
983
|
+
assetDropTarget: null,
|
|
984
|
+
assetDropOutline: null,
|
|
985
|
+
assetSearchBusy: false,
|
|
986
|
+
assetSavingIndex: -1,
|
|
987
|
+
assetSearchPage: 1,
|
|
988
|
+
assetSearchKey: '',
|
|
989
|
+
assetVisibleCount: 0,
|
|
990
|
+
assetPendingCount: 0,
|
|
767
991
|
};
|
|
768
992
|
const els = {
|
|
769
993
|
frame: null,
|
|
@@ -781,10 +1005,20 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
|
|
|
781
1005
|
comment: null,
|
|
782
1006
|
commentThread: null,
|
|
783
1007
|
send: null,
|
|
1008
|
+
inspectComment: null,
|
|
784
1009
|
inspectButton: null,
|
|
785
1010
|
inspectLanguage: null,
|
|
786
1011
|
inspectCards: null,
|
|
787
1012
|
inspectStale: null,
|
|
1013
|
+
assetSearchToggle: null,
|
|
1014
|
+
assetSearchBack: null,
|
|
1015
|
+
assetSearchView: null,
|
|
1016
|
+
assetQuery: null,
|
|
1017
|
+
assetPurpose: null,
|
|
1018
|
+
assetSearchButton: null,
|
|
1019
|
+
assetShuffleButton: null,
|
|
1020
|
+
assetResults: null,
|
|
1021
|
+
editSavedAssets: null,
|
|
788
1022
|
status: null,
|
|
789
1023
|
};
|
|
790
1024
|
|
|
@@ -814,14 +1048,24 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
|
|
|
814
1048
|
els.comment = document.getElementById('comment');
|
|
815
1049
|
els.commentThread = document.getElementById('commentThread');
|
|
816
1050
|
els.send = document.getElementById('send');
|
|
1051
|
+
els.inspectComment = document.getElementById('inspectComment');
|
|
817
1052
|
els.inspectButton = document.getElementById('inspectButton');
|
|
818
1053
|
els.inspectCards = document.getElementById('inspectCards');
|
|
819
1054
|
els.inspectStale = document.getElementById('inspectStale');
|
|
1055
|
+
els.assetSearchToggle = document.getElementById('assetSearchToggle');
|
|
1056
|
+
els.assetSearchBack = document.getElementById('assetSearchBack');
|
|
1057
|
+
els.assetSearchView = document.getElementById('assetSearchView');
|
|
1058
|
+
els.assetQuery = document.getElementById('assetQuery');
|
|
1059
|
+
els.assetPurpose = document.getElementById('assetPurpose');
|
|
1060
|
+
els.assetSearchButton = document.getElementById('assetSearchButton');
|
|
1061
|
+
els.assetShuffleButton = document.getElementById('assetShuffleButton');
|
|
1062
|
+
els.assetResults = document.getElementById('assetResults');
|
|
1063
|
+
els.editSavedAssets = document.getElementById('editSavedAssets');
|
|
820
1064
|
els.status = document.getElementById('status');
|
|
821
1065
|
|
|
822
1066
|
els.inspectLanguage = document.getElementById('inspectLanguage');
|
|
823
1067
|
|
|
824
|
-
if (!els.frame || !els.hitbox || !els.resizeHandle || !els.deckPrev || !els.deckNext || !els.deckCounter || !els.selectionSummary || !els.selectionChips || !els.editTab || !els.inspectTab || !els.editPanel || !els.inspectPanel || !els.comment || !els.commentThread || !els.send || !els.inspectButton || !els.inspectLanguage || !els.inspectCards || !els.inspectStale || !els.status) {
|
|
1068
|
+
if (!els.frame || !els.hitbox || !els.resizeHandle || !els.deckPrev || !els.deckNext || !els.deckCounter || !els.selectionSummary || !els.selectionChips || !els.editTab || !els.inspectTab || !els.editPanel || !els.inspectPanel || !els.comment || !els.commentThread || !els.send || !els.inspectComment || !els.inspectButton || !els.inspectLanguage || !els.inspectCards || !els.inspectStale || !els.assetSearchToggle || !els.assetSearchBack || !els.assetSearchView || !els.assetQuery || !els.assetPurpose || !els.assetSearchButton || !els.assetShuffleButton || !els.assetResults || !els.editSavedAssets || !els.status) {
|
|
825
1069
|
throw new Error('Editor boot failed: required DOM nodes are missing.');
|
|
826
1070
|
}
|
|
827
1071
|
|
|
@@ -830,6 +1074,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
|
|
|
830
1074
|
setMode(state.mode);
|
|
831
1075
|
setStatus('Editor ready. Ctrl/Cmd + click deck elements to reference them.');
|
|
832
1076
|
initFrame();
|
|
1077
|
+
loadSavedAssets();
|
|
833
1078
|
startDeckVersionPolling();
|
|
834
1079
|
} catch (error) {
|
|
835
1080
|
reportError(error);
|
|
@@ -856,11 +1101,19 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
|
|
|
856
1101
|
});
|
|
857
1102
|
els.comment.addEventListener('input', () => {
|
|
858
1103
|
saveCommentRange();
|
|
859
|
-
syncReferencesFromComment(false);
|
|
1104
|
+
syncReferencesFromComment(false, els.comment);
|
|
1105
|
+
syncSelectedAssetFromComment();
|
|
860
1106
|
updateSendState();
|
|
861
1107
|
});
|
|
862
1108
|
els.comment.addEventListener('keyup', saveCommentRange);
|
|
863
1109
|
els.comment.addEventListener('mouseup', saveCommentRange);
|
|
1110
|
+
els.inspectComment.addEventListener('input', () => {
|
|
1111
|
+
saveCommentRange();
|
|
1112
|
+
syncReferencesFromComment(false, els.inspectComment);
|
|
1113
|
+
updateSendState();
|
|
1114
|
+
});
|
|
1115
|
+
els.inspectComment.addEventListener('keyup', saveCommentRange);
|
|
1116
|
+
els.inspectComment.addEventListener('mouseup', saveCommentRange);
|
|
864
1117
|
document.addEventListener('selectionchange', saveCommentRange);
|
|
865
1118
|
els.hitbox.addEventListener('pointermove', onHover);
|
|
866
1119
|
els.hitbox.addEventListener('pointerdown', onPointerDown);
|
|
@@ -882,11 +1135,25 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
|
|
|
882
1135
|
els.deckNext.addEventListener('click', nextDeckSlide);
|
|
883
1136
|
els.send.addEventListener('click', sendComment);
|
|
884
1137
|
els.inspectButton.addEventListener('click', inspectCurrentSelection);
|
|
1138
|
+
els.assetSearchToggle.addEventListener('click', toggleAssetSearchPanel);
|
|
1139
|
+
els.assetSearchBack.addEventListener('click', closeAssetSearchPanel);
|
|
1140
|
+
els.assetSearchButton.addEventListener('click', () => searchAssets(false));
|
|
1141
|
+
els.assetShuffleButton.addEventListener('click', () => searchAssets(true));
|
|
1142
|
+
els.assetQuery.addEventListener('keydown', (event) => {
|
|
1143
|
+
if (event.key === 'Enter') {
|
|
1144
|
+
event.preventDefault();
|
|
1145
|
+
searchAssets(false);
|
|
1146
|
+
}
|
|
1147
|
+
});
|
|
1148
|
+
els.assetPurpose.addEventListener('change', () => resetAssetSearchBatch());
|
|
885
1149
|
els.inspectLanguage.addEventListener('change', () => {
|
|
886
1150
|
state.inspectLanguage = els.inspectLanguage.value || 'Auto';
|
|
887
1151
|
});
|
|
888
1152
|
els.editTab.addEventListener('click', () => setMode('edit'));
|
|
889
1153
|
els.inspectTab.addEventListener('click', () => setMode('inspect'));
|
|
1154
|
+
els.hitbox.addEventListener('dragover', onAssetDragOver);
|
|
1155
|
+
els.hitbox.addEventListener('dragleave', onAssetDragLeave);
|
|
1156
|
+
els.hitbox.addEventListener('drop', onAssetDrop);
|
|
890
1157
|
}
|
|
891
1158
|
|
|
892
1159
|
function setMode(mode) {
|
|
@@ -895,9 +1162,31 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
|
|
|
895
1162
|
els.inspectTab.classList.toggle('active', state.mode === 'inspect');
|
|
896
1163
|
els.editPanel.classList.toggle('active', state.mode === 'edit');
|
|
897
1164
|
els.inspectPanel.classList.toggle('active', state.mode === 'inspect');
|
|
1165
|
+
saveCommentRange();
|
|
898
1166
|
updateSendState();
|
|
899
1167
|
}
|
|
900
1168
|
|
|
1169
|
+
function activeCommentEditor() {
|
|
1170
|
+
return state.mode === 'inspect' ? els.inspectComment : els.comment;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function toggleAssetSearchPanel() {
|
|
1174
|
+
const open = !els.assetSearchView.classList.contains('open');
|
|
1175
|
+
setAssetSearchOpen(open);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function closeAssetSearchPanel() {
|
|
1179
|
+
setAssetSearchOpen(false);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function setAssetSearchOpen(open) {
|
|
1183
|
+
els.assetSearchView.classList.toggle('open', open);
|
|
1184
|
+
els.assetSearchView.setAttribute('aria-hidden', open ? 'false' : 'true');
|
|
1185
|
+
els.assetSearchToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
1186
|
+
els.assetSearchToggle.textContent = '+';
|
|
1187
|
+
if (open) els.assetQuery.focus();
|
|
1188
|
+
}
|
|
1189
|
+
|
|
901
1190
|
function restoreEditorWidth() {
|
|
902
1191
|
try {
|
|
903
1192
|
const saved = Number(window.localStorage.getItem(EDITOR_WIDTH_KEY));
|
|
@@ -960,6 +1249,8 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
|
|
|
960
1249
|
clearReferences(false);
|
|
961
1250
|
state.hoverEl = null;
|
|
962
1251
|
state.hoverOutline = createOutline(doc, '#38bdf8', 'rgba(56,189,248,.12)');
|
|
1252
|
+
state.assetDropTarget = null;
|
|
1253
|
+
state.assetDropOutline = createOutline(doc, '#a9793f', 'rgba(169,121,63,.16)');
|
|
963
1254
|
state.referenceOutlines = [];
|
|
964
1255
|
doc.addEventListener('scroll', () => {
|
|
965
1256
|
renderHoverOutline(state.hoverEl);
|
|
@@ -1110,6 +1401,8 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
|
|
|
1110
1401
|
clearReferences(true);
|
|
1111
1402
|
state.hoverEl = null;
|
|
1112
1403
|
if (state.hoverOutline) state.hoverOutline.style.display = 'none';
|
|
1404
|
+
state.assetDropTarget = null;
|
|
1405
|
+
if (state.assetDropOutline) state.assetDropOutline.style.display = 'none';
|
|
1113
1406
|
state.referenceOutlines.forEach((outline) => outline.style.display = 'none');
|
|
1114
1407
|
state.referenceOutlines = [];
|
|
1115
1408
|
updateSendState();
|
|
@@ -1161,34 +1454,353 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
|
|
|
1161
1454
|
}
|
|
1162
1455
|
|
|
1163
1456
|
async function sendComment() {
|
|
1164
|
-
syncReferencesFromComment(false);
|
|
1457
|
+
syncReferencesFromComment(false, els.comment);
|
|
1458
|
+
syncSelectedAssetFromComment();
|
|
1165
1459
|
const text = getCommentText().trim();
|
|
1166
1460
|
if (!text) return;
|
|
1167
1461
|
const elements = state.references.map((reference) => reference.payload);
|
|
1462
|
+
const asset = state.selectedAsset || undefined;
|
|
1168
1463
|
const commentId = addPendingComment(text, elements, 'sending');
|
|
1169
1464
|
clearReferences(false);
|
|
1465
|
+
state.selectedAsset = null;
|
|
1170
1466
|
els.comment.textContent = '';
|
|
1171
1467
|
renderReferenceOutlines();
|
|
1172
|
-
|
|
1468
|
+
state.sendingEdit = true;
|
|
1469
|
+
updateSendState();
|
|
1173
1470
|
setStatus('Sending...');
|
|
1174
1471
|
try {
|
|
1175
1472
|
const res = await fetch('/api/comment?token=' + encodeURIComponent(token), {
|
|
1176
1473
|
method: 'POST',
|
|
1177
1474
|
headers: { 'content-type': 'application/json' },
|
|
1178
|
-
body: JSON.stringify({ comment: text, elements }),
|
|
1475
|
+
body: JSON.stringify({ comment: text, elements, asset }),
|
|
1179
1476
|
});
|
|
1180
1477
|
const body = await res.json().catch(() => ({}));
|
|
1181
1478
|
if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to send comment');
|
|
1182
1479
|
updatePendingCommentStatus(commentId, 'sent', { baseDeckVersion: body.deckVersion || state.deckVersion });
|
|
1183
1480
|
if (pendingCommentStatus(commentId) !== 'updated') setStatus('Comment sent. Waiting for deck update...');
|
|
1481
|
+
state.sendingEdit = false;
|
|
1184
1482
|
updateSendState();
|
|
1185
1483
|
} catch (error) {
|
|
1186
1484
|
updatePendingCommentStatus(commentId, 'failed');
|
|
1485
|
+
state.sendingEdit = false;
|
|
1187
1486
|
reportError(error);
|
|
1188
1487
|
updateSendState();
|
|
1189
1488
|
}
|
|
1190
1489
|
}
|
|
1191
1490
|
|
|
1491
|
+
async function searchAssets(nextBatch) {
|
|
1492
|
+
const query = (els.assetQuery.value || '').trim();
|
|
1493
|
+
if (!query || state.assetSearchBusy) return;
|
|
1494
|
+
const key = query + '\u0000' + (els.assetPurpose.value || 'illustration');
|
|
1495
|
+
if (!nextBatch || state.assetSearchKey !== key) {
|
|
1496
|
+
state.assetSearchPage = 1;
|
|
1497
|
+
state.assetSearchKey = key;
|
|
1498
|
+
} else {
|
|
1499
|
+
state.assetSearchPage += 1;
|
|
1500
|
+
}
|
|
1501
|
+
state.assetSearchBusy = true;
|
|
1502
|
+
els.assetSearchButton.disabled = true;
|
|
1503
|
+
els.assetShuffleButton.disabled = true;
|
|
1504
|
+
renderAssetSearchLoading(nextBatch ? 'Searching another batch...' : 'Searching remote image sources...');
|
|
1505
|
+
setButtonLoading(els.assetSearchButton, true, 'Searching...');
|
|
1506
|
+
setStatus(nextBatch ? 'Searching another asset batch...' : 'Searching assets...');
|
|
1507
|
+
try {
|
|
1508
|
+
const params = new URLSearchParams({ query, purpose: els.assetPurpose.value || 'illustration', limit: '24', page: String(state.assetSearchPage) });
|
|
1509
|
+
const res = await fetch('/api/assets/search?token=' + encodeURIComponent(token) + '&' + params.toString(), { cache: 'no-store' });
|
|
1510
|
+
const body = await res.json().catch(() => ({}));
|
|
1511
|
+
if (!res.ok || !body.ok) throw new Error(body.error || 'Asset search failed');
|
|
1512
|
+
state.assetCandidates = Array.isArray(body.candidates) ? body.candidates : [];
|
|
1513
|
+
renderAssetCandidates();
|
|
1514
|
+
setStatus(state.assetCandidates.length ? 'Asset search complete. Click + to save an asset to the workspace.' : (nextBatch ? 'No more assets found. Try another query or purpose.' : 'No assets found. Try another query or purpose.'));
|
|
1515
|
+
} catch (error) {
|
|
1516
|
+
if (nextBatch) state.assetSearchPage = Math.max(1, state.assetSearchPage - 1);
|
|
1517
|
+
els.assetResults.innerHTML = '<p class="asset-empty">' + escapeHtml(error && error.message ? error.message : String(error)) + '</p>';
|
|
1518
|
+
reportError(error);
|
|
1519
|
+
} finally {
|
|
1520
|
+
state.assetSearchBusy = false;
|
|
1521
|
+
setButtonLoading(els.assetSearchButton, false, 'Search Assets');
|
|
1522
|
+
updateAssetShuffleState();
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
function renderAssetSearchLoading(message) {
|
|
1527
|
+
els.assetResults.innerHTML = '<p class="asset-empty"><span class="loading-row"><span class="spinner" aria-hidden="true"></span>' + escapeHtml(message) + '</span></p>'
|
|
1528
|
+
+ Array.from({ length: 8 }, () => '<div class="skeleton-card asset-skeleton"><div class="skeleton-line long"></div><div class="skeleton-line medium"></div></div>').join('');
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
function resetAssetSearchBatch() {
|
|
1532
|
+
state.assetSearchPage = 1;
|
|
1533
|
+
state.assetSearchKey = '';
|
|
1534
|
+
updateAssetShuffleState();
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
function updateAssetShuffleState() {
|
|
1538
|
+
els.assetShuffleButton.disabled = state.assetSearchBusy || !state.assetCandidates.length;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
function renderAssetCandidates() {
|
|
1542
|
+
els.assetResults.textContent = '';
|
|
1543
|
+
state.assetVisibleCount = 0;
|
|
1544
|
+
state.assetPendingCount = state.assetCandidates.length;
|
|
1545
|
+
if (!state.assetCandidates.length) {
|
|
1546
|
+
els.assetResults.innerHTML = '<p class="asset-empty">No assets found. Try another query or purpose.</p>';
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
state.assetCandidates.forEach((candidate, index) => {
|
|
1550
|
+
const card = assetCard(candidate, false, index);
|
|
1551
|
+
if (state.assetSavingIndex === index) {
|
|
1552
|
+
card.classList.add('is-saving');
|
|
1553
|
+
appendAssetSaveButton(card, 'Saving...', 'Saving to workspace', () => {}, true);
|
|
1554
|
+
} else {
|
|
1555
|
+
appendAssetSaveButton(card, 'Save', 'Save to workspace', () => saveCandidate(index));
|
|
1556
|
+
}
|
|
1557
|
+
els.assetResults.appendChild(card);
|
|
1558
|
+
});
|
|
1559
|
+
updateAssetShuffleState();
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
async function saveCandidate(index) {
|
|
1563
|
+
const candidate = state.assetCandidates[index];
|
|
1564
|
+
if (!candidate) return;
|
|
1565
|
+
state.assetSavingIndex = index;
|
|
1566
|
+
renderAssetCandidates();
|
|
1567
|
+
setStatus('Saving asset to workspace...');
|
|
1568
|
+
try {
|
|
1569
|
+
const res = await fetch('/api/assets/save?token=' + encodeURIComponent(token), {
|
|
1570
|
+
method: 'POST',
|
|
1571
|
+
headers: { 'content-type': 'application/json' },
|
|
1572
|
+
body: JSON.stringify({ candidate, purpose: els.assetPurpose.value || candidate.purpose || 'illustration' }),
|
|
1573
|
+
});
|
|
1574
|
+
const body = await res.json().catch(() => ({}));
|
|
1575
|
+
if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to save asset');
|
|
1576
|
+
await loadSavedAssets();
|
|
1577
|
+
const path = body.asset && (body.asset.path || body.asset.deckPath);
|
|
1578
|
+
setStatus(path ? 'Saved to ' + path + '. Use it from Local Assets.' : 'Asset saved. Use it from Local Assets.');
|
|
1579
|
+
} catch (error) {
|
|
1580
|
+
reportError(error);
|
|
1581
|
+
} finally {
|
|
1582
|
+
state.assetSavingIndex = -1;
|
|
1583
|
+
renderAssetCandidates();
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
async function loadSavedAssets() {
|
|
1588
|
+
try {
|
|
1589
|
+
const res = await fetch('/api/assets/list?token=' + encodeURIComponent(token), { cache: 'no-store' });
|
|
1590
|
+
const body = await res.json().catch(() => ({}));
|
|
1591
|
+
if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to list assets');
|
|
1592
|
+
state.savedAssets = Array.isArray(body.assets) ? body.assets : [];
|
|
1593
|
+
renderSavedAssets();
|
|
1594
|
+
} catch (error) {
|
|
1595
|
+
const message = '<p class="asset-empty">' + escapeHtml(error && error.message ? error.message : String(error)) + '</p>';
|
|
1596
|
+
els.editSavedAssets.innerHTML = message;
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
function renderSavedAssets() {
|
|
1601
|
+
renderSavedAssetGrid(els.editSavedAssets, 'No local assets yet. Click + to search assets.');
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
function renderSavedAssetGrid(container, emptyMessage) {
|
|
1605
|
+
container.textContent = '';
|
|
1606
|
+
if (!state.savedAssets.length) {
|
|
1607
|
+
container.innerHTML = '<p class="asset-empty">' + escapeHtml(emptyMessage) + '</p>';
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
state.savedAssets.forEach((asset) => {
|
|
1611
|
+
const card = assetCard(asset, true, 0);
|
|
1612
|
+
card.draggable = true;
|
|
1613
|
+
card.addEventListener('click', () => addAssetToComment(asset));
|
|
1614
|
+
card.addEventListener('dragstart', (event) => {
|
|
1615
|
+
state.draggingAsset = asset;
|
|
1616
|
+
event.dataTransfer?.setData('application/revela-asset-id', asset.id || '');
|
|
1617
|
+
event.dataTransfer?.setData('text/plain', asset.path || asset.id || '');
|
|
1618
|
+
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'copy';
|
|
1619
|
+
});
|
|
1620
|
+
card.addEventListener('dragend', () => {
|
|
1621
|
+
state.draggingAsset = null;
|
|
1622
|
+
document.body.classList.remove('drop-active');
|
|
1623
|
+
});
|
|
1624
|
+
container.appendChild(card);
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
function assetCard(asset, saved, index) {
|
|
1629
|
+
const card = document.createElement('div');
|
|
1630
|
+
card.className = saved ? 'asset-card saved' : 'asset-card';
|
|
1631
|
+
const image = document.createElement('img');
|
|
1632
|
+
image.className = 'asset-thumb';
|
|
1633
|
+
image.loading = !saved && index < 8 ? 'eager' : 'lazy';
|
|
1634
|
+
image.decoding = 'async';
|
|
1635
|
+
image.alt = asset.alt || asset.title || asset.id || 'Image asset';
|
|
1636
|
+
if (!saved) {
|
|
1637
|
+
image.addEventListener('load', () => markAssetImageLoaded());
|
|
1638
|
+
image.addEventListener('error', () => hideBrokenAssetCard(card));
|
|
1639
|
+
}
|
|
1640
|
+
image.src = saved ? (asset.previewUrl || '') : (asset.thumbnailUrl || asset.imageUrl || '');
|
|
1641
|
+
card.title = asset.title || asset.id || asset.alt || 'Image asset';
|
|
1642
|
+
card.appendChild(image);
|
|
1643
|
+
return card;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
function markAssetImageLoaded() {
|
|
1647
|
+
state.assetVisibleCount += 1;
|
|
1648
|
+
state.assetPendingCount = Math.max(0, state.assetPendingCount - 1);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
function hideBrokenAssetCard(card) {
|
|
1652
|
+
card.remove();
|
|
1653
|
+
state.assetPendingCount = Math.max(0, state.assetPendingCount - 1);
|
|
1654
|
+
if (!state.assetVisibleCount && !state.assetPendingCount) {
|
|
1655
|
+
els.assetResults.innerHTML = '<p class="asset-empty">No displayable images found. Try Refresh or another purpose.</p>';
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
function appendAssetSaveButton(card, text, label, onClick, loading) {
|
|
1660
|
+
const button = document.createElement('button');
|
|
1661
|
+
button.type = 'button';
|
|
1662
|
+
button.className = 'asset-save';
|
|
1663
|
+
button.innerHTML = loading ? '<span class="spinner" aria-hidden="true"></span><span>' + escapeHtml(text) + '</span>' : escapeHtml(text);
|
|
1664
|
+
button.disabled = !!loading;
|
|
1665
|
+
button.setAttribute('aria-label', label);
|
|
1666
|
+
button.title = label;
|
|
1667
|
+
button.addEventListener('click', onClick);
|
|
1668
|
+
card.appendChild(button);
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
function addAssetToComment(asset) {
|
|
1672
|
+
if (!asset) return;
|
|
1673
|
+
state.selectedAsset = asset;
|
|
1674
|
+
removeAssetChip();
|
|
1675
|
+
const intro = els.comment.textContent && !/\s$/.test(els.comment.textContent) ? ' Use asset ' : 'Use asset ';
|
|
1676
|
+
insertPlainText(intro);
|
|
1677
|
+
insertAssetChip(asset);
|
|
1678
|
+
insertPlainText(' ');
|
|
1679
|
+
setMode('edit');
|
|
1680
|
+
updateSendState();
|
|
1681
|
+
setStatus('Asset added to the Edit comment. Describe where or how to use it, then Apply Fix.');
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
function onAssetDragOver(event) {
|
|
1685
|
+
if (!state.draggingAsset) return;
|
|
1686
|
+
event.preventDefault();
|
|
1687
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
|
1688
|
+
document.body.classList.add('drop-active');
|
|
1689
|
+
const placement = collectAssetPlacement(event, state.draggingAsset);
|
|
1690
|
+
renderAssetDropTarget(placement);
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
function onAssetDragLeave() {
|
|
1694
|
+
document.body.classList.remove('drop-active');
|
|
1695
|
+
renderAssetDropTarget(null);
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
async function onAssetDrop(event) {
|
|
1699
|
+
const asset = state.draggingAsset || findSavedAsset(event.dataTransfer?.getData('application/revela-asset-id'));
|
|
1700
|
+
if (!asset) return;
|
|
1701
|
+
event.preventDefault();
|
|
1702
|
+
document.body.classList.remove('drop-active');
|
|
1703
|
+
renderAssetDropTarget(null);
|
|
1704
|
+
const placement = collectAssetPlacement(event, asset);
|
|
1705
|
+
if (!placement) {
|
|
1706
|
+
setStatus('Drop the asset onto a deck slide.');
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
await sendAssetPlacement(asset, placement);
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
function findSavedAsset(id) {
|
|
1713
|
+
return state.savedAssets.find((asset) => asset.id === id) || null;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
function collectAssetPlacement(event, asset) {
|
|
1717
|
+
initFrame();
|
|
1718
|
+
const doc = els.frame.contentDocument;
|
|
1719
|
+
const win = els.frame.contentWindow;
|
|
1720
|
+
if (!doc || !win) return null;
|
|
1721
|
+
const frameRect = els.frame.getBoundingClientRect();
|
|
1722
|
+
const frameX = event.clientX - frameRect.left;
|
|
1723
|
+
const frameY = event.clientY - frameRect.top;
|
|
1724
|
+
const rawTarget = doc.elementFromPoint(frameX, frameY);
|
|
1725
|
+
const target = selectable(rawTarget);
|
|
1726
|
+
const slide = findSlide(target) || findSlide(rawTarget);
|
|
1727
|
+
if (!slide) return null;
|
|
1728
|
+
const slides = getSlides(doc);
|
|
1729
|
+
const explicitSlideIndex = Number(slide.getAttribute('data-slide-index'));
|
|
1730
|
+
const slideIndex = Number.isFinite(explicitSlideIndex) && explicitSlideIndex > 0 ? explicitSlideIndex : slides.indexOf(slide) + 1;
|
|
1731
|
+
const slideRect = slide.getBoundingClientRect();
|
|
1732
|
+
const x = slideRect.width ? Math.max(0, Math.min(1, (frameX - slideRect.left) / slideRect.width)) : 0;
|
|
1733
|
+
const y = slideRect.height ? Math.max(0, Math.min(1, (frameY - slideRect.top) / slideRect.height)) : 0;
|
|
1734
|
+
const targetPayload = target && target !== slide ? collectPayload(target) : null;
|
|
1735
|
+
const targetMode = targetPayload
|
|
1736
|
+
? (target && target.tagName && target.tagName.toLowerCase() === 'img' ? 'replace' : 'insert-into')
|
|
1737
|
+
: 'add';
|
|
1738
|
+
return {
|
|
1739
|
+
slideIndex,
|
|
1740
|
+
x,
|
|
1741
|
+
y,
|
|
1742
|
+
viewport: { width: win.innerWidth, height: win.innerHeight },
|
|
1743
|
+
nearbyText: (slide.innerText || slide.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 1000),
|
|
1744
|
+
targetMode,
|
|
1745
|
+
target: targetPayload,
|
|
1746
|
+
targetLabel: targetMode === 'replace' ? 'Replace image' : targetMode === 'insert-into' ? 'Insert into this element' : 'Add near here',
|
|
1747
|
+
targetTagName: target?.tagName?.toLowerCase(),
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
function renderAssetDropTarget(placement) {
|
|
1752
|
+
if (!state.assetDropOutline) return;
|
|
1753
|
+
const target = placement?.target ? elementFromPayload(placement.target) : null;
|
|
1754
|
+
state.assetDropTarget = target;
|
|
1755
|
+
renderBox(state.assetDropOutline, target);
|
|
1756
|
+
if (placement?.targetLabel) setStatus(placement.targetLabel + '. Drop to send an asset placement comment.');
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
function elementFromPayload(payload) {
|
|
1760
|
+
const doc = els.frame.contentDocument;
|
|
1761
|
+
if (!doc || !payload) return null;
|
|
1762
|
+
const slides = getSlides(doc);
|
|
1763
|
+
const slide = slides.find((item, index) => {
|
|
1764
|
+
const explicit = Number(item.getAttribute('data-slide-index'));
|
|
1765
|
+
const slideIndex = Number.isFinite(explicit) && explicit > 0 ? explicit : index + 1;
|
|
1766
|
+
return slideIndex === payload.slideIndex;
|
|
1767
|
+
});
|
|
1768
|
+
if (!slide) return null;
|
|
1769
|
+
if (payload.selector) {
|
|
1770
|
+
try {
|
|
1771
|
+
const selected = slide.querySelector(payload.selector);
|
|
1772
|
+
if (selected) return selected;
|
|
1773
|
+
} catch {}
|
|
1774
|
+
}
|
|
1775
|
+
return slide;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
async function sendAssetPlacement(asset, placement) {
|
|
1779
|
+
const modeText = placement.targetMode === 'replace'
|
|
1780
|
+
? 'replace the image at the drop target'
|
|
1781
|
+
: placement.targetMode === 'insert-into'
|
|
1782
|
+
? 'insert it into the target element'
|
|
1783
|
+
: 'add it near the drop point';
|
|
1784
|
+
const comment = 'Place workspace asset ' + asset.path + ' on slide ' + placement.slideIndex + ' as a ' + (asset.purpose || 'visual asset') + '; ' + modeText + '. Preserve the current layout and do not cover existing text, charts, tables, or evidence.';
|
|
1785
|
+
const elements = placement.target ? [placement.target] : [];
|
|
1786
|
+
const commentId = addPendingComment(comment, elements, 'sending');
|
|
1787
|
+
setStatus('Sending asset placement comment...');
|
|
1788
|
+
try {
|
|
1789
|
+
const res = await fetch('/api/comment?token=' + encodeURIComponent(token), {
|
|
1790
|
+
method: 'POST',
|
|
1791
|
+
headers: { 'content-type': 'application/json' },
|
|
1792
|
+
body: JSON.stringify({ comment, elements, asset, drop: placement }),
|
|
1793
|
+
});
|
|
1794
|
+
const body = await res.json().catch(() => ({}));
|
|
1795
|
+
if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to send asset placement');
|
|
1796
|
+
updatePendingCommentStatus(commentId, 'sent', { baseDeckVersion: body.deckVersion || state.deckVersion });
|
|
1797
|
+
if (pendingCommentStatus(commentId) !== 'updated') setStatus('Asset placement sent. Waiting for deck update...');
|
|
1798
|
+
} catch (error) {
|
|
1799
|
+
updatePendingCommentStatus(commentId, 'failed');
|
|
1800
|
+
reportError(error);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1192
1804
|
function selectable(node) {
|
|
1193
1805
|
if (!node || node.nodeType !== 1) return null;
|
|
1194
1806
|
if (node === state.hoverOutline || state.referenceOutlines.includes(node)) return null;
|
|
@@ -1217,7 +1829,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
|
|
|
1217
1829
|
renderReferenceOutlines();
|
|
1218
1830
|
updateSendState();
|
|
1219
1831
|
renderSelectionSummary();
|
|
1220
|
-
resetInspectCards('References ready. Open Inspect and click Inspect
|
|
1832
|
+
resetInspectCards('References ready. Open Inspect and click Inspect Reference for concise Purpose and Source context.');
|
|
1221
1833
|
setStatus('Inserted @' + label + '. ' + state.references.length + ' reference' + (state.references.length === 1 ? '' : 's') + ' will be sent.');
|
|
1222
1834
|
}
|
|
1223
1835
|
|
|
@@ -1239,8 +1851,9 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
|
|
|
1239
1851
|
updateSendState();
|
|
1240
1852
|
}
|
|
1241
1853
|
|
|
1242
|
-
function syncReferencesFromComment(showStatus) {
|
|
1243
|
-
const
|
|
1854
|
+
function syncReferencesFromComment(showStatus, editor) {
|
|
1855
|
+
const source = editor || activeCommentEditor();
|
|
1856
|
+
const activeIds = new Set(Array.from(source.querySelectorAll('.ref-chip[data-ref-id]')).map((chip) => chip.getAttribute('data-ref-id')));
|
|
1244
1857
|
const before = state.references.length;
|
|
1245
1858
|
state.references = state.references.filter((reference) => activeIds.has(reference.id));
|
|
1246
1859
|
if (state.references.length !== before) {
|
|
@@ -1250,6 +1863,11 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
|
|
|
1250
1863
|
}
|
|
1251
1864
|
}
|
|
1252
1865
|
|
|
1866
|
+
function syncSelectedAssetFromComment() {
|
|
1867
|
+
if (els.comment.querySelector('.asset-ref-chip[data-asset-id]')) return;
|
|
1868
|
+
state.selectedAsset = null;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1253
1871
|
function addPendingComment(text, elements, status) {
|
|
1254
1872
|
const id = 'comment-' + state.nextCommentId++;
|
|
1255
1873
|
state.pendingComments.push({
|
|
@@ -1394,10 +2012,20 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
|
|
|
1394
2012
|
}
|
|
1395
2013
|
|
|
1396
2014
|
function updateSendState() {
|
|
1397
|
-
els.send
|
|
2015
|
+
if (state.sendingEdit) setButtonLoading(els.send, true, 'Sending...');
|
|
2016
|
+
else setButtonLoading(els.send, false, '<svg class="send-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94L14.7 6.3z"/></svg><span>Apply Fix</span>', true);
|
|
2017
|
+
els.send.disabled = state.sendingEdit || !getCommentText().trim();
|
|
2018
|
+
if (state.inspecting) setButtonLoading(els.inspectButton, true, 'Inspecting...');
|
|
2019
|
+
else setButtonLoading(els.inspectButton, false, 'Inspect Reference');
|
|
1398
2020
|
els.inspectButton.disabled = state.inspecting || state.references.length === 0;
|
|
1399
2021
|
}
|
|
1400
2022
|
|
|
2023
|
+
function setButtonLoading(button, loading, label, html) {
|
|
2024
|
+
if (!button) return;
|
|
2025
|
+
button.innerHTML = loading ? '<span class="spinner" aria-hidden="true"></span><span>' + escapeHtml(label) + '</span>' : (html ? label : escapeHtml(label));
|
|
2026
|
+
button.disabled = !!loading;
|
|
2027
|
+
}
|
|
2028
|
+
|
|
1401
2029
|
function renderSelectionSummary() {
|
|
1402
2030
|
const label = state.references.length
|
|
1403
2031
|
? state.references.length + ' referenced element' + (state.references.length === 1 ? '' : 's') + ' selected.'
|
|
@@ -1421,27 +2049,39 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
|
|
|
1421
2049
|
els.inspectCards.innerHTML = '<div class="inspect-empty">' + escapeHtml(message) + '</div>';
|
|
1422
2050
|
}
|
|
1423
2051
|
|
|
2052
|
+
function renderInspectLoading(message) {
|
|
2053
|
+
els.inspectCards.innerHTML = '<div class="inspect-loading"><span class="loading-row"><span class="spinner" aria-hidden="true"></span><b>' + escapeHtml(message) + '</b></span><br>Preparing concise Purpose and Source context.</div>'
|
|
2054
|
+
+ '<div class="skeleton-card"><div class="skeleton-line short"></div><div class="skeleton-line long"></div><div class="skeleton-line medium"></div></div>'
|
|
2055
|
+
+ '<div class="skeleton-card"><div class="skeleton-line short"></div><div class="skeleton-line long"></div><div class="skeleton-line medium"></div></div>';
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
function getInspectComment() {
|
|
2059
|
+
syncReferencesFromComment(false, els.inspectComment);
|
|
2060
|
+
return getCommentText(els.inspectComment).trim().slice(0, 2000);
|
|
2061
|
+
}
|
|
2062
|
+
|
|
1424
2063
|
async function inspectCurrentSelection() {
|
|
1425
2064
|
if (!state.references.length || state.inspecting) return;
|
|
1426
2065
|
const snapshot = collectReferenceSnapshot();
|
|
2066
|
+
const comment = getInspectComment();
|
|
1427
2067
|
state.inspecting = true;
|
|
1428
2068
|
updateSendState();
|
|
1429
2069
|
setMode('inspect');
|
|
1430
2070
|
els.inspectStale.innerHTML = '';
|
|
1431
2071
|
state.inspectFallback = null;
|
|
1432
|
-
|
|
2072
|
+
renderInspectLoading('Reading selection...');
|
|
1433
2073
|
try {
|
|
1434
2074
|
const res = await fetch('/api/inspect?token=' + encodeURIComponent(token), {
|
|
1435
2075
|
method: 'POST',
|
|
1436
2076
|
headers: { 'content-type': 'application/json' },
|
|
1437
|
-
body: JSON.stringify({ snapshot, deckVersion: state.deckVersion, language: state.inspectLanguage }),
|
|
2077
|
+
body: JSON.stringify({ snapshot, deckVersion: state.deckVersion, language: state.inspectLanguage, comment }),
|
|
1438
2078
|
});
|
|
1439
2079
|
const body = await res.json().catch(() => ({}));
|
|
1440
2080
|
if (!res.ok || !body.ok) throw new Error(body.error || 'Inspection failed');
|
|
1441
2081
|
state.deckVersion = body.deckVersion || state.deckVersion;
|
|
1442
2082
|
state.activeInspectRequestId = body.requestId;
|
|
1443
2083
|
state.inspectFallback = body.preprocess || null;
|
|
1444
|
-
|
|
2084
|
+
renderInspectLoading('Waiting for Purpose and Source...');
|
|
1445
2085
|
await pollInspectResult(body.requestId);
|
|
1446
2086
|
} catch (error) {
|
|
1447
2087
|
if (state.inspectFallback) {
|
|
@@ -1500,8 +2140,6 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
|
|
|
1500
2140
|
else els.inspectStale.innerHTML = '';
|
|
1501
2141
|
els.inspectCards.innerHTML = [
|
|
1502
2142
|
'<div class="status">' + escapeHtml(phase || 'Inspection') + '</div>',
|
|
1503
|
-
result.cards.reading ? renderInspectCard('Narrative Reading', result.cards.reading.status, result.cards.reading.rationale, renderReading(result.cards.reading)) : '',
|
|
1504
|
-
result.cards.exploratory ? renderInspectCard('Exploratory Reading', result.cards.exploratory.status, result.cards.exploratory.rationale, renderExploratory(result.cards.exploratory)) : '',
|
|
1505
2143
|
renderInspectCard('Purpose', result.cards.purpose.status, result.cards.purpose.rationale, renderPurpose(result.cards.purpose)),
|
|
1506
2144
|
renderInspectCard('Source', result.cards.source.status, result.cards.source.rationale, renderSource(result.cards.source)),
|
|
1507
2145
|
].join('');
|
|
@@ -1579,6 +2217,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
|
|
|
1579
2217
|
}
|
|
1580
2218
|
|
|
1581
2219
|
function insertReferenceChip(reference) {
|
|
2220
|
+
const editor = activeCommentEditor();
|
|
1582
2221
|
const chip = document.createElement('span');
|
|
1583
2222
|
chip.className = 'ref-chip';
|
|
1584
2223
|
chip.contentEditable = 'false';
|
|
@@ -1596,16 +2235,60 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
|
|
|
1596
2235
|
range.collapse(true);
|
|
1597
2236
|
applyCommentRange(range);
|
|
1598
2237
|
} else {
|
|
1599
|
-
if (
|
|
1600
|
-
|
|
1601
|
-
|
|
2238
|
+
if (editor.textContent && !/\\s$/.test(editor.textContent)) editor.appendChild(document.createTextNode(' '));
|
|
2239
|
+
editor.appendChild(chip);
|
|
2240
|
+
editor.appendChild(trailingSpace);
|
|
1602
2241
|
placeCaretAfter(trailingSpace);
|
|
1603
2242
|
}
|
|
2243
|
+
editor.focus();
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
function insertAssetChip(asset) {
|
|
2247
|
+
const chip = document.createElement('span');
|
|
2248
|
+
chip.className = 'ref-chip asset-ref-chip';
|
|
2249
|
+
chip.contentEditable = 'false';
|
|
2250
|
+
chip.dataset.assetId = asset.id || '';
|
|
2251
|
+
chip.style.setProperty('--ref-bg', '#dcfce7');
|
|
2252
|
+
chip.style.setProperty('--ref-border', '#86efac');
|
|
2253
|
+
chip.style.setProperty('--ref-text', '#166534');
|
|
2254
|
+
chip.textContent = '@Asset ' + (asset.id || asset.deckPath || asset.path || 'image');
|
|
2255
|
+
const range = getCommentInsertRange();
|
|
2256
|
+
if (range) {
|
|
2257
|
+
range.insertNode(chip);
|
|
2258
|
+
range.setStartAfter(chip);
|
|
2259
|
+
range.collapse(true);
|
|
2260
|
+
applyCommentRange(range);
|
|
2261
|
+
} else {
|
|
2262
|
+
els.comment.appendChild(chip);
|
|
2263
|
+
placeCaretAfter(chip);
|
|
2264
|
+
}
|
|
1604
2265
|
els.comment.focus();
|
|
1605
2266
|
}
|
|
1606
2267
|
|
|
2268
|
+
function insertPlainText(text) {
|
|
2269
|
+
const node = document.createTextNode(text);
|
|
2270
|
+
const range = getCommentInsertRange();
|
|
2271
|
+
if (range) {
|
|
2272
|
+
range.insertNode(node);
|
|
2273
|
+
range.setStartAfter(node);
|
|
2274
|
+
range.collapse(true);
|
|
2275
|
+
applyCommentRange(range);
|
|
2276
|
+
} else {
|
|
2277
|
+
els.comment.appendChild(node);
|
|
2278
|
+
placeCaretAfter(node);
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
function removeAssetChip() {
|
|
2283
|
+
els.comment.querySelectorAll('.asset-ref-chip').forEach((chip) => {
|
|
2284
|
+
const next = chip.nextSibling;
|
|
2285
|
+
chip.remove();
|
|
2286
|
+
if (next && next.nodeType === Node.TEXT_NODE && next.textContent === ' ') next.remove();
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
|
|
1607
2290
|
function removeReferenceChip(id) {
|
|
1608
|
-
const chip =
|
|
2291
|
+
const chip = activeCommentEditor().querySelector('.ref-chip[data-ref-id="' + cssEscape(id) + '"]');
|
|
1609
2292
|
if (!chip) return;
|
|
1610
2293
|
const next = chip.nextSibling;
|
|
1611
2294
|
chip.remove();
|
|
@@ -1614,13 +2297,18 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
|
|
|
1614
2297
|
|
|
1615
2298
|
function clearReferences(removeChips) {
|
|
1616
2299
|
state.references = [];
|
|
1617
|
-
if (removeChips)
|
|
2300
|
+
if (removeChips) {
|
|
2301
|
+
activeCommentEditor().querySelectorAll('.ref-chip[data-ref-id]').forEach((chip) => chip.remove());
|
|
2302
|
+
state.selectedAsset = null;
|
|
2303
|
+
removeAssetChip();
|
|
2304
|
+
}
|
|
1618
2305
|
renderSelectionSummary();
|
|
1619
|
-
resetInspectCards('Select
|
|
2306
|
+
resetInspectCards('Select a deck element to create an @ref, optionally ask a question, then Inspect. This does not edit the deck.');
|
|
1620
2307
|
}
|
|
1621
2308
|
|
|
1622
|
-
function getCommentText() {
|
|
1623
|
-
|
|
2309
|
+
function getCommentText(editor) {
|
|
2310
|
+
const source = editor || els.comment;
|
|
2311
|
+
return (source.innerText || source.textContent || '').replace(/\\u00a0/g, ' ');
|
|
1624
2312
|
}
|
|
1625
2313
|
|
|
1626
2314
|
function placeCaretAfter(node) {
|
|
@@ -1633,18 +2321,20 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
|
|
|
1633
2321
|
function saveCommentRange() {
|
|
1634
2322
|
const selection = window.getSelection();
|
|
1635
2323
|
if (!selection || selection.rangeCount === 0) return;
|
|
1636
|
-
|
|
2324
|
+
const editor = activeCommentEditor();
|
|
2325
|
+
if (!editor || !editor.contains(selection.anchorNode)) return;
|
|
1637
2326
|
state.commentRange = selection.getRangeAt(0).cloneRange();
|
|
1638
2327
|
}
|
|
1639
2328
|
|
|
1640
2329
|
function getCommentInsertRange() {
|
|
2330
|
+
const editor = activeCommentEditor();
|
|
1641
2331
|
const selection = window.getSelection();
|
|
1642
|
-
if (selection && selection.rangeCount > 0 &&
|
|
2332
|
+
if (selection && selection.rangeCount > 0 && editor.contains(selection.anchorNode)) {
|
|
1643
2333
|
const range = selection.getRangeAt(0).cloneRange();
|
|
1644
2334
|
range.deleteContents();
|
|
1645
2335
|
return range;
|
|
1646
2336
|
}
|
|
1647
|
-
if (state.commentRange &&
|
|
2337
|
+
if (state.commentRange && editor.contains(state.commentRange.commonAncestorContainer)) {
|
|
1648
2338
|
const range = state.commentRange.cloneRange();
|
|
1649
2339
|
range.deleteContents();
|
|
1650
2340
|
return range;
|