@triiiceratops/plugin-annotation-editor 1.0.0-rc.6 → 1.0.0

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.
@@ -6,6 +6,6 @@ import type { AnnotationStore } from './AnnotationStore.svelte';
6
6
  * (the panel may never open), so it drives the shared store directly: point the
7
7
  * store at the current canvas and load; the store injects into this viewer's
8
8
  * per-viewer display state (F10, ADR 0007). When the editor panel is mounted,
9
- * its manager shares this same store, so both paths converge on one cache.
9
+ * its drawing layer shares this same store, so both paths converge on one cache.
10
10
  */
11
11
  export declare function createLoader(store: AnnotationStore): (viewerState: ViewerState) => void;
package/dist/plugin.d.ts CHANGED
@@ -3,11 +3,15 @@
3
3
  *
4
4
  * `definePlugin` returns the framework-neutral factory core activates through the
5
5
  * structural seam (it carries its own `activate(host)`); core never imports this
6
- * package or its Svelte runtime. The full domain machinery — Store, Adapter seam,
7
- * per-viewer display sync, undo/redo, body editors, Annotorious integration — is
8
- * carried intact and driven from the neutral `view.mount(container, context)`
9
- * contract (see `mount.svelte.ts`). Annotorious needs the raw OSD viewer, so the
10
- * plugin declares `requiredCapabilities: ['osd@5']` (ADR 0009).
6
+ * package or its Svelte runtime. The domain machinery — Store, Adapter seam,
7
+ * per-viewer display sync, undo/redo, body editors, the drawing layer — is driven
8
+ * from the neutral `view.mount(container, context)` contract (see
9
+ * `mount.svelte.ts`).
10
+ *
11
+ * `uiId` is load-bearing, not cosmetic: core decides whether an annotation shape
12
+ * is editable by finding a toolbar button whose plugin id is the literal
13
+ * `'annotation-editor'` (`AnnotationShapeOverlay.svelte`). Renaming it makes
14
+ * every shape non-editable, and no test in this package would catch it.
11
15
  */
12
16
  import { type SdkPlugin } from '@triiiceratops/plugin-sdk';
13
17
  import type { AnnotationEditorConfig } from './types';
@@ -1,8 +1,8 @@
1
1
  import type { AnnotationStorageAdapter } from '../types';
