@annotorious/core 3.0.0-pre-alpha-43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # @annotorious/core
2
+
3
+ Annotorious core models and helpers.
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@annotorious/core",
3
+ "version": "3.0.0-pre-alpha-43",
4
+ "description": "Experimental rewrite of Annotorious",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "files": [
9
+ "src"
10
+ ],
11
+ "scripts": {
12
+ "build": "echo 'Skipping build in @annotorious/formats package'",
13
+ "test": "vitest",
14
+ "coverage": "vitest run --coverage"
15
+ },
16
+ "author": "Rainer Simon",
17
+ "license": "BSD-3-Clause",
18
+ "devDependencies": {
19
+ "@tsconfig/svelte": "^3.0.0",
20
+ "@types/deep-equal": "^1.0.1",
21
+ "@types/uuid": "^9.0.1",
22
+ "svelte": "^3.58.0",
23
+ "typescript": "^4.9.5",
24
+ "vite": "^4.2.1",
25
+ "vite-plugin-dts": "^2.3.0",
26
+ "vitest": "^0.29.3"
27
+ },
28
+ "dependencies": {
29
+ "dequal": "^2.0.3",
30
+ "nanoevents": "^7.0.1",
31
+ "nanoid": "^4.0.1",
32
+ "uuid": "^9.0.0"
33
+ }
34
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './lifecycle';
2
+ export * from './model';
3
+ export * from './presence';
4
+ export * from './state';
5
+ export * from './utils';
@@ -0,0 +1,192 @@
1
+ import { dequal } from 'dequal/lite';
2
+ import type { Annotation, FormatAdapter, W3CAnnotation } from '../model';
3
+ import { Origin } from '../state';
4
+ import type { HoverState, SelectionState, Store, ViewportState } from '../state';
5
+ import type { LifecycleEvents } from './LifecycleEvents';
6
+
7
+ export type Lifecycle<T extends Annotation> = ReturnType<typeof createLifecyleObserver<T>>;
8
+
9
+ export const createLifecyleObserver = <T extends Annotation, A extends unknown = W3CAnnotation>(
10
+ store: Store<T>,
11
+ selectionState: SelectionState<T>,
12
+ hoverState: HoverState<T>,
13
+ viewportState?: ViewportState,
14
+ adapter?: FormatAdapter<T, A>
15
+ ) => {
16
+ const observers = new Map<string, LifecycleEvents<A>[keyof LifecycleEvents<T>][]>();
17
+
18
+ // The currently selected annotations, in the state when they were selected
19
+ let initialSelection: T[] = [];
20
+
21
+ let currentHover: string | undefined;
22
+
23
+ let idleTimeout: ReturnType<typeof setTimeout>;
24
+
25
+ const on = <E extends keyof LifecycleEvents<T>>(event: E, callback: LifecycleEvents<A>[E]) => {
26
+ if (observers.has(event)) {
27
+ observers.get(event).push(callback);
28
+ } else {
29
+ observers.set(event, [callback]);
30
+ }
31
+ }
32
+
33
+ const off = <E extends keyof LifecycleEvents<T>>(event: E, callback: LifecycleEvents<A>[E]) => {
34
+ const callbacks = observers.get(event);
35
+ if (callbacks) {
36
+ const idx = callbacks.indexOf(callback);
37
+ if (idx > 0)
38
+ callbacks.splice(callbacks.indexOf(callback), 1);
39
+ }
40
+ }
41
+
42
+ const emit = (event: keyof LifecycleEvents<T>, arg0: T | T[], arg1: T = null) => {
43
+ if (observers.has(event)) {
44
+ setTimeout(() => {
45
+ observers.get(event).forEach(callback => {
46
+ if (adapter) {
47
+ const serialized0 = Array.isArray(arg0) ?
48
+ arg0.map(a => adapter.serialize(a)) : adapter.serialize(arg0);
49
+
50
+ const serialized1 = arg1 && adapter.serialize(arg1);
51
+
52
+ callback(serialized0 as A & A[], serialized1);
53
+ } else {
54
+ callback(arg0 as A & A[], arg1 as unknown as A);
55
+ }
56
+ });
57
+ }, 1);
58
+ }
59
+ }
60
+
61
+ const onIdleUpdate = () => {
62
+ const { selected } = selectionState;
63
+
64
+ // User idle after activity - fire update events for selected
65
+ // annotations that changed
66
+ const updatedSelected = selected.map(({ id }) => store.getAnnotation(id));
67
+
68
+ updatedSelected.forEach(updated => {
69
+ const initial = initialSelection.find(a => a.id === updated.id);
70
+ if (!initial || !dequal(initial, updated)) {
71
+ emit('updateAnnotation', updated, initial);
72
+ }
73
+ });
74
+
75
+ initialSelection = initialSelection.map(initial => {
76
+ const updated = updatedSelected.find(({ id }) => id === initial.id);
77
+ return updated ? updated : initial
78
+ });
79
+ }
80
+
81
+ selectionState.subscribe(({ selected })=> {
82
+ if (initialSelection.length === 0 && selected.length === 0)
83
+ return;
84
+
85
+ if (initialSelection.length === 0 && selected.length > 0) {
86
+ // A new selection was made - store the editable annotation as initial state
87
+ initialSelection = selected.map(({ id }) => store.getAnnotation(id));
88
+ } else if (initialSelection.length > 0 && selected.length === 0) {
89
+ // Deselect!
90
+ initialSelection.forEach(initial => {
91
+ const updatedState = store.getAnnotation(initial.id);
92
+
93
+ if (updatedState && !dequal(updatedState, initial)) {
94
+ emit('updateAnnotation', updatedState, initial);
95
+ }
96
+ });
97
+
98
+ initialSelection = [];
99
+ } else {
100
+ // Changed selection
101
+ const initialIds = new Set(initialSelection.map(a => a.id));
102
+ const selectedIds = new Set(selected.map(({ id }) => id));
103
+
104
+ // Fire update events for deselected annotations that have changed
105
+ const deselected = initialSelection.filter(a => !selectedIds.has(a.id));
106
+ deselected.forEach(initial => {
107
+ const updatedState = store.getAnnotation(initial.id);
108
+
109
+ if (updatedState && !dequal(updatedState, initial))
110
+ emit('updateAnnotation', updatedState, initial);
111
+ });
112
+
113
+ initialSelection = [
114
+ // Remove annotations that were deselected
115
+ ...initialSelection.filter(a => selectedIds.has(a.id)),
116
+ // Add editable annotations that were selected
117
+ ...selected.filter(({ id }) => !initialIds.has(id))
118
+ .map(({ id }) => store.getAnnotation(id))
119
+ ];
120
+ }
121
+
122
+ emit('selectionChanged', initialSelection);
123
+ });
124
+
125
+ hoverState.subscribe(id => {
126
+ if (!currentHover && id) {
127
+ emit('mouseEnterAnnotation', store.getAnnotation(id));
128
+ } else if (currentHover && !id) {
129
+ emit('mouseLeaveAnnotation', store.getAnnotation(currentHover));
130
+ } else if (currentHover && id) {
131
+ emit('mouseLeaveAnnotation', store.getAnnotation(currentHover));
132
+ emit('mouseEnterAnnotation', store.getAnnotation(id));
133
+ }
134
+
135
+ currentHover = id;
136
+ });
137
+
138
+ viewportState?.subscribe(ids =>
139
+ emit('viewportIntersect', ids.map(store.getAnnotation)));
140
+
141
+ store.observe(event => {
142
+ // Idleness update trigger
143
+ if (idleTimeout)
144
+ clearTimeout(idleTimeout);
145
+
146
+ idleTimeout = setTimeout(onIdleUpdate, 1000);
147
+
148
+ // Local CREATE and DELETE events are applied immediately
149
+ const { created, deleted } = event.changes;
150
+ created.forEach(a => emit('createAnnotation', a));
151
+ deleted.forEach(a => emit('deleteAnnotation', a));
152
+
153
+ // Updates are only applied immediately if they involve body changes
154
+ const updatesWithBody = event.changes.updated.filter(u => [
155
+ ...(u.bodiesCreated || []),
156
+ ...(u.bodiesDeleted || []),
157
+ ...(u.bodiesUpdated || [])
158
+ ].length > 0);
159
+
160
+ // Emit an update with the new annototation and the stored initial state
161
+ updatesWithBody.forEach(({ oldValue, newValue }) => {
162
+ const initial = initialSelection.find(a => a.id === oldValue.id) || oldValue;
163
+
164
+ // Record the update as the new last known state
165
+ initialSelection = initialSelection
166
+ .map(a => a.id === oldValue.id ? newValue : a);
167
+
168
+ emit('updateAnnotation', newValue, initial);
169
+ });
170
+ }, { origin: Origin.LOCAL });
171
+
172
+ // Track remote changes - these should update the initial state
173
+ store.observe(event => {
174
+ if (initialSelection) {
175
+ const selectedIds = new Set(initialSelection.map(a => a.id));
176
+
177
+ const relevantUpdates = event.changes.updated
178
+ .filter(({ newValue }) => selectedIds.has(newValue.id))
179
+ .map(({ newValue }) => newValue);
180
+
181
+ if (relevantUpdates.length > 0) {
182
+ initialSelection = initialSelection.map(selected => {
183
+ const updated = relevantUpdates.find(updated => updated.id === selected.id);
184
+ return updated ? updated : selected;
185
+ })
186
+ }
187
+ }
188
+ }, { origin: Origin.REMOTE });
189
+
190
+ return { on, off, emit }
191
+
192
+ }
@@ -0,0 +1,19 @@
1
+ import type { Annotation } from '../model';
2
+
3
+ export interface LifecycleEvents<T extends unknown = Annotation> {
4
+
5
+ createAnnotation: (annotation: T) => void;
6
+
7
+ deleteAnnotation: (annotation: T) => void;
8
+
9
+ mouseEnterAnnotation: (annotation: T) => void;
10
+
11
+ mouseLeaveAnnotation: (annotation: T) => void;
12
+
13
+ selectionChanged: (annotation: T[]) => void;
14
+
15
+ updateAnnotation: (annotation: T, previous: T) => void;
16
+
17
+ viewportIntersect: (visible: T[]) => void;
18
+
19
+ }
@@ -0,0 +1,2 @@
1
+ export * from './Lifecycle';
2
+ export * from './LifecycleEvents';
@@ -0,0 +1,36 @@
1
+ import type { Annotation } from './core/Annotation';
2
+
3
+ export interface FormatAdapter<A extends Annotation, T extends unknown> {
4
+
5
+ parse(serialized: T): ParseResult<A>;
6
+
7
+ serialize(core: A): T;
8
+
9
+ }
10
+
11
+ export interface ParseResult<A extends Annotation> {
12
+
13
+ parsed?: A;
14
+
15
+ error?: Error;
16
+
17
+ }
18
+
19
+ export const serializeAll =
20
+ <A extends Annotation, T extends unknown>(adapter: FormatAdapter<A, T>) =>
21
+ (annotations: A[]) => annotations.map(a => adapter.serialize(a));
22
+
23
+ export const parseAll =
24
+ <A extends Annotation, T extends unknown>(adapter: FormatAdapter<A, T>) =>
25
+ (serialized: T[]) => serialized.reduce((result, next) => {
26
+ const { parsed, error } = adapter.parse(next);
27
+
28
+ return error ? {
29
+ parsed: result.parsed,
30
+ failed: [...result.failed, next ]
31
+ } : {
32
+ parsed: [...result.parsed, parsed ],
33
+ failed: result.failed
34
+ }
35
+ }, { parsed: [], failed: [] });
36
+
@@ -0,0 +1,57 @@
1
+ import type { User } from './User';
2
+
3
+ export interface Annotation {
4
+
5
+ id: string;
6
+
7
+ target: AnnotationTarget;
8
+
9
+ bodies: AnnotationBody[];
10
+
11
+ properties?: {
12
+
13
+ [key: string]: any;
14
+
15
+ }
16
+
17
+ }
18
+
19
+ export interface AnnotationTarget {
20
+
21
+ annotation: string;
22
+
23
+ selector: AbstractSelector;
24
+
25
+ creator?: User;
26
+
27
+ created?: Date;
28
+
29
+ updatedBy?: User;
30
+
31
+ updated?: Date;
32
+
33
+ }
34
+
35
+ export interface AbstractSelector { }
36
+
37
+ export interface AnnotationBody {
38
+
39
+ id: string;
40
+
41
+ annotation: string;
42
+
43
+ type?: string;
44
+
45
+ purpose?: string;
46
+
47
+ value: string;
48
+
49
+ creator?: User;
50
+
51
+ created?: Date;
52
+
53
+ updatedBy?: User;
54
+
55
+ updated?: Date;
56
+
57
+ }
@@ -0,0 +1,47 @@
1
+ import type { Annotation } from './Annotation';
2
+ import type { User } from './User';
3
+ import type { PresenceProvider } from '../../presence';
4
+ import type { HoverState, SelectionState, Store, ViewportState } from '../../state';
5
+ import type { LifecycleEvents } from '../../lifecycle';
6
+ import type { W3CAnnotation } from '../w3c';
7
+ import type { Formatter } from './Formatter';
8
+
9
+ export interface Annotator<A extends Annotation = Annotation, T extends unknown = W3CAnnotation> {
10
+
11
+ addAnnotation(annotation: T): void;
12
+
13
+ getAnnotationById(id: string): T | undefined;
14
+
15
+ getAnnotations(): T[];
16
+
17
+ getUser(): User;
18
+
19
+ loadAnnotations(url: string): Promise<T[]>;
20
+
21
+ setAnnotations(annotations: T[]): void;
22
+
23
+ setFormatter(formatter: Formatter): void;
24
+
25
+ setUser(user: User): void;
26
+
27
+ setPresenceProvider?(provider: PresenceProvider): void;
28
+
29
+ on<E extends keyof LifecycleEvents<T>>(event: E, callback: LifecycleEvents<T>[E]): void;
30
+
31
+ off<E extends keyof LifecycleEvents<T>>(event: E, callback: LifecycleEvents<T>[E]): void;
32
+
33
+ state: AnnotatorState<A>;
34
+
35
+ }
36
+
37
+ export interface AnnotatorState<A extends Annotation> {
38
+
39
+ store: Store<A>;
40
+
41
+ selection: SelectionState<A>;
42
+
43
+ hover: HoverState<A>;
44
+
45
+ viewport?: ViewportState;
46
+
47
+ }
@@ -0,0 +1,19 @@
1
+ import type { Annotation } from './Annotation';
2
+
3
+ type RGB = `rgb(${number}, ${number}, ${number})`;
4
+
5
+ type RGBA = `rgba(${number}, ${number}, ${number}, ${number})`;
6
+
7
+ type HEX = `#${string}`;
8
+
9
+ export type Color = RGB | RGBA | HEX;
10
+
11
+ export interface DrawingStyle {
12
+
13
+ fill?: Color;
14
+
15
+ fillOpacity?: number;
16
+
17
+ }
18
+
19
+ export type Formatter = <T extends Annotation = Annotation>(annotation: T, isSelected?: boolean) => DrawingStyle;
@@ -0,0 +1,19 @@
1
+ import { customAlphabet } from 'nanoid';
2
+
3
+ export interface User {
4
+
5
+ id: string;
6
+
7
+ isGuest?: boolean;
8
+
9
+ name?: string;
10
+
11
+ avatar?: string;
12
+
13
+ }
14
+
15
+ export const createAnonymousGuest = () => {
16
+ const nanoid = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_', 20);
17
+
18
+ return { isGuest: true, id: nanoid() }
19
+ }
@@ -0,0 +1,4 @@
1
+ export * from './Annotation';
2
+ export * from './Annotator';
3
+ export * from './Formatter';
4
+ export * from './User';
@@ -0,0 +1,3 @@
1
+ export * from './core';
2
+ export * from './w3c';
3
+ export * from './FormatAdapter';
@@ -0,0 +1,73 @@
1
+ import { v4 as uuidv4 } from 'uuid';
2
+ import type { AnnotationBody } from '../core';
3
+
4
+ export interface W3CAnnotation {
5
+
6
+ '@context': 'http://www.w3.org/ns/anno.jsonld';
7
+
8
+ type: 'Annotation';
9
+
10
+ id: string;
11
+
12
+ body: W3CAnnotationBody | W3CAnnotationBody[]
13
+
14
+ target: W3CAnnotationTarget | W3CAnnotationTarget[];
15
+
16
+ [key: string]: any;
17
+
18
+ }
19
+
20
+ export interface W3CAnnotationBody {
21
+
22
+ type?: string;
23
+
24
+ purpose?: string;
25
+
26
+ value?: string;
27
+
28
+ created?: Date;
29
+
30
+ creator?: {
31
+
32
+ id: string;
33
+
34
+ name?: string;
35
+
36
+ };
37
+
38
+ }
39
+
40
+ export interface W3CAnnotationTarget {
41
+
42
+ source: string;
43
+
44
+ selector?: W3CSelector | W3CSelector[];
45
+
46
+ }
47
+
48
+ export interface W3CSelector {
49
+
50
+ type: string;
51
+
52
+ conformsTo?: string;
53
+
54
+ value: string;
55
+ }
56
+
57
+ /**
58
+ * Helper to crosswalk the W3C annotation body to a list of core AnnotationBody objects.
59
+ */
60
+ export const parseBodies = (
61
+ body: W3CAnnotationBody | W3CAnnotationBody[],
62
+ annotationId: string
63
+ ): AnnotationBody[] => (Array.isArray(body) ? body : [body]).map(b => ({
64
+ id: uuidv4(),
65
+ annotation: annotationId,
66
+ type: b.type,
67
+ purpose: b.purpose,
68
+ value: b.value,
69
+ created: b.created,
70
+ creator: b.creator ? { ...b.creator } : undefined
71
+ }));
72
+
73
+
@@ -0,0 +1 @@
1
+ export * from './W3CAnnotation';
@@ -0,0 +1,9 @@
1
+ export interface Appearance {
2
+
3
+ label: string;
4
+
5
+ color: string;
6
+
7
+ avatar?: string;
8
+
9
+ }
@@ -0,0 +1,53 @@
1
+ import type { User } from '../model';
2
+ import type { Appearance } from './Appearance';
3
+ import type { PresentUser } from './PresentUser';
4
+ import { DEFAULT_PALETTE } from './ColorPalette';
5
+
6
+ export interface AppearanceProvider {
7
+
8
+ addUser(presenceKey: string, user: User): Appearance;
9
+
10
+ removeUser(user: PresentUser): void;
11
+
12
+ }
13
+
14
+ export const defaultColorProvider = () => {
15
+
16
+ const unassignedColors = [...DEFAULT_PALETTE];
17
+
18
+ const assignRandomColor = () => {
19
+ const rnd = Math.floor(Math.random() * unassignedColors.length);
20
+ const color = unassignedColors[rnd];
21
+
22
+ unassignedColors.splice(rnd, 1);
23
+
24
+ return color;
25
+ }
26
+
27
+ const releaseColor = (color: string) =>
28
+ unassignedColors.push(color);
29
+
30
+ return { assignRandomColor, releaseColor };
31
+
32
+ }
33
+
34
+ export const createDefaultAppearenceProvider = () => {
35
+
36
+ const colorProvider = defaultColorProvider();
37
+
38
+ const addUser = (presenceKey: string, user: User): Appearance => {
39
+ const color = colorProvider.assignRandomColor();
40
+
41
+ return {
42
+ label: user.name || user.id,
43
+ avatar: user.avatar,
44
+ color
45
+ };
46
+ }
47
+
48
+ const removeUser = (user: PresentUser) =>
49
+ colorProvider.releaseColor(user.appearance.color);
50
+
51
+ return { addUser, removeUser }
52
+
53
+ }
@@ -0,0 +1,14 @@
1
+ // SEABORN_BRIGHT
2
+ export const DEFAULT_PALETTE: Palette = [
3
+ '#ff7c00', // orange
4
+ '#1ac938', // green
5
+ '#e8000b', // red
6
+ '#8b2be2', // purple
7
+ '#9f4800', // brown
8
+ '#f14cc1', // pink
9
+ '#ffc400', // khaki
10
+ '#00d7ff', // cyan
11
+ '#023eff' // blue
12
+ ];
13
+
14
+ export type Palette = string[];
@@ -0,0 +1,9 @@
1
+ import type { PresentUser } from './PresentUser';
2
+
3
+ export interface PresenceEvents {
4
+
5
+ presence: (users: PresentUser[]) => void;
6
+
7
+ selectionChange: (from: PresentUser, selection: string[] | null) => void;
8
+
9
+ }
@@ -0,0 +1,7 @@
1
+ import type { PresenceEvents } from './PresenceEvents';
2
+
3
+ export interface PresenceProvider {
4
+
5
+ on<E extends keyof PresenceEvents>(event: E, callback: PresenceEvents[E]): void;
6
+
7
+ }