promptslide 0.3.6 → 0.3.8
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/CHANGELOG.md +17 -0
- package/dist/index.d.ts +80 -2
- package/dist/index.js +700 -121
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/create.mjs +41 -5
- package/src/commands/publish.mjs +74 -6
- package/src/commands/pull.mjs +29 -0
- package/src/core/annotations/adapters/http.ts +43 -0
- package/src/core/annotations/annotation-form.tsx +82 -0
- package/src/core/annotations/annotation-overlay.tsx +198 -0
- package/src/core/annotations/annotation-panel.tsx +150 -0
- package/src/core/annotations/annotation-pin.tsx +42 -0
- package/src/core/annotations/api.ts +23 -0
- package/src/core/annotations/index.ts +5 -0
- package/src/core/annotations/selectors.ts +76 -0
- package/src/core/annotations/types.ts +47 -0
- package/src/core/annotations/use-annotations.ts +50 -0
- package/src/core/index.ts +4 -0
- package/src/core/slide-deck.tsx +168 -83
- package/src/vite/plugin.mjs +54 -0
package/src/commands/publish.mjs
CHANGED
|
@@ -2,10 +2,10 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"
|
|
|
2
2
|
import { dirname, join, basename, relative, extname } from "node:path"
|
|
3
3
|
import { fileURLToPath } from "node:url"
|
|
4
4
|
|
|
5
|
-
import { bold, green, cyan, red, dim } from "../utils/ansi.mjs"
|
|
5
|
+
import { bold, green, cyan, red, yellow, dim } from "../utils/ansi.mjs"
|
|
6
6
|
import { requireAuth } from "../utils/auth.mjs"
|
|
7
7
|
import { captureSlideAsDataUri, isPlaywrightAvailable, createCaptureSession } from "../utils/export.mjs"
|
|
8
|
-
import { publishToRegistry, registryItemExists, searchRegistry, updateLockfileItem, updateLockfilePublishConfig, readLockfile, writeLockfile, hashContent, detectPackageManager, requestUploadTokens, uploadBinaryToBlob, assetFileToSlug, detectAssetDepsInContent, readDeckMeta, updateDeckMeta, readItemMeta, updateItemMeta } from "../utils/registry.mjs"
|
|
8
|
+
import { publishToRegistry, registryItemExists, fetchRegistryItem, searchRegistry, updateLockfileItem, updateLockfilePublishConfig, readLockfile, writeLockfile, hashContent, detectPackageManager, requestUploadTokens, uploadBinaryToBlob, assetFileToSlug, detectAssetDepsInContent, readDeckMeta, updateDeckMeta, readItemMeta, updateItemMeta } from "../utils/registry.mjs"
|
|
9
9
|
import { prompt, confirm, select, closePrompts } from "../utils/prompts.mjs"
|
|
10
10
|
import { parseDeckConfig } from "../utils/deck-config.mjs"
|
|
11
11
|
|
|
@@ -15,12 +15,17 @@ const CLI_VERSION = JSON.parse(readFileSync(join(__dirname, "..", "..", "package
|
|
|
15
15
|
function readDeckSlug(cwd) {
|
|
16
16
|
const lock = readLockfile(cwd)
|
|
17
17
|
// Prefer stored deck slug — migrate old two-part format (e.g. "my-deck/name" → "my-deck")
|
|
18
|
-
|
|
18
|
+
// Sanitize to match validation rules (lowercase alphanumeric + hyphens only)
|
|
19
|
+
if (lock.deckSlug) return sanitizeSlug(lock.deckSlug.split("/")[0])
|
|
19
20
|
// Migrate legacy deckPrefix (old lockfiles stored prefix separately)
|
|
20
|
-
if (lock.deckPrefix) return lock.deckPrefix
|
|
21
|
+
if (lock.deckPrefix) return sanitizeSlug(lock.deckPrefix)
|
|
21
22
|
return ""
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
function sanitizeSlug(raw) {
|
|
26
|
+
return raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
|
|
27
|
+
}
|
|
28
|
+
|
|
24
29
|
function defaultDeckSlug(cwd) {
|
|
25
30
|
const dirName = basename(cwd)
|
|
26
31
|
return dirName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
|
|
@@ -91,6 +96,24 @@ function detectRegistryDeps(content) {
|
|
|
91
96
|
return deps
|
|
92
97
|
}
|
|
93
98
|
|
|
99
|
+
/** Detect sibling layout imports — both relative ("./foo") and absolute ("@/layouts/foo"). */
|
|
100
|
+
function detectLayoutSiblingDeps(content) {
|
|
101
|
+
const deps = []
|
|
102
|
+
// Relative imports: import ... from "./shared-footer"
|
|
103
|
+
const relativeRegex = /import\s+.*?\s+from\s+["']\.\/([^"']+)["']/g
|
|
104
|
+
for (const match of content.matchAll(relativeRegex)) {
|
|
105
|
+
const name = match[1].replace(/\.tsx?$/, "")
|
|
106
|
+
deps.push(name)
|
|
107
|
+
}
|
|
108
|
+
// Absolute imports: import ... from "@/layouts/shared-footer"
|
|
109
|
+
const absoluteRegex = /import\s+.*?\s+from\s+["']@\/layouts\/([^"']+)["']/g
|
|
110
|
+
for (const match of content.matchAll(absoluteRegex)) {
|
|
111
|
+
const name = match[1].replace(/\.tsx?$/, "")
|
|
112
|
+
if (!deps.includes(name)) deps.push(name)
|
|
113
|
+
}
|
|
114
|
+
return deps
|
|
115
|
+
}
|
|
116
|
+
|
|
94
117
|
/**
|
|
95
118
|
* Discover shared source files under src/ that aren't slides, layouts, or theme.
|
|
96
119
|
* These are files in directories like src/components/, src/lib/, src/hooks/, etc.
|
|
@@ -451,6 +474,27 @@ export async function publish(args) {
|
|
|
451
474
|
// Persist slug early so it's available even if publish fails partway
|
|
452
475
|
updateLockfilePublishConfig(cwd, { deckSlug })
|
|
453
476
|
|
|
477
|
+
// Warn if this deck already exists in the registry
|
|
478
|
+
try {
|
|
479
|
+
const existing = await fetchRegistryItem(deckSlug, auth)
|
|
480
|
+
if (existing) {
|
|
481
|
+
const existingTitle = existing.title || existing.name || deckSlug
|
|
482
|
+
const existingVer = existing.version ? ` ${dim(`(v${existing.version})`)}` : ""
|
|
483
|
+
console.log(` ${yellow("Warning:")} ${cyan(deckSlug)} already exists in the registry.`)
|
|
484
|
+
console.log(` ${dim("Existing deck:")} ${existingTitle}${existingVer}`)
|
|
485
|
+
console.log()
|
|
486
|
+
const shouldContinue = await confirm("Do you want to overwrite it?")
|
|
487
|
+
if (!shouldContinue) {
|
|
488
|
+
console.log(` ${dim("Publish cancelled.")}`)
|
|
489
|
+
closePrompts()
|
|
490
|
+
return
|
|
491
|
+
}
|
|
492
|
+
console.log()
|
|
493
|
+
}
|
|
494
|
+
} catch {
|
|
495
|
+
// Existence check failure is non-blocking (item doesn't exist or network error)
|
|
496
|
+
}
|
|
497
|
+
|
|
454
498
|
// Walk public/ to collect assets and build reference set
|
|
455
499
|
const publicDir = join(cwd, "public")
|
|
456
500
|
const publicAssets = []
|
|
@@ -687,8 +731,9 @@ export async function publish(args) {
|
|
|
687
731
|
|
|
688
732
|
const assetDeps = detectAssetDepsInContent(content, deckSlug, publicFileSet)
|
|
689
733
|
const npmDeps = detectNpmDeps(content)
|
|
734
|
+
const siblingDeps = detectLayoutSiblingDeps(content).map(d => `${deckSlug}/${d}`)
|
|
690
735
|
const regDeps = hasTheme ? [`${deckSlug}/theme`] : []
|
|
691
|
-
regDeps.push(...assetDeps)
|
|
736
|
+
regDeps.push(...siblingDeps, ...assetDeps)
|
|
692
737
|
|
|
693
738
|
try {
|
|
694
739
|
const result = await publishToRegistry({
|
|
@@ -772,9 +817,11 @@ export async function publish(args) {
|
|
|
772
817
|
|
|
773
818
|
// ── Phase 5: Deck manifest (includes shared source modules) ──
|
|
774
819
|
itemIndex++
|
|
820
|
+
const themeSlugs = hasTheme ? [`${deckSlug}/theme`] : []
|
|
821
|
+
const layoutSlugs = layoutEntries.map(f => `${deckSlug}/${f.replace(/\.tsx?$/, "")}`)
|
|
775
822
|
const slideSlugs = slideEntries.map(f => `${deckSlug}/${f.replace(/\.tsx?$/, "")}`)
|
|
776
823
|
const assetSlugs = publicAssets.map(a => assetFileToSlug(deckSlug, a.relativePath))
|
|
777
|
-
const allDeckDeps = [...slideSlugs, ...assetSlugs]
|
|
824
|
+
const allDeckDeps = [...themeSlugs, ...layoutSlugs, ...slideSlugs, ...assetSlugs]
|
|
778
825
|
|
|
779
826
|
// Bundle shared source files with the deck item so preview/install can resolve @/ imports
|
|
780
827
|
const sharedFiles = sharedSources.map(s => ({
|
|
@@ -883,6 +930,27 @@ export async function publish(args) {
|
|
|
883
930
|
console.log(` Slug: ${cyan(slug)}`)
|
|
884
931
|
console.log()
|
|
885
932
|
|
|
933
|
+
// Warn if this item already exists in the registry
|
|
934
|
+
try {
|
|
935
|
+
const existing = await fetchRegistryItem(slug, auth)
|
|
936
|
+
if (existing) {
|
|
937
|
+
const existingTitle = existing.title || existing.name || slug
|
|
938
|
+
const existingVer = existing.version ? ` ${dim(`(v${existing.version})`)}` : ""
|
|
939
|
+
console.log(` ${yellow("Warning:")} ${cyan(slug)} already exists in the registry.`)
|
|
940
|
+
console.log(` ${dim("Existing item:")} ${existingTitle}${existingVer}`)
|
|
941
|
+
console.log()
|
|
942
|
+
const shouldContinue = await confirm("Do you want to overwrite it?")
|
|
943
|
+
if (!shouldContinue) {
|
|
944
|
+
console.log(` ${dim("Publish cancelled.")}`)
|
|
945
|
+
closePrompts()
|
|
946
|
+
return
|
|
947
|
+
}
|
|
948
|
+
console.log()
|
|
949
|
+
}
|
|
950
|
+
} catch {
|
|
951
|
+
// Existence check failure is non-blocking (item doesn't exist or network error)
|
|
952
|
+
}
|
|
953
|
+
|
|
886
954
|
// Warn if same base slug exists under a different prefix
|
|
887
955
|
try {
|
|
888
956
|
const { items: results } = await searchRegistry({ search: baseSlug, type }, auth)
|
package/src/commands/pull.mjs
CHANGED
|
@@ -216,6 +216,35 @@ export async function pull(args) {
|
|
|
216
216
|
}
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
// Fetch annotations from registry
|
|
220
|
+
if (deckItem.id) {
|
|
221
|
+
try {
|
|
222
|
+
const annotationsRes = await fetch(`${auth.registry}/api/items/${deckItem.id}/annotations`, {
|
|
223
|
+
headers: { Authorization: `Bearer ${auth.token}`, ...(auth.organizationId ? { "X-Organization-Id": auth.organizationId } : {}) }
|
|
224
|
+
})
|
|
225
|
+
if (annotationsRes.ok) {
|
|
226
|
+
const data = await annotationsRes.json()
|
|
227
|
+
const annotations = data.annotations ?? []
|
|
228
|
+
if (annotations.length > 0) {
|
|
229
|
+
const annotationsFile = { version: 1, annotations: annotations.map(a => ({
|
|
230
|
+
id: a.id,
|
|
231
|
+
slideIndex: a.slideIndex,
|
|
232
|
+
slideTitle: a.slideTitle,
|
|
233
|
+
target: a.target,
|
|
234
|
+
body: a.body,
|
|
235
|
+
createdAt: a.createdAt,
|
|
236
|
+
status: a.status,
|
|
237
|
+
...(a.resolution ? { resolution: a.resolution } : {})
|
|
238
|
+
})) }
|
|
239
|
+
writeFileSync(join(cwd, "annotations.json"), JSON.stringify(annotationsFile, null, 2) + "\n", "utf-8")
|
|
240
|
+
console.log(` ${green("+")} ${cyan("annotations.json")} ${dim(`(${annotations.length} annotation${annotations.length === 1 ? "" : 's'})`)}`)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
} catch {
|
|
244
|
+
// Annotations are non-critical; don't fail the pull
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
219
248
|
// Summary
|
|
220
249
|
console.log()
|
|
221
250
|
if (written.length === 0) {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Annotation, AnnotationsFile, AnnotationStorageAdapter } from "../types"
|
|
2
|
+
|
|
3
|
+
const ENDPOINT = "/__promptslide_annotations"
|
|
4
|
+
|
|
5
|
+
async function loadAll(): Promise<Annotation[]> {
|
|
6
|
+
try {
|
|
7
|
+
const res = await fetch(ENDPOINT)
|
|
8
|
+
if (!res.ok) return []
|
|
9
|
+
const data: AnnotationsFile = await res.json()
|
|
10
|
+
return data.annotations || []
|
|
11
|
+
} catch {
|
|
12
|
+
return []
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function saveAll(annotations: Annotation[]): Promise<void> {
|
|
17
|
+
const data: AnnotationsFile = { version: 1, annotations }
|
|
18
|
+
await fetch(ENDPOINT, {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: { "Content-Type": "application/json" },
|
|
21
|
+
body: JSON.stringify(data)
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Storage adapter for local Vite dev server (reads/writes annotations.json via middleware) */
|
|
26
|
+
export function createHttpAdapter(): AnnotationStorageAdapter {
|
|
27
|
+
let cache: Annotation[] = []
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
async load() {
|
|
31
|
+
cache = await loadAll()
|
|
32
|
+
return cache
|
|
33
|
+
},
|
|
34
|
+
async add(annotation) {
|
|
35
|
+
cache = [...cache, annotation]
|
|
36
|
+
await saveAll(cache)
|
|
37
|
+
},
|
|
38
|
+
async remove(id) {
|
|
39
|
+
cache = cache.filter(a => a.id !== id)
|
|
40
|
+
await saveAll(cache)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react"
|
|
2
|
+
import { ArrowUp, X } from "lucide-react"
|
|
3
|
+
|
|
4
|
+
interface AnnotationFormProps {
|
|
5
|
+
xPercent: number
|
|
6
|
+
yPercent: number
|
|
7
|
+
onSubmit: (text: string) => void
|
|
8
|
+
onCancel: () => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function AnnotationForm({ xPercent, yPercent, onSubmit, onCancel }: AnnotationFormProps) {
|
|
12
|
+
const [text, setText] = useState("")
|
|
13
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
textareaRef.current?.focus()
|
|
17
|
+
}, [])
|
|
18
|
+
|
|
19
|
+
const handleSubmit = () => {
|
|
20
|
+
const trimmed = text.trim()
|
|
21
|
+
if (!trimmed) return
|
|
22
|
+
onSubmit(trimmed)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
26
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
27
|
+
e.preventDefault()
|
|
28
|
+
handleSubmit()
|
|
29
|
+
}
|
|
30
|
+
if (e.key === "Escape") {
|
|
31
|
+
onCancel()
|
|
32
|
+
}
|
|
33
|
+
e.stopPropagation()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Position the form, flipping if near edges
|
|
37
|
+
const left = xPercent > 70 ? undefined : `${xPercent}%`
|
|
38
|
+
const right = xPercent > 70 ? `${100 - xPercent}%` : undefined
|
|
39
|
+
const top = yPercent > 70 ? undefined : `${yPercent}%`
|
|
40
|
+
const bottom = yPercent > 70 ? `${100 - yPercent}%` : undefined
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
role="dialog"
|
|
45
|
+
className="absolute z-40 w-72 overflow-hidden rounded-xl border border-white/[0.08] bg-neutral-900/95 shadow-2xl backdrop-blur-2xl"
|
|
46
|
+
style={{ left, right, top, bottom }}
|
|
47
|
+
onClick={e => e.stopPropagation()}
|
|
48
|
+
onKeyDown={e => e.stopPropagation()}
|
|
49
|
+
>
|
|
50
|
+
<div className="flex items-center justify-between border-b border-white/[0.06] px-3.5 py-2.5">
|
|
51
|
+
<span className="text-xs font-medium tracking-wide text-neutral-400">Add annotation</span>
|
|
52
|
+
<button
|
|
53
|
+
onClick={onCancel}
|
|
54
|
+
className="rounded-lg p-1 text-neutral-500 transition-colors hover:bg-white/[0.06] hover:text-neutral-300"
|
|
55
|
+
>
|
|
56
|
+
<X className="h-3.5 w-3.5" />
|
|
57
|
+
</button>
|
|
58
|
+
</div>
|
|
59
|
+
<div className="p-3">
|
|
60
|
+
<textarea
|
|
61
|
+
ref={textareaRef}
|
|
62
|
+
value={text}
|
|
63
|
+
onChange={e => setText(e.target.value)}
|
|
64
|
+
onKeyDown={handleKeyDown}
|
|
65
|
+
placeholder="Describe the change you want..."
|
|
66
|
+
className="w-full resize-none rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-[#FF6B35]/50 focus:bg-white/[0.06]"
|
|
67
|
+
rows={3}
|
|
68
|
+
/>
|
|
69
|
+
<div className="mt-2.5 flex items-center justify-between">
|
|
70
|
+
<span className="text-[11px] text-neutral-600">Enter to send</span>
|
|
71
|
+
<button
|
|
72
|
+
onClick={handleSubmit}
|
|
73
|
+
disabled={!text.trim()}
|
|
74
|
+
className="flex h-7 w-7 items-center justify-center rounded-lg bg-[#FF6B35] text-white shadow-lg shadow-[#FF6B35]/20 transition-all hover:bg-[#FF7A4A] hover:shadow-[#FF6B35]/30 disabled:opacity-30 disabled:shadow-none disabled:hover:bg-[#FF6B35]"
|
|
75
|
+
>
|
|
76
|
+
<ArrowUp className="h-3.5 w-3.5" strokeWidth={2.5} />
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react"
|
|
2
|
+
import type { SlideConfig } from "../types"
|
|
3
|
+
import { AnnotationForm } from "./annotation-form"
|
|
4
|
+
import { AnnotationPin } from "./annotation-pin"
|
|
5
|
+
import { buildElementTarget, resolveTarget } from "./selectors"
|
|
6
|
+
import type { Annotation, AnnotationTarget } from "./types"
|
|
7
|
+
|
|
8
|
+
interface AnnotationOverlayProps {
|
|
9
|
+
slides: SlideConfig[]
|
|
10
|
+
currentSlide: number
|
|
11
|
+
/** Ref to the slide container element (the div wrapping SlideRenderer) */
|
|
12
|
+
slideContainerRef: React.RefObject<HTMLDivElement | null>
|
|
13
|
+
selectedId: string | null
|
|
14
|
+
onSelectId: (id: string | null) => void
|
|
15
|
+
onShowPanel: () => void
|
|
16
|
+
slideAnnotations: Annotation[]
|
|
17
|
+
addAnnotation: (slideIndex: number, slideTitle: string, target: AnnotationTarget, body: string) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PendingAnnotation {
|
|
21
|
+
target: AnnotationTarget
|
|
22
|
+
xPercent: number
|
|
23
|
+
yPercent: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function AnnotationOverlay({ slides, currentSlide, slideContainerRef, selectedId, onSelectId, onShowPanel, slideAnnotations, addAnnotation }: AnnotationOverlayProps) {
|
|
27
|
+
const [pending, setPending] = useState<PendingAnnotation | null>(null)
|
|
28
|
+
const [hoveredElement, setHoveredElement] = useState<DOMRect | null>(null)
|
|
29
|
+
const overlayRef = useRef<HTMLDivElement>(null)
|
|
30
|
+
|
|
31
|
+
const slideTitle = slides[currentSlide]?.title || `Slide ${currentSlide + 1}`
|
|
32
|
+
|
|
33
|
+
// Resolve annotation positions — use the element if found, otherwise fallback coordinates
|
|
34
|
+
const resolvedAnnotations = slideAnnotations.map((a, i) => {
|
|
35
|
+
const container = slideContainerRef.current
|
|
36
|
+
if (!container) {
|
|
37
|
+
return { annotation: a, xPercent: a.target.position.xPercent, yPercent: a.target.position.yPercent, number: i + 1 }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const { element } = resolveTarget(a.target, container)
|
|
41
|
+
if (element) {
|
|
42
|
+
const rect = element.getBoundingClientRect()
|
|
43
|
+
const containerRect = container.getBoundingClientRect()
|
|
44
|
+
const xPercent = ((rect.left + rect.width / 2 - containerRect.left) / containerRect.width) * 100
|
|
45
|
+
const yPercent = ((rect.top + rect.height / 2 - containerRect.top) / containerRect.height) * 100
|
|
46
|
+
return { annotation: a, xPercent, yPercent, number: i + 1 }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { annotation: a, xPercent: a.target.position.xPercent, yPercent: a.target.position.yPercent, number: i + 1 }
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const handleOverlayClick = useCallback(
|
|
53
|
+
(e: React.MouseEvent) => {
|
|
54
|
+
const container = slideContainerRef.current
|
|
55
|
+
if (!container) return
|
|
56
|
+
|
|
57
|
+
// Get the element under the click by temporarily hiding the overlay
|
|
58
|
+
const overlay = overlayRef.current
|
|
59
|
+
if (overlay) overlay.style.pointerEvents = "none"
|
|
60
|
+
const elementUnder = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null
|
|
61
|
+
if (overlay) overlay.style.pointerEvents = ""
|
|
62
|
+
|
|
63
|
+
if (!elementUnder || !container.contains(elementUnder)) {
|
|
64
|
+
setPending(null)
|
|
65
|
+
onSelectId(null)
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Pick the most specific meaningful element (skip tiny text nodes, pick their parent)
|
|
70
|
+
let target = elementUnder
|
|
71
|
+
if (target.tagName === "SPAN" && target.parentElement && container.contains(target.parentElement)) {
|
|
72
|
+
// For inline spans, prefer their parent for a more meaningful target
|
|
73
|
+
const parent = target.parentElement
|
|
74
|
+
if (parent.tagName !== "DIV" || parent.children.length <= 3) {
|
|
75
|
+
target = parent
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const annotationTarget = buildElementTarget(target, container)
|
|
80
|
+
const containerRect = container.getBoundingClientRect()
|
|
81
|
+
const xPercent = ((e.clientX - containerRect.left) / containerRect.width) * 100
|
|
82
|
+
const yPercent = ((e.clientY - containerRect.top) / containerRect.height) * 100
|
|
83
|
+
|
|
84
|
+
setPending({ target: annotationTarget, xPercent, yPercent })
|
|
85
|
+
onSelectId(null)
|
|
86
|
+
},
|
|
87
|
+
[slideContainerRef, onSelectId]
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const handleSubmit = useCallback(
|
|
91
|
+
(text: string) => {
|
|
92
|
+
if (!pending) return
|
|
93
|
+
addAnnotation(currentSlide, slideTitle, pending.target, text)
|
|
94
|
+
setPending(null)
|
|
95
|
+
onShowPanel()
|
|
96
|
+
},
|
|
97
|
+
[pending, currentSlide, slideTitle, addAnnotation, onShowPanel]
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
// Track hover for element highlighting
|
|
101
|
+
const handleMouseMove = useCallback(
|
|
102
|
+
(e: React.MouseEvent) => {
|
|
103
|
+
if (pending) {
|
|
104
|
+
setHoveredElement(null)
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const container = slideContainerRef.current
|
|
109
|
+
if (!container) return
|
|
110
|
+
|
|
111
|
+
const overlay = overlayRef.current
|
|
112
|
+
if (overlay) overlay.style.pointerEvents = "none"
|
|
113
|
+
const elementUnder = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null
|
|
114
|
+
if (overlay) overlay.style.pointerEvents = ""
|
|
115
|
+
|
|
116
|
+
if (elementUnder && container.contains(elementUnder) && elementUnder !== container) {
|
|
117
|
+
const containerRect = container.getBoundingClientRect()
|
|
118
|
+
const elRect = elementUnder.getBoundingClientRect()
|
|
119
|
+
setHoveredElement(
|
|
120
|
+
new DOMRect(
|
|
121
|
+
elRect.left - containerRect.left,
|
|
122
|
+
elRect.top - containerRect.top,
|
|
123
|
+
elRect.width,
|
|
124
|
+
elRect.height
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
} else {
|
|
128
|
+
setHoveredElement(null)
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
[slideContainerRef, pending]
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
// Clear pending when slide changes
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
setPending(null)
|
|
137
|
+
onSelectId(null)
|
|
138
|
+
setHoveredElement(null)
|
|
139
|
+
}, [currentSlide, onSelectId])
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<>
|
|
143
|
+
{/* Hover highlight */}
|
|
144
|
+
{hoveredElement && (
|
|
145
|
+
<div
|
|
146
|
+
className="pointer-events-none absolute z-20 rounded-lg border-2 border-dashed border-[#FF6B35]/50 bg-[#FF6B35]/5"
|
|
147
|
+
style={{
|
|
148
|
+
left: hoveredElement.x,
|
|
149
|
+
top: hoveredElement.y,
|
|
150
|
+
width: hoveredElement.width,
|
|
151
|
+
height: hoveredElement.height
|
|
152
|
+
}}
|
|
153
|
+
/>
|
|
154
|
+
)}
|
|
155
|
+
|
|
156
|
+
{/* Click capture overlay */}
|
|
157
|
+
<div
|
|
158
|
+
ref={overlayRef}
|
|
159
|
+
role="button"
|
|
160
|
+
tabIndex={0}
|
|
161
|
+
className="absolute inset-0 z-20"
|
|
162
|
+
style={{ cursor: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none'%3E%3Ccircle cx='12' cy='12' r='8' stroke='%23FF6B35' stroke-width='2' opacity='0.8'/%3E%3Ccircle cx='12' cy='12' r='2' fill='%23FF6B35'/%3E%3C/svg%3E") 12 12, crosshair` }}
|
|
163
|
+
onClick={handleOverlayClick}
|
|
164
|
+
onKeyDown={e => { if (e.key === "Escape") { setPending(null); onSelectId(null) } }}
|
|
165
|
+
onMouseMove={handleMouseMove}
|
|
166
|
+
onMouseLeave={() => setHoveredElement(null)}
|
|
167
|
+
/>
|
|
168
|
+
|
|
169
|
+
{/* Annotation pins */}
|
|
170
|
+
{resolvedAnnotations.map(({ annotation, xPercent, yPercent, number }) => (
|
|
171
|
+
<AnnotationPin
|
|
172
|
+
key={annotation.id}
|
|
173
|
+
number={number}
|
|
174
|
+
status={annotation.status}
|
|
175
|
+
xPercent={xPercent}
|
|
176
|
+
yPercent={yPercent}
|
|
177
|
+
isSelected={annotation.id === selectedId}
|
|
178
|
+
onClick={() => {
|
|
179
|
+
onSelectId(annotation.id === selectedId ? null : annotation.id)
|
|
180
|
+
onShowPanel()
|
|
181
|
+
setPending(null)
|
|
182
|
+
}}
|
|
183
|
+
/>
|
|
184
|
+
))}
|
|
185
|
+
|
|
186
|
+
{/* New annotation form */}
|
|
187
|
+
{pending && (
|
|
188
|
+
<AnnotationForm
|
|
189
|
+
xPercent={pending.xPercent}
|
|
190
|
+
yPercent={pending.yPercent}
|
|
191
|
+
onSubmit={handleSubmit}
|
|
192
|
+
onCancel={() => setPending(null)}
|
|
193
|
+
/>
|
|
194
|
+
)}
|
|
195
|
+
|
|
196
|
+
</>
|
|
197
|
+
)
|
|
198
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { MessageCircle, Trash2, X } from "lucide-react"
|
|
2
|
+
import type { Annotation } from "./types"
|
|
3
|
+
|
|
4
|
+
interface AnnotationPanelProps {
|
|
5
|
+
annotations: Annotation[]
|
|
6
|
+
selectedId: string | null
|
|
7
|
+
onSelect: (id: string) => void
|
|
8
|
+
onDelete: (id: string) => void
|
|
9
|
+
onClose: () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function AnnotationPanel({ annotations, selectedId, onSelect, onDelete, onClose }: AnnotationPanelProps) {
|
|
13
|
+
const open = annotations.filter(a => a.status === "open")
|
|
14
|
+
const resolved = annotations.filter(a => a.status === "resolved")
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex h-full w-80 flex-shrink-0 flex-col border-l border-white/[0.06] bg-neutral-950/95 backdrop-blur-2xl">
|
|
18
|
+
<div className="flex items-center justify-between px-4 py-3.5">
|
|
19
|
+
<div className="flex items-center gap-2.5">
|
|
20
|
+
<div className="flex h-6 w-6 items-center justify-center rounded-lg bg-[#FF6B35]/15">
|
|
21
|
+
<MessageCircle className="h-3.5 w-3.5 text-[#FF6B35]" />
|
|
22
|
+
</div>
|
|
23
|
+
<span className="text-sm font-medium text-white">Annotations</span>
|
|
24
|
+
{open.length > 0 && (
|
|
25
|
+
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-[#FF6B35]/15 px-1.5 text-[11px] font-semibold text-[#FF6B35]">
|
|
26
|
+
{open.length}
|
|
27
|
+
</span>
|
|
28
|
+
)}
|
|
29
|
+
</div>
|
|
30
|
+
<button
|
|
31
|
+
onClick={onClose}
|
|
32
|
+
className="rounded-lg p-1.5 text-neutral-500 transition-colors hover:bg-white/[0.06] hover:text-neutral-300"
|
|
33
|
+
>
|
|
34
|
+
<X className="h-4 w-4" />
|
|
35
|
+
</button>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div className="h-px bg-gradient-to-r from-transparent via-white/[0.06] to-transparent" />
|
|
39
|
+
|
|
40
|
+
<div className="flex-1 overflow-y-auto p-2">
|
|
41
|
+
{annotations.length === 0 && (
|
|
42
|
+
<div className="flex flex-col items-center gap-2 px-4 py-8 text-center">
|
|
43
|
+
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/[0.04]">
|
|
44
|
+
<MessageCircle className="h-5 w-5 text-neutral-600" />
|
|
45
|
+
</div>
|
|
46
|
+
<p className="text-sm text-neutral-500">Click on slide elements to add annotations</p>
|
|
47
|
+
</div>
|
|
48
|
+
)}
|
|
49
|
+
|
|
50
|
+
{open.length > 0 && (
|
|
51
|
+
<div>
|
|
52
|
+
<div className="mb-1.5 px-2 pt-1 text-[11px] font-medium tracking-wider text-neutral-500 uppercase">
|
|
53
|
+
Open
|
|
54
|
+
</div>
|
|
55
|
+
{open.map((a, i) => (
|
|
56
|
+
<AnnotationItem
|
|
57
|
+
key={a.id}
|
|
58
|
+
annotation={a}
|
|
59
|
+
number={i + 1}
|
|
60
|
+
isSelected={a.id === selectedId}
|
|
61
|
+
onSelect={() => onSelect(a.id)}
|
|
62
|
+
onDelete={() => onDelete(a.id)}
|
|
63
|
+
/>
|
|
64
|
+
))}
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
|
|
68
|
+
{resolved.length > 0 && (
|
|
69
|
+
<div className={open.length > 0 ? "mt-3" : ""}>
|
|
70
|
+
<div className="mb-1.5 px-2 pt-1 text-[11px] font-medium tracking-wider text-neutral-500 uppercase">
|
|
71
|
+
Resolved
|
|
72
|
+
</div>
|
|
73
|
+
{resolved.map((a, i) => (
|
|
74
|
+
<AnnotationItem
|
|
75
|
+
key={a.id}
|
|
76
|
+
annotation={a}
|
|
77
|
+
number={open.length + i + 1}
|
|
78
|
+
isSelected={a.id === selectedId}
|
|
79
|
+
onSelect={() => onSelect(a.id)}
|
|
80
|
+
onDelete={() => onDelete(a.id)}
|
|
81
|
+
/>
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function AnnotationItem({
|
|
91
|
+
annotation,
|
|
92
|
+
number,
|
|
93
|
+
isSelected,
|
|
94
|
+
onSelect,
|
|
95
|
+
onDelete
|
|
96
|
+
}: {
|
|
97
|
+
annotation: Annotation
|
|
98
|
+
number: number
|
|
99
|
+
isSelected: boolean
|
|
100
|
+
onSelect: () => void
|
|
101
|
+
onDelete: () => void
|
|
102
|
+
}) {
|
|
103
|
+
return (
|
|
104
|
+
<div
|
|
105
|
+
role="button"
|
|
106
|
+
tabIndex={0}
|
|
107
|
+
onClick={onSelect}
|
|
108
|
+
onKeyDown={e => { if (e.key === "Enter" || e.key === " ") onSelect() }}
|
|
109
|
+
className={`group relative mb-0.5 cursor-pointer rounded-xl p-2.5 transition-all duration-150 ${
|
|
110
|
+
isSelected
|
|
111
|
+
? "bg-[#FF6B35]/10 ring-1 ring-[#FF6B35]/20"
|
|
112
|
+
: "hover:bg-white/[0.04]"
|
|
113
|
+
}`}
|
|
114
|
+
>
|
|
115
|
+
<button
|
|
116
|
+
onClick={e => {
|
|
117
|
+
e.stopPropagation()
|
|
118
|
+
onDelete()
|
|
119
|
+
}}
|
|
120
|
+
className="absolute top-2 right-2 rounded-lg p-1 text-neutral-600 opacity-0 transition-all hover:bg-white/[0.08] hover:text-neutral-300 group-hover:opacity-100"
|
|
121
|
+
>
|
|
122
|
+
<Trash2 className="h-3 w-3" />
|
|
123
|
+
</button>
|
|
124
|
+
<div className="flex items-start gap-2.5">
|
|
125
|
+
<div
|
|
126
|
+
className={`mt-0.5 flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full text-[11px] font-semibold ${
|
|
127
|
+
annotation.status === "open"
|
|
128
|
+
? "bg-[#FF6B35] text-white"
|
|
129
|
+
: "bg-neutral-700 text-neutral-400"
|
|
130
|
+
}`}
|
|
131
|
+
>
|
|
132
|
+
{number}
|
|
133
|
+
</div>
|
|
134
|
+
<div className="min-w-0 pr-4">
|
|
135
|
+
<p className="text-[13px] leading-relaxed text-neutral-200">{annotation.body}</p>
|
|
136
|
+
{annotation.target.contentNearPin && (
|
|
137
|
+
<p className="mt-1 truncate text-[11px] text-neutral-600">
|
|
138
|
+
{annotation.target.contentNearPin}
|
|
139
|
+
</p>
|
|
140
|
+
)}
|
|
141
|
+
{annotation.resolution && (
|
|
142
|
+
<p className="mt-1.5 text-[11px] text-emerald-400/80 italic">
|
|
143
|
+
{annotation.resolution}
|
|
144
|
+
</p>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
interface AnnotationPinProps {
|
|
2
|
+
number: number
|
|
3
|
+
status: "open" | "resolved"
|
|
4
|
+
xPercent: number
|
|
5
|
+
yPercent: number
|
|
6
|
+
isSelected: boolean
|
|
7
|
+
onClick: () => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function AnnotationPin({ number, status, xPercent, yPercent, isSelected, onClick }: AnnotationPinProps) {
|
|
11
|
+
const isResolved = status === "resolved"
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<button
|
|
15
|
+
onClick={e => {
|
|
16
|
+
e.stopPropagation()
|
|
17
|
+
onClick()
|
|
18
|
+
}}
|
|
19
|
+
className="absolute z-30 -translate-x-1/2 -translate-y-1/2 transition-all duration-200 hover:scale-110"
|
|
20
|
+
style={{ left: `${xPercent}%`, top: `${yPercent}%` }}
|
|
21
|
+
title={`Annotation #${number}`}
|
|
22
|
+
>
|
|
23
|
+
{/* Glow ring */}
|
|
24
|
+
{isSelected && !isResolved && (
|
|
25
|
+
<div className="absolute inset-[-4px] animate-pulse rounded-full bg-[#FF6B35]/25 blur-sm" />
|
|
26
|
+
)}
|
|
27
|
+
<div
|
|
28
|
+
className={`relative flex h-7 w-7 items-center justify-center rounded-full text-[11px] font-semibold shadow-lg backdrop-blur-sm transition-all duration-200 ${
|
|
29
|
+
isSelected
|
|
30
|
+
? isResolved
|
|
31
|
+
? "bg-neutral-500/90 text-white ring-2 ring-neutral-400/50"
|
|
32
|
+
: "bg-[#FF6B35] text-white ring-2 ring-[#FF6B35]/40 ring-offset-1 ring-offset-black/50"
|
|
33
|
+
: isResolved
|
|
34
|
+
? "bg-neutral-700/80 text-neutral-400"
|
|
35
|
+
: "bg-[#FF6B35]/90 text-white hover:bg-[#FF6B35]"
|
|
36
|
+
}`}
|
|
37
|
+
>
|
|
38
|
+
{number}
|
|
39
|
+
</div>
|
|
40
|
+
</button>
|
|
41
|
+
)
|
|
42
|
+
}
|