2
2
  /**
3
3
  * Adapter authoring kit — a reusable conformance suite so adapter authors can
4
- * verify their implementation against the contract the plugin relies on (ticket
5
- * 10, F28 / SPEC §2.6).
4
+ * verify their implementation against the contract the plugin relies on (F28 /
5
+ * SPEC §2.6).
6
6
  *
7
7
  * An adapter is pure storage: the plugin owns display sync, caching, id
8
8
  * bookkeeping, timestamp/attribution stamping, and error handling. This suite
@@ -1,158 +1 @@
1
- import { describe as _, beforeEach as A, it as c, expect as n } from "vitest";
2
- function w(i) {
3
- const u = { ...i };
4
- return delete u.__fullBodyLoaded, delete u.__bodyPreview, u;
5
- }
6
- function b(i, u) {
7
- return typeof i == "string" ? i : i && typeof i == "object" ? i.id : u;
8
- }
9
- function f(i, u, h) {
10
- return {
11
- "@context": "http://www.w3.org/ns/anno.jsonld",
12
- id: i,
13
- type: "Annotation",
14
- motivation: "commenting",
15
- body: h ?? [
16
- { type: "TextualBody", purpose: "commenting", value: "hello" }
17
- ],
18
- target: {
19
- type: "SpecificResource",
20
- source: u,
21
- selector: {
22
- type: "FragmentSelector",
23
- conformsTo: "http://www.w3.org/TR/media-frags/",
24
- value: "xywh=10,20,30,40"
25
- }
26
- }
27
- };
28
- }
29
- function C(i, u = {}) {
30
- const { supportsIdReconciliation: h, supportsHydrate: g, label: x } = u;
31
- _(x ?? "AnnotationStorageAdapter contract", () => {
32
- let o, d, t, m = 0;
33
- A(() => {
34
- o = i(), m += 1, d = `contract-manifest-${m}`, t = `contract-canvas-${m}`;
35
- });
36
- async function p(a) {
37
- const r = await o.create(
38
- d,
39
- t,
40
- structuredClone(a)
41
- ), e = b(r, a.id), s = (await o.load(d, t)).find((l) => l.id === e) ?? null;
42
- if (!s) return { id: e, resolved: null };
43
- if (s.__fullBodyLoaded === !1) {
44
- n(typeof o.hydrate).toBe("function");
45
- const l = await o.hydrate(d, t, e);
46
- return { id: e, resolved: l };
47
- }
48
- return { id: e, resolved: s };
49
- }
50
- c("load returns an empty array for a canvas with no annotations", async () => {
51
- const a = await o.load(d, t);
52
- n(Array.isArray(a)).toBe(!0), n(a).toHaveLength(0);
53
- }), c("create then load round-trips the annotation verbatim", async () => {
54
- const a = f("anno-1", t), { id: r, resolved: e } = await p(a);
55
- n(e).not.toBeNull(), n(w(e)).toEqual({
56
- ...a,
57
- id: r
58
- });
59
- }), c("create preserves structured / unknown body shapes verbatim", async () => {
60
- const a = {
61
- type: "Dataset",
62
- purpose: "linking",
63
- value: {
64
- nested: { deep: [1, 2, 3], flag: !0 },
65
- ref: "https://example.org/entity/42"
66
- },
67
- extra: null
68
- }, r = f(
69
- "anno-structured",
70
- t,
71
- a
72
- ), { resolved: e } = await p(r);
73
- n(e).not.toBeNull(), n(e.body).toEqual(a);
74
- }), c("update replaces a stored annotation body", async () => {
75
- const a = f("anno-2", t), { id: r } = await p(a), e = {
76
- ...a,
77
- id: r,
78
- body: [
79
- {
80
- type: "TextualBody",
81
- purpose: "commenting",
82
- value: "edited"
83
- }
84
- ]
85
- };
86
- await o.update(
87
- d,
88
- t,
89
- structuredClone(e)
90
- );
91
- const s = (await o.load(d, t)).find((v) => v.id === r);
92
- n(s).toBeDefined();
93
- const l = s?.__fullBodyLoaded === !1 ? await o.hydrate(d, t, r) : s;
94
- n(w(l).body).toEqual(e.body);
95
- }), c("delete removes a stored annotation", async () => {
96
- const a = f("anno-3", t), { id: r } = await p(a);
97
- await o.delete(d, t, r);
98
- const e = await o.load(d, t);
99
- n(e.find((y) => y.id === r)).toBeUndefined();
100
- }), c("isolates annotations by manifest and canvas", async () => {
101
- const a = f("anno-iso", t), { id: r } = await p(a), e = `${t}-other`, y = `${d}-other`;
102
- n(await o.load(d, e)).toHaveLength(0), n(await o.load(y, t)).toHaveLength(0);
103
- const s = await o.load(d, t);
104
- n(s.find((l) => l.id === r)).toBeDefined();
105
- }), h && c("returns a server-assigned id honored by update and delete", async () => {
106
- const a = f("local-temp-id", t), r = await o.create(
107
- d,
108
- t,
109
- structuredClone(a)
110
- );
111
- n(r == null).toBe(!1);
112
- const e = b(r, a.id);
113
- n(typeof e).toBe("string");
114
- const y = {
115
- ...a,
116
- id: e,
117
- body: [
118
- {
119
- type: "TextualBody",
120
- purpose: "commenting",
121
- value: "reconciled-edit"
122
- }
123
- ]
124
- };
125
- await o.update(
126
- d,
127
- t,
128
- structuredClone(y)
129
- );
130
- let s = await o.load(d, t);
131
- const l = s.find((B) => B.id === e);
132
- n(l).toBeDefined();
133
- const v = l?.__fullBodyLoaded === !1 ? await o.hydrate(
134
- d,
135
- t,
136
- e
137
- ) : l;
138
- n(w(v).body).toEqual(y.body), await o.delete(d, t, e), s = await o.load(d, t), n(
139
- s.find((B) => B.id === e)
140
- ).toBeUndefined();
141
- }), g && (c("exposes a hydrate method", () => {
142
- n(typeof o.hydrate).toBe("function");
143
- }), c("hydrate returns the full annotation for a known id", async () => {
144
- const a = f("anno-hydrate", t), { id: r } = await p(a), e = await o.hydrate(d, t, r);
145
- n(e).not.toBeNull(), n(w(e).body).toEqual(a.body);
146
- }), c("hydrate returns null for an unknown id", async () => {
147
- const a = await o.hydrate(
148
- d,
149
- t,
150
- "no-such-annotation"
151
- );
152
- n(a).toBeNull();
153
- }));
154
- });
155
- }
156
- export {
157
- C as runAdapterContractTests
158
- };
1
+ import{describe as t,beforeEach as e,it as a,expect as o}from"vitest";function n(t){const e={...t};return delete e.__fullBodyLoaded,delete e.__bodyPreview,e}function d(t,e){return"string"==typeof t?t:t&&"object"==typeof t?t.id:e}function i(t,e,a){return{"@context":"http://www.w3.org/ns/anno.jsonld",id:t,type:"Annotation",motivation:"commenting",body:a??[{type:"TextualBody",purpose:"commenting",value:"hello"}],target:{type:"SpecificResource",source:e,selector:{type:"FragmentSelector",conformsTo:"http://www.w3.org/TR/media-frags/",value:"xywh=10,20,30,40"}}}}function r(r,s={}){const{supportsIdReconciliation:l,supportsHydrate:c,label:u}=s;t(u??"AnnotationStorageAdapter contract",()=>{let t,s,u,y=0;async function p(e){const a=d(await t.create(s,u,structuredClone(e)),e.id),n=(await t.load(s,u)).find(t=>t.id===a)??null;if(!n)return{id:a,resolved:null};if(!1===n.__fullBodyLoaded){o(typeof t.hydrate).toBe("function");const e=await t.hydrate(s,u,a);return{id:a,resolved:e}}return{id:a,resolved:n}}e(()=>{t=r(),y+=1,s=`contract-manifest-${y}`,u=`contract-canvas-${y}`}),a("load returns an empty array for a canvas with no annotations",async()=>{const e=await t.load(s,u);o(Array.isArray(e)).toBe(!0),o(e).toHaveLength(0)}),a("create then load round-trips the annotation verbatim",async()=>{const t=i("anno-1",u),{id:e,resolved:a}=await p(t);o(a).not.toBeNull(),o(n(a)).toEqual({...t,id:e})}),a("create preserves structured / unknown body shapes verbatim",async()=>{const t={type:"Dataset",purpose:"linking",value:{nested:{deep:[1,2,3],flag:!0},ref:"https://example.org/entity/42"},extra:null},e=i("anno-structured",u,t),{resolved:a}=await p(e);o(a).not.toBeNull(),o(a.body).toEqual(t)}),a("update replaces a stored annotation body",async()=>{const e=i("anno-2",u),{id:a}=await p(e),d={...e,id:a,body:[{type:"TextualBody",purpose:"commenting",value:"edited"}]};await t.update(s,u,structuredClone(d));const r=(await t.load(s,u)).find(t=>t.id===a);o(r).toBeDefined();const l=!1===r?.__fullBodyLoaded?await t.hydrate(s,u,a):r;o(n(l).body).toEqual(d.body)}),a("delete removes a stored annotation",async()=>{const e=i("anno-3",u),{id:a}=await p(e);await t.delete(s,u,a);const n=await t.load(s,u);o(n.find(t=>t.id===a)).toBeUndefined()}),a("isolates annotations by manifest and canvas",async()=>{const e=i("anno-iso",u),{id:a}=await p(e),n=`${u}-other`,d=`${s}-other`;o(await t.load(s,n)).toHaveLength(0),o(await t.load(d,u)).toHaveLength(0);const r=await t.load(s,u);o(r.find(t=>t.id===a)).toBeDefined()}),l&&a("returns a server-assigned id honored by update and delete",async()=>{const e=i("local-temp-id",u),a=await t.create(s,u,structuredClone(e));o(null==a).toBe(!1);const r=d(a,e.id);o(typeof r).toBe("string");const l={...e,id:r,body:[{type:"TextualBody",purpose:"commenting",value:"reconciled-edit"}]};await t.update(s,u,structuredClone(l));let c=await t.load(s,u);const y=c.find(t=>t.id===r);o(y).toBeDefined();const p=!1===y?.__fullBodyLoaded?await t.hydrate(s,u,r):y;o(n(p).body).toEqual(l.body),await t.delete(s,u,r),c=await t.load(s,u),o(c.find(t=>t.id===r)).toBeUndefined()}),c&&(a("exposes a hydrate method",()=>{o(typeof t.hydrate).toBe("function")}),a("hydrate returns the full annotation for a known id",async()=>{const e=i("anno-hydrate",u),{id:a}=await p(e),d=await t.hydrate(s,u,a);o(d).not.toBeNull(),o(n(d).body).toEqual(e.body)}),a("hydrate returns null for an unknown id",async()=>{const e=await t.hydrate(s,u,"no-such-annotation");o(e).toBeNull()}))})}export{r as runAdapterContractTests};
@@ -0,0 +1,16 @@
1
+ import type { DrawingTool } from './types';
2
+ /** Every tool the plugin knows how to draw, in default button order. */
3
+ export declare const ALL_TOOLS: DrawingTool[];
4
+ /**
5
+ * Resolve the effective tool set and default tool from config so the panel and
6
+ * the drawing layer share one source of truth (F8). An empty/absent `tools` list
7
+ * means "all tools"; `defaultTool` is honored only when it's within `tools`,
8
+ * otherwise the first available tool wins.
9
+ */
10
+ export declare function resolveTools(config: {
11
+ tools?: DrawingTool[];
12
+ defaultTool?: DrawingTool;
13
+ }): {
14
+ tools: DrawingTool[];
15
+ defaultTool: DrawingTool;
16
+ };
package/dist/types.d.ts CHANGED
@@ -1,15 +1,21 @@
1
- import type { User, DrawingStyle } from '@annotorious/openseadragon';
2
1
  import type { Component } from 'svelte';
