@joewinke/jatui 0.2.7 → 0.2.9
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/CLAUDE.md +1 -1
- package/dist/components/AnnotationLayer.svelte +6 -0
- package/dist/components/AnnotationLayer.svelte.d.ts +3 -0
- package/dist/components/AnnotationPanel.svelte +17 -0
- package/dist/components/AnnotationPanel.svelte.d.ts +4 -0
- package/dist/components/ContextMenu.svelte +53 -4
- package/dist/components/ContextMenu.svelte.d.ts +2 -0
- package/dist/components/ContextMenuTrigger.svelte +85 -0
- package/dist/components/ContextMenuTrigger.svelte.d.ts +41 -0
- package/dist/components/ImageLightbox.svelte +9 -2
- package/dist/components/ImageLightbox.svelte.d.ts +4 -0
- package/dist/components/MarkupOverlay.svelte +23 -3
- package/dist/components/MarkupOverlay.svelte.d.ts +3 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -108,7 +108,7 @@ Then import:
|
|
|
108
108
|
| `MarkupOverlay` | Read-only canvas layer that renders saved `MarkupShape[]` over an image | `shapes: MarkupShape[]`, `imageUrl` |
|
|
109
109
|
| `ImageLightbox` | Inline image viewer with fullscreen modal, zoom controls, optional markup + annotation overlays | `imageUrl`, `filename?`, `markupShapes?`, `annotations?`, `activeAnnotationId?`, `annotationMode?`, `showResolved?` (forwarded to the internal `AnnotationLayer`; default false), `onAnnotationClick?`, `onImageClick?`, `onClose?` |
|
|
110
110
|
| `AnnotationLayer` | Absolute-positioned pin overlay — renders threaded annotation pins at `x_percent`/`y_percent` coordinates | `annotations: Annotation[]`, `activeAnnotationId?`, `annotationMode?`, `showResolved?`, `onAnnotationClick?`, `onImageClick?` |
|
|
111
|
-
| `AnnotationPanel` | Sidebar thread list for annotations — reply, resolve, edit, delete. Uses callback props for async ops. | `annotations`, `profiles`, `activeAnnotationId?`, `currentUserId`, `callbacks: AnnotationCallbacks`, `readOnly?`, `title?` (panel heading, default "Annotations"; pass "Comments" to match host vocabulary), `emptyText?` (empty-state line, default "No annotations yet"), `onAnnotationSelect?`, `onAnnotationsChanged?` |
|
|
111
|
+
| `AnnotationPanel` | Sidebar thread list for annotations — reply, resolve, edit, delete. Uses callback props for async ops. | `annotations`, `profiles`, `activeAnnotationId?`, `currentUserId`, `callbacks: AnnotationCallbacks`, `readOnly?`, `title?` (panel heading, default "Annotations"; pass "Comments" to match host vocabulary), `emptyText?` (empty-state line, default "No annotations yet"), `badges?` (per-annotation chip label by id, e.g. "Markup"), `onAnnotationHover?` (fires root id on hover / null on leave), `onAnnotationSelect?`, `onAnnotationsChanged?` |
|
|
112
112
|
|
|
113
113
|
**Types & utilities:**
|
|
114
114
|
```typescript
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
showResolved?: boolean
|
|
9
9
|
onAnnotationClick?: (annotation: Annotation) => void
|
|
10
10
|
onImageClick?: (x: number, y: number) => void
|
|
11
|
+
/** Fires with a pin's id on hover, null on leave — lets the host light up
|
|
12
|
+
* whatever that annotation links to (e.g. its markup). */
|
|
13
|
+
onAnnotationHover?: (id: string | null) => void
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
let {
|
|
@@ -17,6 +20,7 @@
|
|
|
17
20
|
showResolved = false,
|
|
18
21
|
onAnnotationClick,
|
|
19
22
|
onImageClick,
|
|
23
|
+
onAnnotationHover,
|
|
20
24
|
}: Props = $props()
|
|
21
25
|
|
|
22
26
|
// Group into threads (only show root pins)
|
|
@@ -60,6 +64,8 @@
|
|
|
60
64
|
<button
|
|
61
65
|
class="relative group flex flex-col items-center"
|
|
62
66
|
onclick={(e) => { e.stopPropagation(); onAnnotationClick?.(thread.root) }}
|
|
67
|
+
onmouseenter={() => onAnnotationHover?.(thread.root.id)}
|
|
68
|
+
onmouseleave={() => onAnnotationHover?.(null)}
|
|
63
69
|
title={thread.root.content.slice(0, 80)}
|
|
64
70
|
>
|
|
65
71
|
<!-- Pin icon -->
|
|
@@ -6,6 +6,9 @@ interface Props {
|
|
|
6
6
|
showResolved?: boolean;
|
|
7
7
|
onAnnotationClick?: (annotation: Annotation) => void;
|
|
8
8
|
onImageClick?: (x: number, y: number) => void;
|
|
9
|
+
/** Fires with a pin's id on hover, null on leave — lets the host light up
|
|
10
|
+
* whatever that annotation links to (e.g. its markup). */
|
|
11
|
+
onAnnotationHover?: (id: string | null) => void;
|
|
9
12
|
}
|
|
10
13
|
declare const AnnotationLayer: import("svelte").Component<Props, {}, "">;
|
|
11
14
|
type AnnotationLayer = ReturnType<typeof AnnotationLayer>;
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
title?: string
|
|
14
14
|
/** Empty-state line. Defaults to "No annotations yet". */
|
|
15
15
|
emptyText?: string
|
|
16
|
+
/** Optional per-annotation chip label, keyed by annotation id (e.g. "Markup"). */
|
|
17
|
+
badges?: Record<string, string>
|
|
18
|
+
/** Fires with a thread's root id on hover, null on leave. */
|
|
19
|
+
onAnnotationHover?: (id: string | null) => void
|
|
16
20
|
onAnnotationSelect?: (annotation: Annotation | null) => void
|
|
17
21
|
onAnnotationsChanged?: () => void
|
|
18
22
|
}
|
|
@@ -26,6 +30,8 @@
|
|
|
26
30
|
readOnly = false,
|
|
27
31
|
title = "Annotations",
|
|
28
32
|
emptyText = "No annotations yet",
|
|
33
|
+
badges,
|
|
34
|
+
onAnnotationHover,
|
|
29
35
|
onAnnotationSelect,
|
|
30
36
|
onAnnotationsChanged,
|
|
31
37
|
}: Props = $props()
|
|
@@ -171,8 +177,11 @@
|
|
|
171
177
|
{:else}
|
|
172
178
|
{#each threads as thread (thread.root.id)}
|
|
173
179
|
{@const isActive = activeAnnotationId === thread.root.id}
|
|
180
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
174
181
|
<div
|
|
175
182
|
class="border-b border-base-200 transition-colors {isActive ? 'bg-primary/5' : 'hover:bg-base-200/50'}"
|
|
183
|
+
onmouseenter={() => onAnnotationHover?.(thread.root.id)}
|
|
184
|
+
onmouseleave={() => onAnnotationHover?.(null)}
|
|
176
185
|
>
|
|
177
186
|
<!-- Root annotation -->
|
|
178
187
|
<button
|
|
@@ -188,6 +197,14 @@
|
|
|
188
197
|
<div class="flex items-center gap-1.5">
|
|
189
198
|
<span class="text-xs font-medium text-base-content">{getAuthorName(thread.root.user_id)}</span>
|
|
190
199
|
<span class="text-[10px] text-base-content/40">{formatTime(thread.root.created_at)}</span>
|
|
200
|
+
{#if badges?.[thread.root.id]}
|
|
201
|
+
<span class="badge badge-xs badge-outline gap-0.5">
|
|
202
|
+
<svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
203
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
|
204
|
+
</svg>
|
|
205
|
+
{badges[thread.root.id]}
|
|
206
|
+
</span>
|
|
207
|
+
{/if}
|
|
191
208
|
{#if thread.root.resolved}
|
|
192
209
|
<span class="badge badge-xs badge-success gap-0.5">
|
|
193
210
|
<svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
@@ -11,6 +11,10 @@ interface Props {
|
|
|
11
11
|
title?: string;
|
|
12
12
|
/** Empty-state line. Defaults to "No annotations yet". */
|
|
13
13
|
emptyText?: string;
|
|
14
|
+
/** Optional per-annotation chip label, keyed by annotation id (e.g. "Markup"). */
|
|
15
|
+
badges?: Record<string, string>;
|
|
16
|
+
/** Fires with a thread's root id on hover, null on leave. */
|
|
17
|
+
onAnnotationHover?: (id: string | null) => void;
|
|
14
18
|
onAnnotationSelect?: (annotation: Annotation | null) => void;
|
|
15
19
|
onAnnotationsChanged?: () => void;
|
|
16
20
|
}
|
|
@@ -21,18 +21,25 @@
|
|
|
21
21
|
* y={cm.y}
|
|
22
22
|
* header={cm.target?.name}
|
|
23
23
|
* items={[
|
|
24
|
-
* { label: "Preview", onSelect: () => preview(cm.target) },
|
|
24
|
+
* { label: "Preview", icon: "👁️", onSelect: () => preview(cm.target) },
|
|
25
25
|
* { label: "Copy URL", onSelect: () => copy(cm.target) },
|
|
26
26
|
* { divider: true },
|
|
27
27
|
* { label: "Delete", variant: "danger", onSelect: () => del(cm.target) },
|
|
28
28
|
* ]}
|
|
29
29
|
* />
|
|
30
|
+
*
|
|
31
|
+
* Keyboard: ArrowUp/ArrowDown/Home/End move focus among items, Escape
|
|
32
|
+
* dismisses (handled by use:floating), Enter/Space activates the focused
|
|
33
|
+
* button natively. Focus moves to the first item when the menu opens and
|
|
34
|
+
* returns to the trigger when it closes.
|
|
30
35
|
*/
|
|
31
36
|
|
|
32
37
|
import { floating, makeVirtualAnchor } from '../actions/floating.js';
|
|
33
38
|
|
|
34
39
|
type ActionItem = {
|
|
35
40
|
label: string;
|
|
41
|
+
/** Optional leading icon — a single emoji/glyph, rendered before the label. */
|
|
42
|
+
icon?: string;
|
|
36
43
|
onSelect: () => void;
|
|
37
44
|
variant?: 'default' | 'danger';
|
|
38
45
|
disabled?: boolean;
|
|
@@ -84,16 +91,57 @@
|
|
|
84
91
|
|
|
85
92
|
// Virtual anchor tracks the cursor position; rebuilt whenever x or y change.
|
|
86
93
|
let anchor = $derived(makeVirtualAnchor(x, y));
|
|
94
|
+
|
|
95
|
+
let menuEl = $state<HTMLDivElement | null>(null);
|
|
96
|
+
|
|
97
|
+
function menuItemButtons(): HTMLButtonElement[] {
|
|
98
|
+
if (!menuEl) return [];
|
|
99
|
+
return Array.from(menuEl.querySelectorAll('button[role="menuitem"]:not(:disabled)'));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
103
|
+
if (e.key === 'Escape') {
|
|
104
|
+
close();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
|
|
108
|
+
const buttons = menuItemButtons();
|
|
109
|
+
if (!buttons.length) return;
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
const current = buttons.indexOf(document.activeElement as HTMLButtonElement);
|
|
112
|
+
let next: HTMLButtonElement;
|
|
113
|
+
if (e.key === 'ArrowDown') next = buttons[(current + 1 + buttons.length) % buttons.length];
|
|
114
|
+
else if (e.key === 'ArrowUp') next = buttons[(current - 1 + buttons.length) % buttons.length];
|
|
115
|
+
else if (e.key === 'Home') next = buttons[0];
|
|
116
|
+
else next = buttons[buttons.length - 1];
|
|
117
|
+
next.focus();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Move focus into the menu (first item) whenever it opens — the floating
|
|
121
|
+
// action's returnFocusTarget already captured the pre-open activeElement
|
|
122
|
+
// by the time this effect runs, so focus-return-on-close still works.
|
|
123
|
+
$effect(() => {
|
|
124
|
+
if (open && menuEl) {
|
|
125
|
+
const el = menuEl;
|
|
126
|
+
requestAnimationFrame(() => {
|
|
127
|
+
const buttons = Array.from(
|
|
128
|
+
el.querySelectorAll('button[role="menuitem"]:not(:disabled)')
|
|
129
|
+
) as HTMLButtonElement[];
|
|
130
|
+
buttons[0]?.focus();
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
});
|
|
87
134
|
</script>
|
|
88
135
|
|
|
89
136
|
{#if open}
|
|
90
137
|
<div
|
|
138
|
+
bind:this={menuEl}
|
|
91
139
|
role="menu"
|
|
92
140
|
tabindex="-1"
|
|
93
141
|
class="min-w-[200px] bg-base-200 border border-base-300 rounded-md py-1 {className}"
|
|
94
142
|
style="z-index: {zIndex};"
|
|
95
143
|
use:floating={{ anchor, placement: 'bottom-start', flip: true, shift: true, offset: 2, onDismiss: close }}
|
|
96
|
-
onkeydown={
|
|
144
|
+
onkeydown={handleKeydown}
|
|
97
145
|
>
|
|
98
146
|
{#if header}
|
|
99
147
|
<p
|
|
@@ -110,13 +158,14 @@
|
|
|
110
158
|
<button
|
|
111
159
|
type="button"
|
|
112
160
|
role="menuitem"
|
|
113
|
-
class="w-full text-left px-3 py-1.5 text-sm transition-colors
|
|
161
|
+
class="w-full flex items-center gap-2 text-left px-3 py-1.5 text-sm transition-colors
|
|
114
162
|
{item.variant === 'danger' ? 'text-error hover:bg-error/10' : 'hover:bg-base-200'}
|
|
115
163
|
{item.disabled ? 'opacity-50 cursor-not-allowed' : ''}"
|
|
116
164
|
disabled={item.disabled}
|
|
117
165
|
onclick={() => handleSelect(item)}
|
|
118
166
|
>
|
|
119
|
-
{item.
|
|
167
|
+
{#if item.icon}<span class="shrink-0">{item.icon}</span>{/if}
|
|
168
|
+
<span class="truncate">{item.label}</span>
|
|
120
169
|
</button>
|
|
121
170
|
{/if}
|
|
122
171
|
{/each}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ContextMenuTrigger — wraps content with the standard right-click +
|
|
4
|
+
* "⋯" touch-affordance pattern, backed by ContextMenu.
|
|
5
|
+
*
|
|
6
|
+
* Right-click anywhere in the wrapped area opens the menu at the cursor.
|
|
7
|
+
* A small "⋯" button (visible on hover/focus for mouse, always visible on
|
|
8
|
+
* coarse/touch pointers) opens the same menu anchored to itself, so the
|
|
9
|
+
* action is reachable without a right-click gesture.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* <ContextMenuTrigger
|
|
13
|
+
* items={[
|
|
14
|
+
* { label: "Duplicate", icon: "📄", onSelect: () => duplicate(page) },
|
|
15
|
+
* { divider: true },
|
|
16
|
+
* { label: "Delete", variant: "danger", onSelect: () => del(page) },
|
|
17
|
+
* ]}
|
|
18
|
+
* >
|
|
19
|
+
* <PageCard {page} />
|
|
20
|
+
* </ContextMenuTrigger>
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { Snippet } from 'svelte';
|
|
24
|
+
import ContextMenu from './ContextMenu.svelte';
|
|
25
|
+
import type { ContextMenuItem } from './ContextMenu.svelte';
|
|
26
|
+
|
|
27
|
+
interface Props {
|
|
28
|
+
/** Ordered list of items and dividers to render in the menu. */
|
|
29
|
+
items: ContextMenuItem[];
|
|
30
|
+
/** Optional small italic label rendered above the items. */
|
|
31
|
+
header?: string;
|
|
32
|
+
/** Wrapped content. */
|
|
33
|
+
children: Snippet;
|
|
34
|
+
/** aria-label for the "⋯" touch-trigger button. Default "More actions". */
|
|
35
|
+
triggerLabel?: string;
|
|
36
|
+
/** Hide the "⋯" button — e.g. right-click-only contexts. Default false. */
|
|
37
|
+
hideButton?: boolean;
|
|
38
|
+
/** Stacking context z-index, forwarded to ContextMenu. Default 70. */
|
|
39
|
+
zIndex?: number;
|
|
40
|
+
/** Additional CSS class for the wrapper element. */
|
|
41
|
+
class?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let {
|
|
45
|
+
items,
|
|
46
|
+
header,
|
|
47
|
+
children,
|
|
48
|
+
triggerLabel = 'More actions',
|
|
49
|
+
hideButton = false,
|
|
50
|
+
zIndex = 70,
|
|
51
|
+
class: className = ''
|
|
52
|
+
}: Props = $props();
|
|
53
|
+
|
|
54
|
+
let cm = $state({ open: false, x: 0, y: 0 });
|
|
55
|
+
|
|
56
|
+
function openAt(x: number, y: number) {
|
|
57
|
+
cm = { open: true, x, y };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function handleContextMenu(e: MouseEvent) {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
openAt(e.clientX, e.clientY);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function handleTriggerClick(e: MouseEvent) {
|
|
66
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
67
|
+
openAt(rect.left, rect.bottom);
|
|
68
|
+
}
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<div class="group relative {className}" role="group" oncontextmenu={handleContextMenu}>
|
|
72
|
+
{@render children()}
|
|
73
|
+
{#if !hideButton}
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
class="btn btn-xs btn-circle bg-base-100/85 backdrop-blur-sm shadow-sm absolute top-1 right-1 z-10 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100"
|
|
77
|
+
aria-label={triggerLabel}
|
|
78
|
+
onclick={handleTriggerClick}
|
|
79
|
+
>
|
|
80
|
+
⋯
|
|
81
|
+
</button>
|
|
82
|
+
{/if}
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<ContextMenu bind:open={cm.open} x={cm.x} y={cm.y} {items} {header} {zIndex} />
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ContextMenuTrigger — wraps content with the standard right-click +
|
|
3
|
+
* "⋯" touch-affordance pattern, backed by ContextMenu.
|
|
4
|
+
*
|
|
5
|
+
* Right-click anywhere in the wrapped area opens the menu at the cursor.
|
|
6
|
+
* A small "⋯" button (visible on hover/focus for mouse, always visible on
|
|
7
|
+
* coarse/touch pointers) opens the same menu anchored to itself, so the
|
|
8
|
+
* action is reachable without a right-click gesture.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* <ContextMenuTrigger
|
|
12
|
+
* items={[
|
|
13
|
+
* { label: "Duplicate", icon: "📄", onSelect: () => duplicate(page) },
|
|
14
|
+
* { divider: true },
|
|
15
|
+
* { label: "Delete", variant: "danger", onSelect: () => del(page) },
|
|
16
|
+
* ]}
|
|
17
|
+
* >
|
|
18
|
+
* <PageCard {page} />
|
|
19
|
+
* </ContextMenuTrigger>
|
|
20
|
+
*/
|
|
21
|
+
import type { Snippet } from 'svelte';
|
|
22
|
+
import type { ContextMenuItem } from './ContextMenu.svelte';
|
|
23
|
+
interface Props {
|
|
24
|
+
/** Ordered list of items and dividers to render in the menu. */
|
|
25
|
+
items: ContextMenuItem[];
|
|
26
|
+
/** Optional small italic label rendered above the items. */
|
|
27
|
+
header?: string;
|
|
28
|
+
/** Wrapped content. */
|
|
29
|
+
children: Snippet;
|
|
30
|
+
/** aria-label for the "⋯" touch-trigger button. Default "More actions". */
|
|
31
|
+
triggerLabel?: string;
|
|
32
|
+
/** Hide the "⋯" button — e.g. right-click-only contexts. Default false. */
|
|
33
|
+
hideButton?: boolean;
|
|
34
|
+
/** Stacking context z-index, forwarded to ContextMenu. Default 70. */
|
|
35
|
+
zIndex?: number;
|
|
36
|
+
/** Additional CSS class for the wrapper element. */
|
|
37
|
+
class?: string;
|
|
38
|
+
}
|
|
39
|
+
declare const ContextMenuTrigger: import("svelte").Component<Props, {}, "">;
|
|
40
|
+
type ContextMenuTrigger = ReturnType<typeof ContextMenuTrigger>;
|
|
41
|
+
export default ContextMenuTrigger;
|
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
onAnnotationClick?: (annotation: Annotation) => void
|
|
24
24
|
/** Called when the image is clicked in annotation mode */
|
|
25
25
|
onImageClick?: (x: number, y: number) => void
|
|
26
|
+
/** Called with a pin's id on hover, null on leave */
|
|
27
|
+
onAnnotationHover?: (id: string | null) => void
|
|
28
|
+
/** Markup shape ids to accentuate (e.g. a hovered comment's linked markup) */
|
|
29
|
+
highlightedShapeIds?: number[]
|
|
26
30
|
/** Called when the lightbox requests close */
|
|
27
31
|
onClose?: () => void
|
|
28
32
|
}
|
|
@@ -37,6 +41,8 @@
|
|
|
37
41
|
showResolved = false,
|
|
38
42
|
onAnnotationClick,
|
|
39
43
|
onImageClick,
|
|
44
|
+
onAnnotationHover,
|
|
45
|
+
highlightedShapeIds,
|
|
40
46
|
onClose,
|
|
41
47
|
}: Props = $props()
|
|
42
48
|
|
|
@@ -162,7 +168,7 @@
|
|
|
162
168
|
|
|
163
169
|
<!-- Markup shapes overlay -->
|
|
164
170
|
{#if markupShapes.length > 0}
|
|
165
|
-
<MarkupOverlay shapes={markupShapes} {imageUrl} />
|
|
171
|
+
<MarkupOverlay shapes={markupShapes} {imageUrl} {highlightedShapeIds} />
|
|
166
172
|
{/if}
|
|
167
173
|
|
|
168
174
|
<!-- Annotation overlay -->
|
|
@@ -174,6 +180,7 @@
|
|
|
174
180
|
{showResolved}
|
|
175
181
|
{onAnnotationClick}
|
|
176
182
|
{onImageClick}
|
|
183
|
+
{onAnnotationHover}
|
|
177
184
|
/>
|
|
178
185
|
{/if}
|
|
179
186
|
</div>
|
|
@@ -262,7 +269,7 @@
|
|
|
262
269
|
style="transform: scale({zoom}); transform-origin: center center;"
|
|
263
270
|
/>
|
|
264
271
|
{#if markupShapes.length > 0}
|
|
265
|
-
<MarkupOverlay shapes={markupShapes} {imageUrl} />
|
|
272
|
+
<MarkupOverlay shapes={markupShapes} {imageUrl} {highlightedShapeIds} />
|
|
266
273
|
{/if}
|
|
267
274
|
{#if annotations.length > 0}
|
|
268
275
|
<AnnotationLayer
|
|
@@ -19,6 +19,10 @@ interface Props {
|
|
|
19
19
|
onAnnotationClick?: (annotation: Annotation) => void;
|
|
20
20
|
/** Called when the image is clicked in annotation mode */
|
|
21
21
|
onImageClick?: (x: number, y: number) => void;
|
|
22
|
+
/** Called with a pin's id on hover, null on leave */
|
|
23
|
+
onAnnotationHover?: (id: string | null) => void;
|
|
24
|
+
/** Markup shape ids to accentuate (e.g. a hovered comment's linked markup) */
|
|
25
|
+
highlightedShapeIds?: number[];
|
|
22
26
|
/** Called when the lightbox requests close */
|
|
23
27
|
onClose?: () => void;
|
|
24
28
|
}
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount } from "svelte"
|
|
3
3
|
import type { MarkupShape } from './markup'
|
|
4
|
-
import { renderAllShapes } from './markup'
|
|
4
|
+
import { renderAllShapes, renderShape } from './markup'
|
|
5
5
|
|
|
6
6
|
interface Props {
|
|
7
7
|
shapes: MarkupShape[]
|
|
8
8
|
imageUrl: string
|
|
9
|
+
/** Shape ids to accentuate (e.g. the markup a hovered comment links to).
|
|
10
|
+
* Drawn again on top with a colored glow so the linkage is visible. */
|
|
11
|
+
highlightedShapeIds?: number[]
|
|
9
12
|
}
|
|
10
13
|
|
|
11
|
-
let { shapes, imageUrl }: Props = $props()
|
|
14
|
+
let { shapes, imageUrl, highlightedShapeIds }: Props = $props()
|
|
12
15
|
|
|
13
16
|
let canvas: HTMLCanvasElement | undefined = $state()
|
|
14
17
|
let imgW = $state(0)
|
|
@@ -23,6 +26,22 @@
|
|
|
23
26
|
canvas.height = imgH
|
|
24
27
|
ctx.clearRect(0, 0, imgW, imgH)
|
|
25
28
|
renderAllShapes(ctx, shapes)
|
|
29
|
+
|
|
30
|
+
// Accentuate linked shapes: a soft glow in the shape's own colour plus a
|
|
31
|
+
// second pass for a bolder line, so a hovered comment's markup stands out.
|
|
32
|
+
const hi = highlightedShapeIds
|
|
33
|
+
if (hi && hi.length) {
|
|
34
|
+
const set = new Set(hi)
|
|
35
|
+
for (const shape of shapes) {
|
|
36
|
+
if (!set.has(shape.id)) continue
|
|
37
|
+
ctx.save()
|
|
38
|
+
ctx.shadowColor = shape.color
|
|
39
|
+
ctx.shadowBlur = 24
|
|
40
|
+
renderShape(ctx, shape)
|
|
41
|
+
renderShape(ctx, shape)
|
|
42
|
+
ctx.restore()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
26
45
|
}
|
|
27
46
|
|
|
28
47
|
onMount(() => {
|
|
@@ -37,8 +56,9 @@
|
|
|
37
56
|
img.src = imageUrl
|
|
38
57
|
})
|
|
39
58
|
|
|
40
|
-
// Re-render when shapes change
|
|
59
|
+
// Re-render when shapes OR the highlighted set change
|
|
41
60
|
$effect(() => {
|
|
61
|
+
void highlightedShapeIds
|
|
42
62
|
if (shapes && loaded) {
|
|
43
63
|
render()
|
|
44
64
|
}
|
|
@@ -2,6 +2,9 @@ import type { MarkupShape } from './markup';
|
|
|
2
2
|
interface Props {
|
|
3
3
|
shapes: MarkupShape[];
|
|
4
4
|
imageUrl: string;
|
|
5
|
+
/** Shape ids to accentuate (e.g. the markup a hovered comment links to).
|
|
6
|
+
* Drawn again on top with a colored glow so the linkage is visible. */
|
|
7
|
+
highlightedShapeIds?: number[];
|
|
5
8
|
}
|
|
6
9
|
declare const MarkupOverlay: import("svelte").Component<Props, {}, "">;
|
|
7
10
|
type MarkupOverlay = ReturnType<typeof MarkupOverlay>;
|
package/dist/index.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export type { InputOptions } from './stores/inputDialog';
|
|
|
23
23
|
export { default as SearchDropdown } from './components/SearchDropdown.svelte';
|
|
24
24
|
export { default as FilterDropdown } from './components/FilterDropdown.svelte';
|
|
25
25
|
export { default as ContextMenu } from './components/ContextMenu.svelte';
|
|
26
|
+
export { default as ContextMenuTrigger } from './components/ContextMenuTrigger.svelte';
|
|
26
27
|
export { default as ChipInput } from './components/ChipInput.svelte';
|
|
27
28
|
export { default as ResizableDivider } from './components/ResizableDivider.svelte';
|
|
28
29
|
export { default as DateRangePicker } from './components/DateRangePicker.svelte';
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,7 @@ export { showInput, submitInput, cancelInput, getPendingInput } from './stores/i
|
|
|
20
20
|
export { default as SearchDropdown } from './components/SearchDropdown.svelte';
|
|
21
21
|
export { default as FilterDropdown } from './components/FilterDropdown.svelte';
|
|
22
22
|
export { default as ContextMenu } from './components/ContextMenu.svelte';
|
|
23
|
+
export { default as ContextMenuTrigger } from './components/ContextMenuTrigger.svelte';
|
|
23
24
|
export { default as ChipInput } from './components/ChipInput.svelte';
|
|
24
25
|
export { default as ResizableDivider } from './components/ResizableDivider.svelte';
|
|
25
26
|
export { default as DateRangePicker } from './components/DateRangePicker.svelte';
|