3
2
  import type { PluginUiTarget } from '@triiiceratops/plugin-sdk';
4
- import type { PointStyle } from 'triiiceratops/image-export';
5
3
  import type { W3CAnnotation, AdapterLoadResult } from './adapters/types';
6
- export type { PointStyle };
4
+ /**
5
+ * The person an annotation is attributed to. Only `id` and `name` are ever
6
+ * read — they are what creator stamping writes onto a new annotation — so the
7
+ * shape is declared here rather than borrowed from a drawing library.
8
+ */
9
+ export interface AnnotationEditorUser {
10
+ id: string;
11
+ name?: string;
12
+ }
7
13
  export interface AnnotationEditorRuntimeContext<HostContext = unknown, TBody = W3CAnnotationBody> {
8
14
  manifestId: string | null;
9
15
  canvasId: string | null;
10
16
  isEditing: boolean;
11
17
  selectedAnnotation: W3CAnnotation<TBody> | null;
12
- user?: User;
18
+ user?: AnnotationEditorUser;
13
19
  hostContext: HostContext | null;
14
20
  }
15
21
  export interface AnnotationEditorExtension<HostContext = unknown, TBody = W3CAnnotationBody> {
@@ -115,16 +121,7 @@ export interface AnnotationEditorConfig<TBody = W3CAnnotationBody, THostContext
115
121
  /** Storage adapter for persistence */
116
122
  adapter?: AnnotationStorageAdapter<TBody>;
117
123
  /** Current user for attribution */
118
- user?: User;
119
- /** Drawing style for annotations while editing */
120
- drawingStyle?: DrawingStyle;
121
- /**
122
- * Marker styling for point annotations (`PointSelector`). Consumed by both
123
- * the read-only overlay and the editor so a point looks the same selected or
124
- * not; `radius` is in screen pixels. Defaults to a red marker of radius 5
125
- * (spec §3.4).
126
- */
127
- pointStyle?: PointStyle;
124
+ user?: AnnotationEditorUser;
128
125
  /** Available drawing tools */
129
126
  tools?: DrawingTool[];
130
127
  /** Default drawing tool */
@@ -157,7 +154,7 @@ export interface AnnotationEditorConfig<TBody = W3CAnnotationBody, THostContext
157
154
  */
158
155
  onPersistenceError?: (error: AnnotationPersistenceError) => void;
159
156
  }
160
- export type DrawingTool = 'rectangle' | 'polygon' | 'point';
157
+ export type DrawingTool = 'rectangle' | 'ellipse' | 'polygon' | 'point' | 'wholeCanvas';
161
158
  /** W3C Annotation Body */
162
159
  export interface W3CAnnotationBody {
163
160
  type?: string;
@@ -5,19 +5,44 @@
5
5
  * OWN Svelte runtime) reads `viewerState.canvasId` inside an `$effect`, no
6
6
  * dependency is registered because the two reactivity graphs don't cross. The
7
7
  * plugin therefore mirrors the handful of fields its UI reacts to
8
- * (`manifestId`, `canvasId`, `osdViewer`) into plugin-runtime `$state`, kept in
8
+ * (`manifestId`, `canvasId`, `annotatableCanvasIds`, `surfaceOpen`) into
9
+ * plugin-runtime `$state`, kept in
9
10
  * sync through the framework-neutral `ViewerState.subscribe` fan-out. Every other
10
11
  * member/method (queries, display sync, the annotation-edit bus, the style root)
11
12
  * delegates straight to the real state, so a mirror is a drop-in `ViewerState`
12
- * for the controller, loader, manager, and store.
13
+ * for the controller, loader, drawing layer, and store.
13
14
  */
15
+ import type { PluginSurface } from '@triiiceratops/plugin-sdk';
14
16
  import type { ViewerState } from 'triiiceratops';
17
+ /**
18
+ * The mirror as the plugin's UI consumes it: core's `ViewerState`, plus the
19
+ * mirrored fields core has no `ViewerState` member for.
20
+ */
21
+ export type MirroredViewerState = ViewerState & {
22
+ readonly surfaceOpen: boolean;
23
+ };
15
24
  export declare class ViewerStateMirror {
16
25
  #private;
17
26
  manifestId: string | null;
18
27
  canvasId: string | null;
19
- osdViewer: import("openseadragon").Viewer | null;
20
- constructor(real: ViewerState);
28
+ /**
29
+ * Raw rather than deeply reactive: core replaces the array wholesale, and a
30
+ * `$state` proxy would never compare equal to the source's own reference,
31
+ * so the gate below could not tell an unchanged list from a new one. Core
32
+ * keeps that reference stable while the ids are unchanged.
33
+ */
34
+ annotatableCanvasIds: string[];
35
+ /**
36
+ * Whether this plugin's own panel/flyout is open — the signal core names as
37
+ * the way a plugin observes open/close, since it mounts the plugin's
38
+ * content once per ACTIVATION and only re-parents it in and out of the
39
+ * surface, so a close destroys no component. It is not a `ViewerState`
40
+ * member: it reads off `PluginContext.surface`, which is a live projection
41
+ * over the viewer's plugin UI state, and every mutator of that state
42
+ * notifies through the same fan-out as the fields above.
43
+ */
44
+ surfaceOpen: boolean;
45
+ constructor(real: ViewerState, surface: PluginSurface);
21
46
  /** The per-viewer annotation-edit bus (mutated in place by the controller). */
22
47
  get annotationEditBus(): ViewerState['annotationEditBus'];
23
48
  getCanvases(manifestId: string, sequenceIndex?: number): unknown[];
@@ -25,11 +50,26 @@ export declare class ViewerStateMirror {
25
50
  setUserAnnotations(manifestId: string, canvasId: string, annotations: unknown[]): void;
26
51
  clearUserAnnotations(manifestId: string, canvasId: string): void;
27
52
  getStyleRoot(): Document | ShadowRoot | null;
53
+ /**
54
+ * The owning viewer's config. Read for `pointStyle`, which core's read-only
55
+ * overlay resolves its own point marker from — the editor has to answer
56
+ * from the same object or a point changes size the moment it is selected.
57
+ */
58
+ get config(): ViewerState['config'];
59
+ /**
60
+ * The canvas-space box the viewport shows. The keyboard's place-then-shape
61
+ * creation reads it to put a default shape at the centre of the view.
62
+ */
63
+ get viewportBounds(): ViewerState['viewportBounds'];
64
+ canvasSize(canvasId?: string): ReturnType<ViewerState['canvasSize']>;
65
+ canvasToScreen(point: Parameters<ViewerState['canvasToScreen']>[0], canvasId?: string): ReturnType<ViewerState['canvasToScreen']>;
66
+ screenToCanvas(point: Parameters<ViewerState['screenToCanvas']>[0], canvasId?: string): ReturnType<ViewerState['screenToCanvas']>;
67
+ subscribeFrame(listener: () => void): () => void;
28
68
  /** Drop the bridge's `ViewerState.subscribe` registration. */
29
69
  destroy(): void;
30
70
  }
31
- /** Build a mirror and expose it typed as a `ViewerState` for the plugin UI. */
32
- export declare function createViewerStateMirror(real: ViewerState): {
33
- mirror: ViewerState;
71
+ /** Build a mirror and expose it as a `ViewerState` for the plugin UI. */
72
+ export declare function createViewerStateMirror(real: ViewerState, surface: PluginSurface): {
73
+ mirror: MirroredViewerState;
34
74
  destroy: () => void;
35
75
  };
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@triiiceratops/plugin-annotation-editor",
3
- "version": "1.0.0-rc.6",
3
+ "version": "1.0.0",
4
4
  "type": "module",
5
- "description": "Annotation editor plugin for the triiiceratops IIIF viewer, authored on @triiiceratops/plugin-sdk.",
5
+ "description": "Annotation editing for the Triiiceratops IIIF viewer: a first-party drawing layer with rectangle, ellipse, polygon, point and whole-canvas tools, full keyboard parity, and a pluggable storage adapter.",
6
6
  "license": "MIT",
7
7
  "author": "David Flood",
8
8
  "repository": {
@@ -10,12 +10,11 @@
10
10
  "url": "git+https://github.com/d-flood/triiiceratops.git",
11
11
  "directory": "packages/plugin-annotation-editor"
12
12
  },
13
- "homepage": "https://d-flood.github.io/triiiceratops/",
13
+ "homepage": "https://triiiceratops.org/",
14
14
  "publishConfig": {
15
15
  "access": "public",
16
16
  "provenance": true
17
17
  },
18
- "private": false,
19
18
  "engines": {
20
19
  "node": ">=22"
21
20
  },
@@ -41,7 +40,7 @@
41
40
  "./iife": "./dist/iife.js"
42
41
  },
43
42
  "scripts": {
44
- "build": "pnpm build:esm && pnpm build:iife && pnpm build:types",
43
+ "build": "pnpm clean && pnpm build:esm && pnpm build:iife && pnpm build:types",
45
44
  "build:esm": "BUILD_FORMAT=es vite build",
46
45
  "build:iife": "BUILD_FORMAT=iife vite build",
47
46
  "build:types": "tsc -p tsconfig.build.json",
@@ -53,24 +52,18 @@
53
52
  "lint:fix": "eslint . --fix --max-warnings 0"
54
53
  },
55
54
  "peerDependencies": {
56
- "triiiceratops": "^1.0.0-rc.30",
57
- "@triiiceratops/plugin-sdk": "^1.0.0-rc.5"
58
- },
59
- "dependencies": {
60
- "@annotorious/annotorious": "^3.7.19",
61
- "@annotorious/openseadragon": "^3.7.19",
62
- "openseadragon": "^5.0.1"
55
+ "triiiceratops": "^1.0.0",
56
+ "@triiiceratops/plugin-sdk": "^1.0.0"
63
57
  },
64
58
  "devDependencies": {
65
- "@sveltejs/vite-plugin-svelte": "^6.2.1",
66
- "@triiiceratops/plugin-sdk": "1.0.0-rc.5",
59
+ "@sveltejs/vite-plugin-svelte": "^6.2.4",
60
+ "@triiiceratops/plugin-sdk": "1.0.0",
67
61
  "@triiiceratops/ui": "0.0.0",
68
62
  "@types/node": "^24.10.1",
69
- "@types/openseadragon": "^5.0.1",
70
63
  "@vitest/coverage-v8": "4.0.15",
71
- "svelte": "5.45.5",
64
+ "svelte": "5.56.10",
72
65
  "svelte-check": "^4.3.4",
73
- "triiiceratops": "1.0.0-rc.30",
66
+ "triiiceratops": "1.0.0",
74
67
  "typescript": "~5.9.3",
75
68
  "vite": "^6.0.0",
76
69
  "vitest": "^4.0.15"