@tbib-image-viewer/core 0.0.0-stage → 0.1.1

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 CHANGED
@@ -1,3 +1,24 @@
1
- # Temporary Holding Version
1
+ # @tbib-image-viewer/core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Framework-agnostic coordinate math, annotation engine, stroke smoothing, and native-res canvas export for TBiB Image Viewer.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @tbib-image-viewer/core
9
+ ```
10
+
11
+ ## Features
12
+
13
+ - **Normalized Image Transforms**: Point conversions between screen pixels and normalized image coordinate space `[0..1]`.
14
+ - **Stroke Smoothing**: Bézier quadratic interpolation for anti-aliased freehand drawing.
15
+ - **Annotation Store**: Per-slide history management with undo/redo stacks and JSON serialization.
16
+ - **Native-Res Offscreen Exporter**: High-resolution canvas rendering that burns annotations onto images at original image dimensions without quality loss.
17
+
18
+ ## Documentation
19
+
20
+ See main repository: [https://github.com/itzHamza/tbib-image-viewer](https://github.com/itzHamza/tbib-image-viewer)
21
+
22
+ ## License
23
+
24
+ MIT © [TBiB Space](https://tbib.space)
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Normalized 2D point on an image.
3
+ * x: 0 (left edge) to 1 (right edge)
4
+ * y: 0 (top edge) to 1 (bottom edge)
5
+ */
6
+ interface NormalizedPoint {
7
+ x: number;
8
+ y: number;
9
+ pressure?: number;
10
+ }
11
+ /**
12
+ * Pixel coordinate point relative to the rendered canvas or screen.
13
+ */
14
+ interface ScreenPoint {
15
+ x: number;
16
+ y: number;
17
+ }
18
+ type DrawTool = "pen" | "highlighter" | "laser" | "eraser";
19
+ type InteractionMode = "hand" | DrawTool;
20
+ interface AnnotationStroke {
21
+ id: string;
22
+ tool: "pen" | "highlighter";
23
+ color: string;
24
+ /** Stroke width normalized relative to the image max dimension, or in base image px */
25
+ width: number;
26
+ opacity: number;
27
+ points: NormalizedPoint[];
28
+ createdAt: number;
29
+ }
30
+ interface ImageDimensions {
31
+ width: number;
32
+ height: number;
33
+ }
34
+ interface TransformState {
35
+ scale: number;
36
+ translateX: number;
37
+ translateY: number;
38
+ rotate: number;
39
+ }
40
+ interface SlideAnnotations {
41
+ slideId: string;
42
+ strokes: AnnotationStroke[];
43
+ }
44
+ type ExportFormat = "image/png" | "image/jpeg" | "image/webp";
45
+ interface ExportOptions {
46
+ format?: ExportFormat;
47
+ quality?: number;
48
+ includeAnnotations?: boolean;
49
+ fileName?: string;
50
+ backgroundColor?: string;
51
+ }
52
+ interface GalleryItem {
53
+ id: string;
54
+ src: string;
55
+ alt?: string;
56
+ title?: string;
57
+ description?: string;
58
+ thumbnailSrc?: string;
59
+ width?: number;
60
+ height?: number;
61
+ }
62
+
63
+ declare const DEFAULT_TRANSFORM: TransformState;
64
+ declare const MIN_SCALE = 0.5;
65
+ declare const MAX_SCALE = 8;
66
+ /**
67
+ * Clamps scale between min and max allowed bounds.
68
+ */
69
+ declare function clampScale(scale: number, min?: number, max?: number): number;
70
+ /**
71
+ * Converts a screen/viewport coordinate (e.g. mouse or touch event)
72
+ * into a normalized image coordinate (0 to 1) based on the image bounding box
73
+ * and current scale/pan/rotation transform.
74
+ */
75
+ declare function screenToNormalizedImage(screenPt: ScreenPoint, containerRect: DOMRect, imageDimensions: ImageDimensions, transform: TransformState): NormalizedPoint | null;
76
+ /**
77
+ * Converts a normalized image coordinate (0 to 1) to screen/pixel coordinates
78
+ * inside the given container.
79
+ */
80
+ declare function normalizedImageToScreen(normPt: NormalizedPoint, containerRect: DOMRect, imageDimensions: ImageDimensions, transform: TransformState): ScreenPoint;
81
+ /**
82
+ * Computes fitted size of an image inside container maintaining aspect ratio.
83
+ */
84
+ declare function getFittedImageBounds(containerW: number, containerH: number, imgW: number, imgH: number): {
85
+ width: number;
86
+ height: number;
87
+ };
88
+
89
+ /**
90
+ * Converts a list of normalized points into a smooth SVG path definition
91
+ * using quadratic Bézier midpoint interpolation.
92
+ */
93
+ declare function pointsToSvgPath(points: NormalizedPoint[], width: number, height: number): string;
94
+ /**
95
+ * Calculates squared Euclidean distance between two points in normalized coordinates.
96
+ */
97
+ declare function distanceSq(p1: NormalizedPoint, p2: NormalizedPoint): number;
98
+ /**
99
+ * Checks if a test point in normalized coordinates is close to any point in the stroke
100
+ * within an interaction threshold (used for eraser hit detection).
101
+ */
102
+ declare function isPointNearStroke(testPt: NormalizedPoint, points: NormalizedPoint[], thresholdNorm: number): boolean;
103
+ /**
104
+ * Converts hex color (#rrggbb or #rgb) to an rgba string with alpha.
105
+ */
106
+ declare function hexToRgba(hex: string, alpha?: number): string;
107
+
108
+ interface HistoryState {
109
+ past: AnnotationStroke[][];
110
+ present: AnnotationStroke[];
111
+ future: AnnotationStroke[][];
112
+ }
113
+ declare class AnnotationStore {
114
+ private slides;
115
+ private listeners;
116
+ constructor(initialData?: SlideAnnotations[]);
117
+ private getOrCreate;
118
+ getStrokes(slideId: string): AnnotationStroke[];
119
+ canUndo(slideId: string): boolean;
120
+ canRedo(slideId: string): boolean;
121
+ addStroke(slideId: string, stroke: AnnotationStroke): void;
122
+ removeStroke(slideId: string, strokeId: string): void;
123
+ removeStrokesNear(slideId: string, testPt: {
124
+ x: number;
125
+ y: number;
126
+ }, thresholdNorm: number, isPointNearStrokeFn: (pt: {
127
+ x: number;
128
+ y: number;
129
+ }, pts: {
130
+ x: number;
131
+ y: number;
132
+ }[], thresh: number) => boolean): boolean;
133
+ clear(slideId: string): void;
134
+ undo(slideId: string): boolean;
135
+ redo(slideId: string): boolean;
136
+ subscribe(listener: (slideId: string, strokes: AnnotationStroke[]) => void): () => void;
137
+ private notify;
138
+ exportJSON(): SlideAnnotations[];
139
+ importJSON(data: SlideAnnotations[]): void;
140
+ }
141
+
142
+ /**
143
+ * Loads an image from a URL or source element and renders it
144
+ * along with its annotations onto an off-screen HTMLCanvasElement
145
+ * at original native resolution.
146
+ */
147
+ declare function renderToOffscreenCanvas(imageSource: string | HTMLImageElement, strokes: AnnotationStroke[], options?: ExportOptions): Promise<HTMLCanvasElement>;
148
+ /**
149
+ * Triggers a browser download of the image (with or without annotations).
150
+ */
151
+ declare function downloadAnnotatedImage(imageSource: string | HTMLImageElement, strokes: AnnotationStroke[], options?: ExportOptions): Promise<void>;
152
+
153
+ export { AnnotationStore, type AnnotationStroke, DEFAULT_TRANSFORM, type DrawTool, type ExportFormat, type ExportOptions, type GalleryItem, type HistoryState, type ImageDimensions, type InteractionMode, MAX_SCALE, MIN_SCALE, type NormalizedPoint, type ScreenPoint, type SlideAnnotations, type TransformState, clampScale, distanceSq, downloadAnnotatedImage, getFittedImageBounds, hexToRgba, isPointNearStroke, normalizedImageToScreen, pointsToSvgPath, renderToOffscreenCanvas, screenToNormalizedImage };
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Normalized 2D point on an image.
3
+ * x: 0 (left edge) to 1 (right edge)
4
+ * y: 0 (top edge) to 1 (bottom edge)
5
+ */
6
+ interface NormalizedPoint {
7
+ x: number;
8
+ y: number;
9
+ pressure?: number;
10
+ }
11
+ /**
12
+ * Pixel coordinate point relative to the rendered canvas or screen.
13
+ */
14
+ interface ScreenPoint {
15
+ x: number;
16
+ y: number;
17
+ }
18
+ type DrawTool = "pen" | "highlighter" | "laser" | "eraser";
19
+ type InteractionMode = "hand" | DrawTool;
20
+ interface AnnotationStroke {
21
+ id: string;
22
+ tool: "pen" | "highlighter";
23
+ color: string;
24
+ /** Stroke width normalized relative to the image max dimension, or in base image px */
25
+ width: number;
26
+ opacity: number;
27
+ points: NormalizedPoint[];
28
+ createdAt: number;
29
+ }
30
+ interface ImageDimensions {
31
+ width: number;
32
+ height: number;
33
+ }
34
+ interface TransformState {
35
+ scale: number;
36
+ translateX: number;
37
+ translateY: number;
38
+ rotate: number;
39
+ }
40
+ interface SlideAnnotations {
41
+ slideId: string;
42
+ strokes: AnnotationStroke[];
43
+ }
44
+ type ExportFormat = "image/png" | "image/jpeg" | "image/webp";
45
+ interface ExportOptions {
46
+ format?: ExportFormat;
47
+ quality?: number;
48
+ includeAnnotations?: boolean;
49
+ fileName?: string;
50
+ backgroundColor?: string;
51
+ }
52
+ interface GalleryItem {
53
+ id: string;
54
+ src: string;
55
+ alt?: string;
56
+ title?: string;
57
+ description?: string;
58
+ thumbnailSrc?: string;
59
+ width?: number;
60
+ height?: number;
61
+ }
62
+
63
+ declare const DEFAULT_TRANSFORM: TransformState;
64
+ declare const MIN_SCALE = 0.5;
65
+ declare const MAX_SCALE = 8;
66
+ /**
67
+ * Clamps scale between min and max allowed bounds.
68
+ */
69
+ declare function clampScale(scale: number, min?: number, max?: number): number;
70
+ /**
71
+ * Converts a screen/viewport coordinate (e.g. mouse or touch event)
72
+ * into a normalized image coordinate (0 to 1) based on the image bounding box
73
+ * and current scale/pan/rotation transform.
74
+ */
75
+ declare function screenToNormalizedImage(screenPt: ScreenPoint, containerRect: DOMRect, imageDimensions: ImageDimensions, transform: TransformState): NormalizedPoint | null;
76
+ /**
77
+ * Converts a normalized image coordinate (0 to 1) to screen/pixel coordinates
78
+ * inside the given container.
79
+ */
80
+ declare function normalizedImageToScreen(normPt: NormalizedPoint, containerRect: DOMRect, imageDimensions: ImageDimensions, transform: TransformState): ScreenPoint;
81
+ /**
82
+ * Computes fitted size of an image inside container maintaining aspect ratio.
83
+ */
84
+ declare function getFittedImageBounds(containerW: number, containerH: number, imgW: number, imgH: number): {
85
+ width: number;
86
+ height: number;
87
+ };
88
+
89
+ /**
90
+ * Converts a list of normalized points into a smooth SVG path definition
91
+ * using quadratic Bézier midpoint interpolation.
92
+ */
93
+ declare function pointsToSvgPath(points: NormalizedPoint[], width: number, height: number): string;
94
+ /**
95
+ * Calculates squared Euclidean distance between two points in normalized coordinates.
96
+ */
97
+ declare function distanceSq(p1: NormalizedPoint, p2: NormalizedPoint): number;
98
+ /**
99
+ * Checks if a test point in normalized coordinates is close to any point in the stroke
100
+ * within an interaction threshold (used for eraser hit detection).
101
+ */
102
+ declare function isPointNearStroke(testPt: NormalizedPoint, points: NormalizedPoint[], thresholdNorm: number): boolean;
103
+ /**
104
+ * Converts hex color (#rrggbb or #rgb) to an rgba string with alpha.
105
+ */
106
+ declare function hexToRgba(hex: string, alpha?: number): string;
107
+
108
+ interface HistoryState {
109
+ past: AnnotationStroke[][];
110
+ present: AnnotationStroke[];
111
+ future: AnnotationStroke[][];
112
+ }
113
+ declare class AnnotationStore {
114
+ private slides;
115
+ private listeners;
116
+ constructor(initialData?: SlideAnnotations[]);
117
+ private getOrCreate;
118
+ getStrokes(slideId: string): AnnotationStroke[];
119
+ canUndo(slideId: string): boolean;
120
+ canRedo(slideId: string): boolean;
121
+ addStroke(slideId: string, stroke: AnnotationStroke): void;
122
+ removeStroke(slideId: string, strokeId: string): void;
123
+ removeStrokesNear(slideId: string, testPt: {
124
+ x: number;
125
+ y: number;
126
+ }, thresholdNorm: number, isPointNearStrokeFn: (pt: {
127
+ x: number;
128
+ y: number;
129
+ }, pts: {
130
+ x: number;
131
+ y: number;
132
+ }[], thresh: number) => boolean): boolean;
133
+ clear(slideId: string): void;
134
+ undo(slideId: string): boolean;
135
+ redo(slideId: string): boolean;
136
+ subscribe(listener: (slideId: string, strokes: AnnotationStroke[]) => void): () => void;
137
+ private notify;
138
+ exportJSON(): SlideAnnotations[];
139
+ importJSON(data: SlideAnnotations[]): void;
140
+ }
141
+
142
+ /**
143
+ * Loads an image from a URL or source element and renders it
144
+ * along with its annotations onto an off-screen HTMLCanvasElement
145
+ * at original native resolution.
146
+ */
147
+ declare function renderToOffscreenCanvas(imageSource: string | HTMLImageElement, strokes: AnnotationStroke[], options?: ExportOptions): Promise<HTMLCanvasElement>;
148
+ /**
149
+ * Triggers a browser download of the image (with or without annotations).
150
+ */
151
+ declare function downloadAnnotatedImage(imageSource: string | HTMLImageElement, strokes: AnnotationStroke[], options?: ExportOptions): Promise<void>;
152
+
153
+ export { AnnotationStore, type AnnotationStroke, DEFAULT_TRANSFORM, type DrawTool, type ExportFormat, type ExportOptions, type GalleryItem, type HistoryState, type ImageDimensions, type InteractionMode, MAX_SCALE, MIN_SCALE, type NormalizedPoint, type ScreenPoint, type SlideAnnotations, type TransformState, clampScale, distanceSq, downloadAnnotatedImage, getFittedImageBounds, hexToRgba, isPointNearStroke, normalizedImageToScreen, pointsToSvgPath, renderToOffscreenCanvas, screenToNormalizedImage };
package/dist/index.js ADDED
@@ -0,0 +1,407 @@
1
+ 'use strict';
2
+
3
+ // src/engine/transform.ts
4
+ var DEFAULT_TRANSFORM = {
5
+ scale: 1,
6
+ translateX: 0,
7
+ translateY: 0,
8
+ rotate: 0
9
+ };
10
+ var MIN_SCALE = 0.5;
11
+ var MAX_SCALE = 8;
12
+ function clampScale(scale, min = MIN_SCALE, max = MAX_SCALE) {
13
+ return Math.min(Math.max(scale, min), max);
14
+ }
15
+ function screenToNormalizedImage(screenPt, containerRect, imageDimensions, transform) {
16
+ if (imageDimensions.width <= 0 || imageDimensions.height <= 0) return null;
17
+ const centerX = containerRect.left + containerRect.width / 2;
18
+ const centerY = containerRect.top + containerRect.height / 2;
19
+ let dx = screenPt.x - centerX - transform.translateX;
20
+ let dy = screenPt.y - centerY - transform.translateY;
21
+ const rad = -transform.rotate * Math.PI / 180;
22
+ const cos = Math.cos(rad);
23
+ const sin = Math.sin(rad);
24
+ const unrotatedX = dx * cos - dy * sin;
25
+ const unrotatedY = dx * sin + dy * cos;
26
+ const scaledX = unrotatedX / transform.scale;
27
+ const scaledY = unrotatedY / transform.scale;
28
+ const aspect = imageDimensions.width / imageDimensions.height;
29
+ const containerAspect = containerRect.width / containerRect.height;
30
+ let fittedW;
31
+ let fittedH;
32
+ if (containerAspect > aspect) {
33
+ fittedH = containerRect.height;
34
+ fittedW = fittedH * aspect;
35
+ } else {
36
+ fittedW = containerRect.width;
37
+ fittedH = fittedW / aspect;
38
+ }
39
+ const normX = (scaledX + fittedW / 2) / fittedW;
40
+ const normY = (scaledY + fittedH / 2) / fittedH;
41
+ return {
42
+ x: Math.max(0, Math.min(1, normX)),
43
+ y: Math.max(0, Math.min(1, normY))
44
+ };
45
+ }
46
+ function normalizedImageToScreen(normPt, containerRect, imageDimensions, transform) {
47
+ const aspect = imageDimensions.width / imageDimensions.height;
48
+ const containerAspect = containerRect.width / containerRect.height;
49
+ let fittedW;
50
+ let fittedH;
51
+ if (containerAspect > aspect) {
52
+ fittedH = containerRect.height;
53
+ fittedW = fittedH * aspect;
54
+ } else {
55
+ fittedW = containerRect.width;
56
+ fittedH = fittedW / aspect;
57
+ }
58
+ const localX = normPt.x * fittedW - fittedW / 2;
59
+ const localY = normPt.y * fittedH - fittedH / 2;
60
+ const scaledX = localX * transform.scale;
61
+ const scaledY = localY * transform.scale;
62
+ const rad = transform.rotate * Math.PI / 180;
63
+ const cos = Math.cos(rad);
64
+ const sin = Math.sin(rad);
65
+ const rotatedX = scaledX * cos - scaledY * sin;
66
+ const rotatedY = scaledX * sin + scaledY * cos;
67
+ const centerX = containerRect.left + containerRect.width / 2;
68
+ const centerY = containerRect.top + containerRect.height / 2;
69
+ return {
70
+ x: centerX + transform.translateX + rotatedX,
71
+ y: centerY + transform.translateY + rotatedY
72
+ };
73
+ }
74
+ function getFittedImageBounds(containerW, containerH, imgW, imgH) {
75
+ if (imgW <= 0 || imgH <= 0 || containerW <= 0 || containerH <= 0) {
76
+ return { width: 0, height: 0 };
77
+ }
78
+ const aspect = imgW / imgH;
79
+ const containerAspect = containerW / containerH;
80
+ if (containerAspect > aspect) {
81
+ const height = containerH;
82
+ return { width: height * aspect, height };
83
+ } else {
84
+ const width = containerW;
85
+ return { width, height: width / aspect };
86
+ }
87
+ }
88
+
89
+ // src/annotations/smooth.ts
90
+ function pointsToSvgPath(points, width, height) {
91
+ if (points.length === 0) return "";
92
+ if (points.length === 1) {
93
+ const p = points[0];
94
+ const px = p.x * width;
95
+ const py = p.y * height;
96
+ return `M ${px} ${py} L ${px + 0.1} ${py + 0.1}`;
97
+ }
98
+ const p0 = points[0];
99
+ let d = `M ${p0.x * width} ${p0.y * height}`;
100
+ for (let i = 1; i < points.length - 1; i++) {
101
+ const current = points[i];
102
+ const next = points[i + 1];
103
+ const cx = (current.x * width + next.x * width) / 2;
104
+ const cy = (current.y * height + next.y * height) / 2;
105
+ d += ` Q ${current.x * width} ${current.y * height}, ${cx} ${cy}`;
106
+ }
107
+ const last = points[points.length - 1];
108
+ d += ` L ${last.x * width} ${last.y * height}`;
109
+ return d;
110
+ }
111
+ function distanceSq(p1, p2) {
112
+ const dx = p1.x - p2.x;
113
+ const dy = p1.y - p2.y;
114
+ return dx * dx + dy * dy;
115
+ }
116
+ function isPointNearStroke(testPt, points, thresholdNorm) {
117
+ const thresholdSq = thresholdNorm * thresholdNorm;
118
+ for (let i = 0; i < points.length; i++) {
119
+ if (distanceSq(testPt, points[i]) <= thresholdSq) {
120
+ return true;
121
+ }
122
+ }
123
+ return false;
124
+ }
125
+ function hexToRgba(hex, alpha = 1) {
126
+ if (hex.startsWith("rgba")) return hex;
127
+ const clean = hex.trim().replace("#", "");
128
+ if (clean.length === 3) {
129
+ const r = parseInt(clean[0] + clean[0], 16);
130
+ const g = parseInt(clean[1] + clean[1], 16);
131
+ const b = parseInt(clean[2] + clean[2], 16);
132
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
133
+ }
134
+ if (clean.length === 6) {
135
+ const r = parseInt(clean.substring(0, 2), 16);
136
+ const g = parseInt(clean.substring(2, 4), 16);
137
+ const b = parseInt(clean.substring(4, 6), 16);
138
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
139
+ }
140
+ return hex;
141
+ }
142
+
143
+ // src/annotations/store.ts
144
+ var AnnotationStore = class {
145
+ slides = /* @__PURE__ */ new Map();
146
+ listeners = /* @__PURE__ */ new Set();
147
+ constructor(initialData) {
148
+ if (initialData) {
149
+ for (const item of initialData) {
150
+ this.slides.set(item.slideId, {
151
+ past: [],
152
+ present: [...item.strokes],
153
+ future: []
154
+ });
155
+ }
156
+ }
157
+ }
158
+ getOrCreate(slideId) {
159
+ let state = this.slides.get(slideId);
160
+ if (!state) {
161
+ state = {
162
+ past: [],
163
+ present: [],
164
+ future: []
165
+ };
166
+ this.slides.set(slideId, state);
167
+ }
168
+ return state;
169
+ }
170
+ getStrokes(slideId) {
171
+ return this.getOrCreate(slideId).present;
172
+ }
173
+ canUndo(slideId) {
174
+ return this.getOrCreate(slideId).past.length > 0;
175
+ }
176
+ canRedo(slideId) {
177
+ return this.getOrCreate(slideId).future.length > 0;
178
+ }
179
+ addStroke(slideId, stroke) {
180
+ const state = this.getOrCreate(slideId);
181
+ state.past.push([...state.present]);
182
+ state.present = [...state.present, stroke];
183
+ state.future = [];
184
+ this.notify(slideId);
185
+ }
186
+ removeStroke(slideId, strokeId) {
187
+ const state = this.getOrCreate(slideId);
188
+ const filtered = state.present.filter((s) => s.id !== strokeId);
189
+ if (filtered.length !== state.present.length) {
190
+ state.past.push([...state.present]);
191
+ state.present = filtered;
192
+ state.future = [];
193
+ this.notify(slideId);
194
+ }
195
+ }
196
+ removeStrokesNear(slideId, testPt, thresholdNorm, isPointNearStrokeFn) {
197
+ const state = this.getOrCreate(slideId);
198
+ const toKeep = [];
199
+ let removedAny = false;
200
+ for (const stroke of state.present) {
201
+ if (isPointNearStrokeFn(testPt, stroke.points, thresholdNorm)) {
202
+ removedAny = true;
203
+ } else {
204
+ toKeep.push(stroke);
205
+ }
206
+ }
207
+ if (removedAny) {
208
+ state.past.push([...state.present]);
209
+ state.present = toKeep;
210
+ state.future = [];
211
+ this.notify(slideId);
212
+ return true;
213
+ }
214
+ return false;
215
+ }
216
+ clear(slideId) {
217
+ const state = this.getOrCreate(slideId);
218
+ if (state.present.length === 0) return;
219
+ state.past.push([...state.present]);
220
+ state.present = [];
221
+ state.future = [];
222
+ this.notify(slideId);
223
+ }
224
+ undo(slideId) {
225
+ const state = this.getOrCreate(slideId);
226
+ if (state.past.length === 0) return false;
227
+ const previous = state.past.pop();
228
+ state.future.unshift([...state.present]);
229
+ state.present = previous;
230
+ this.notify(slideId);
231
+ return true;
232
+ }
233
+ redo(slideId) {
234
+ const state = this.getOrCreate(slideId);
235
+ if (state.future.length === 0) return false;
236
+ const next = state.future.shift();
237
+ state.past.push([...state.present]);
238
+ state.present = next;
239
+ this.notify(slideId);
240
+ return true;
241
+ }
242
+ subscribe(listener) {
243
+ this.listeners.add(listener);
244
+ return () => {
245
+ this.listeners.delete(listener);
246
+ };
247
+ }
248
+ notify(slideId) {
249
+ const strokes = this.getStrokes(slideId);
250
+ for (const listener of this.listeners) {
251
+ listener(slideId, strokes);
252
+ }
253
+ }
254
+ exportJSON() {
255
+ const result = [];
256
+ for (const [slideId, state] of this.slides.entries()) {
257
+ if (state.present.length > 0) {
258
+ result.push({
259
+ slideId,
260
+ strokes: [...state.present]
261
+ });
262
+ }
263
+ }
264
+ return result;
265
+ }
266
+ importJSON(data) {
267
+ for (const item of data) {
268
+ this.slides.set(item.slideId, {
269
+ past: [],
270
+ present: [...item.strokes],
271
+ future: []
272
+ });
273
+ this.notify(item.slideId);
274
+ }
275
+ }
276
+ };
277
+
278
+ // src/export/canvas.ts
279
+ async function renderToOffscreenCanvas(imageSource, strokes, options = {}) {
280
+ const { includeAnnotations = true, backgroundColor } = options;
281
+ let img;
282
+ if (typeof imageSource === "string") {
283
+ img = new Image();
284
+ img.crossOrigin = "anonymous";
285
+ await new Promise((resolve, reject) => {
286
+ img.onload = () => resolve();
287
+ img.onerror = (e) => reject(new Error(`Failed to load image from source: ${imageSource}`));
288
+ img.src = imageSource;
289
+ });
290
+ } else {
291
+ img = imageSource;
292
+ }
293
+ const naturalWidth = img.naturalWidth || img.width;
294
+ const naturalHeight = img.naturalHeight || img.height;
295
+ if (naturalWidth === 0 || naturalHeight === 0) {
296
+ throw new Error("Invalid image dimensions: width or height is 0");
297
+ }
298
+ const canvas = document.createElement("canvas");
299
+ canvas.width = naturalWidth;
300
+ canvas.height = naturalHeight;
301
+ const ctx = canvas.getContext("2d");
302
+ if (!ctx) {
303
+ throw new Error("Unable to obtain 2D canvas context for export");
304
+ }
305
+ if (backgroundColor) {
306
+ ctx.fillStyle = backgroundColor;
307
+ ctx.fillRect(0, 0, naturalWidth, naturalHeight);
308
+ }
309
+ ctx.drawImage(img, 0, 0, naturalWidth, naturalHeight);
310
+ if (includeAnnotations && strokes.length > 0) {
311
+ for (const stroke of strokes) {
312
+ if (stroke.points.length === 0) continue;
313
+ ctx.save();
314
+ ctx.beginPath();
315
+ ctx.lineCap = "round";
316
+ ctx.lineJoin = "round";
317
+ const strokeScale = Math.max(naturalWidth, naturalHeight) / 1e3;
318
+ ctx.lineWidth = Math.max(1, stroke.width * strokeScale);
319
+ if (stroke.tool === "highlighter") {
320
+ ctx.globalAlpha = stroke.opacity || 0.35;
321
+ ctx.strokeStyle = hexToRgba(stroke.color, 1);
322
+ ctx.globalCompositeOperation = "multiply";
323
+ } else {
324
+ ctx.globalAlpha = stroke.opacity || 1;
325
+ ctx.strokeStyle = stroke.color;
326
+ ctx.globalCompositeOperation = "source-over";
327
+ }
328
+ const p0 = stroke.points[0];
329
+ const startX = p0.x * naturalWidth;
330
+ const startY = p0.y * naturalHeight;
331
+ ctx.moveTo(startX, startY);
332
+ if (stroke.points.length === 1) {
333
+ ctx.lineTo(startX + 0.5, startY + 0.5);
334
+ } else {
335
+ for (let i = 1; i < stroke.points.length - 1; i++) {
336
+ const pt = stroke.points[i];
337
+ const next = stroke.points[i + 1];
338
+ const cx = (pt.x * naturalWidth + next.x * naturalWidth) / 2;
339
+ const cy = (pt.y * naturalHeight + next.y * naturalHeight) / 2;
340
+ ctx.quadraticCurveTo(pt.x * naturalWidth, pt.y * naturalHeight, cx, cy);
341
+ }
342
+ const last = stroke.points[stroke.points.length - 1];
343
+ ctx.lineTo(last.x * naturalWidth, last.y * naturalHeight);
344
+ }
345
+ ctx.stroke();
346
+ ctx.restore();
347
+ }
348
+ }
349
+ return canvas;
350
+ }
351
+ async function downloadAnnotatedImage(imageSource, strokes, options = {}) {
352
+ const {
353
+ format = "image/png",
354
+ quality = 0.95,
355
+ fileName = `tbib-annotated-image-${Date.now()}.${format === "image/jpeg" ? "jpg" : format === "image/webp" ? "webp" : "png"}`
356
+ } = options;
357
+ try {
358
+ const canvas = await renderToOffscreenCanvas(imageSource, strokes, options);
359
+ return new Promise((resolve, reject) => {
360
+ canvas.toBlob(
361
+ (blob) => {
362
+ if (!blob) {
363
+ reject(new Error("Failed to generate image blob from canvas"));
364
+ return;
365
+ }
366
+ const url = URL.createObjectURL(blob);
367
+ const link = document.createElement("a");
368
+ link.href = url;
369
+ link.download = fileName;
370
+ document.body.appendChild(link);
371
+ link.click();
372
+ document.body.removeChild(link);
373
+ setTimeout(() => URL.revokeObjectURL(url), 1e3);
374
+ resolve();
375
+ },
376
+ format,
377
+ quality
378
+ );
379
+ });
380
+ } catch (err) {
381
+ console.warn("Offscreen canvas render encountered an issue, falling back to direct asset download:", err);
382
+ const src = typeof imageSource === "string" ? imageSource : imageSource.src;
383
+ const link = document.createElement("a");
384
+ link.href = src;
385
+ link.download = fileName;
386
+ document.body.appendChild(link);
387
+ link.click();
388
+ document.body.removeChild(link);
389
+ }
390
+ }
391
+
392
+ exports.AnnotationStore = AnnotationStore;
393
+ exports.DEFAULT_TRANSFORM = DEFAULT_TRANSFORM;
394
+ exports.MAX_SCALE = MAX_SCALE;
395
+ exports.MIN_SCALE = MIN_SCALE;
396
+ exports.clampScale = clampScale;
397
+ exports.distanceSq = distanceSq;
398
+ exports.downloadAnnotatedImage = downloadAnnotatedImage;
399
+ exports.getFittedImageBounds = getFittedImageBounds;
400
+ exports.hexToRgba = hexToRgba;
401
+ exports.isPointNearStroke = isPointNearStroke;
402
+ exports.normalizedImageToScreen = normalizedImageToScreen;
403
+ exports.pointsToSvgPath = pointsToSvgPath;
404
+ exports.renderToOffscreenCanvas = renderToOffscreenCanvas;
405
+ exports.screenToNormalizedImage = screenToNormalizedImage;
406
+ //# sourceMappingURL=index.js.map
407
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/engine/transform.ts","../src/annotations/smooth.ts","../src/annotations/store.ts","../src/export/canvas.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import type { ImageDimensions, NormalizedPoint, ScreenPoint, TransformState } from \"../types\";\n\nexport const DEFAULT_TRANSFORM: TransformState = {\n scale: 1,\n translateX: 0,\n translateY: 0,\n rotate: 0,\n};\n\nexport const MIN_SCALE = 0.5;\nexport const MAX_SCALE = 8.0;\n\n/**\n * Clamps scale between min and max allowed bounds.\n */\nexport function clampScale(scale: number, min = MIN_SCALE, max = MAX_SCALE): number {\n return Math.min(Math.max(scale, min), max);\n}\n\n/**\n * Converts a screen/viewport coordinate (e.g. mouse or touch event)\n * into a normalized image coordinate (0 to 1) based on the image bounding box\n * and current scale/pan/rotation transform.\n */\nexport function screenToNormalizedImage(\n screenPt: ScreenPoint,\n containerRect: DOMRect,\n imageDimensions: ImageDimensions,\n transform: TransformState\n): NormalizedPoint | null {\n if (imageDimensions.width <= 0 || imageDimensions.height <= 0) return null;\n\n // 1. Point relative to container center\n const centerX = containerRect.left + containerRect.width / 2;\n const centerY = containerRect.top + containerRect.height / 2;\n\n let dx = screenPt.x - centerX - transform.translateX;\n let dy = screenPt.y - centerY - transform.translateY;\n\n // 2. Undo rotation\n const rad = (-transform.rotate * Math.PI) / 180;\n const cos = Math.cos(rad);\n const sin = Math.sin(rad);\n const unrotatedX = dx * cos - dy * sin;\n const unrotatedY = dx * sin + dy * cos;\n\n // 3. Undo scale\n const scaledX = unrotatedX / transform.scale;\n const scaledY = unrotatedY / transform.scale;\n\n // 4. Compute displayed image fitted bounds at scale = 1\n const aspect = imageDimensions.width / imageDimensions.height;\n const containerAspect = containerRect.width / containerRect.height;\n\n let fittedW: number;\n let fittedH: number;\n\n if (containerAspect > aspect) {\n fittedH = containerRect.height;\n fittedW = fittedH * aspect;\n } else {\n fittedW = containerRect.width;\n fittedH = fittedW / aspect;\n }\n\n // 5. Convert to normalized 0..1 coordinates relative to fitted image box\n const normX = (scaledX + fittedW / 2) / fittedW;\n const normY = (scaledY + fittedH / 2) / fittedH;\n\n return {\n x: Math.max(0, Math.min(1, normX)),\n y: Math.max(0, Math.min(1, normY)),\n };\n}\n\n/**\n * Converts a normalized image coordinate (0 to 1) to screen/pixel coordinates\n * inside the given container.\n */\nexport function normalizedImageToScreen(\n normPt: NormalizedPoint,\n containerRect: DOMRect,\n imageDimensions: ImageDimensions,\n transform: TransformState\n): ScreenPoint {\n const aspect = imageDimensions.width / imageDimensions.height;\n const containerAspect = containerRect.width / containerRect.height;\n\n let fittedW: number;\n let fittedH: number;\n\n if (containerAspect > aspect) {\n fittedH = containerRect.height;\n fittedW = fittedH * aspect;\n } else {\n fittedW = containerRect.width;\n fittedH = fittedW / aspect;\n }\n\n // Local unscaled, unrotated position relative to center\n const localX = normPt.x * fittedW - fittedW / 2;\n const localY = normPt.y * fittedH - fittedH / 2;\n\n // Apply scale\n const scaledX = localX * transform.scale;\n const scaledY = localY * transform.scale;\n\n // Apply rotation\n const rad = (transform.rotate * Math.PI) / 180;\n const cos = Math.cos(rad);\n const sin = Math.sin(rad);\n const rotatedX = scaledX * cos - scaledY * sin;\n const rotatedY = scaledX * sin + scaledY * cos;\n\n // Apply translation and container center\n const centerX = containerRect.left + containerRect.width / 2;\n const centerY = containerRect.top + containerRect.height / 2;\n\n return {\n x: centerX + transform.translateX + rotatedX,\n y: centerY + transform.translateY + rotatedY,\n };\n}\n\n/**\n * Computes fitted size of an image inside container maintaining aspect ratio.\n */\nexport function getFittedImageBounds(\n containerW: number,\n containerH: number,\n imgW: number,\n imgH: number\n): { width: number; height: number } {\n if (imgW <= 0 || imgH <= 0 || containerW <= 0 || containerH <= 0) {\n return { width: 0, height: 0 };\n }\n const aspect = imgW / imgH;\n const containerAspect = containerW / containerH;\n\n if (containerAspect > aspect) {\n const height = containerH;\n return { width: height * aspect, height };\n } else {\n const width = containerW;\n return { width, height: width / aspect };\n }\n}\n","import type { NormalizedPoint } from \"../types\";\n\n/**\n * Converts a list of normalized points into a smooth SVG path definition\n * using quadratic Bézier midpoint interpolation.\n */\nexport function pointsToSvgPath(\n points: NormalizedPoint[],\n width: number,\n height: number\n): string {\n if (points.length === 0) return \"\";\n if (points.length === 1) {\n const p = points[0];\n const px = p.x * width;\n const py = p.y * height;\n return `M ${px} ${py} L ${px + 0.1} ${py + 0.1}`;\n }\n\n const p0 = points[0];\n let d = `M ${p0.x * width} ${p0.y * height}`;\n\n for (let i = 1; i < points.length - 1; i++) {\n const current = points[i];\n const next = points[i + 1];\n\n const cx = (current.x * width + next.x * width) / 2;\n const cy = (current.y * height + next.y * height) / 2;\n\n d += ` Q ${current.x * width} ${current.y * height}, ${cx} ${cy}`;\n }\n\n const last = points[points.length - 1];\n d += ` L ${last.x * width} ${last.y * height}`;\n\n return d;\n}\n\n/**\n * Calculates squared Euclidean distance between two points in normalized coordinates.\n */\nexport function distanceSq(p1: NormalizedPoint, p2: NormalizedPoint): number {\n const dx = p1.x - p2.x;\n const dy = p1.y - p2.y;\n return dx * dx + dy * dy;\n}\n\n/**\n * Checks if a test point in normalized coordinates is close to any point in the stroke\n * within an interaction threshold (used for eraser hit detection).\n */\nexport function isPointNearStroke(\n testPt: NormalizedPoint,\n points: NormalizedPoint[],\n thresholdNorm: number\n): boolean {\n const thresholdSq = thresholdNorm * thresholdNorm;\n for (let i = 0; i < points.length; i++) {\n if (distanceSq(testPt, points[i]) <= thresholdSq) {\n return true;\n }\n }\n return false;\n}\n\n/**\n * Converts hex color (#rrggbb or #rgb) to an rgba string with alpha.\n */\nexport function hexToRgba(hex: string, alpha = 1): string {\n if (hex.startsWith(\"rgba\")) return hex;\n const clean = hex.trim().replace(\"#\", \"\");\n if (clean.length === 3) {\n const r = parseInt(clean[0] + clean[0], 16);\n const g = parseInt(clean[1] + clean[1], 16);\n const b = parseInt(clean[2] + clean[2], 16);\n return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n }\n if (clean.length === 6) {\n const r = parseInt(clean.substring(0, 2), 16);\n const g = parseInt(clean.substring(2, 4), 16);\n const b = parseInt(clean.substring(4, 6), 16);\n return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n }\n return hex;\n}\n","import type { AnnotationStroke, SlideAnnotations } from \"../types\";\n\nexport interface HistoryState {\n past: AnnotationStroke[][];\n present: AnnotationStroke[];\n future: AnnotationStroke[][];\n}\n\nexport class AnnotationStore {\n private slides = new Map<string, HistoryState>();\n private listeners = new Set<(slideId: string, strokes: AnnotationStroke[]) => void>();\n\n constructor(initialData?: SlideAnnotations[]) {\n if (initialData) {\n for (const item of initialData) {\n this.slides.set(item.slideId, {\n past: [],\n present: [...item.strokes],\n future: [],\n });\n }\n }\n }\n\n private getOrCreate(slideId: string): HistoryState {\n let state = this.slides.get(slideId);\n if (!state) {\n state = {\n past: [],\n present: [],\n future: [],\n };\n this.slides.set(slideId, state);\n }\n return state;\n }\n\n public getStrokes(slideId: string): AnnotationStroke[] {\n return this.getOrCreate(slideId).present;\n }\n\n public canUndo(slideId: string): boolean {\n return this.getOrCreate(slideId).past.length > 0;\n }\n\n public canRedo(slideId: string): boolean {\n return this.getOrCreate(slideId).future.length > 0;\n }\n\n public addStroke(slideId: string, stroke: AnnotationStroke): void {\n const state = this.getOrCreate(slideId);\n state.past.push([...state.present]);\n state.present = [...state.present, stroke];\n state.future = []; // Clear redo tree on new action\n this.notify(slideId);\n }\n\n public removeStroke(slideId: string, strokeId: string): void {\n const state = this.getOrCreate(slideId);\n const filtered = state.present.filter((s) => s.id !== strokeId);\n if (filtered.length !== state.present.length) {\n state.past.push([...state.present]);\n state.present = filtered;\n state.future = [];\n this.notify(slideId);\n }\n }\n\n public removeStrokesNear(\n slideId: string,\n testPt: { x: number; y: number },\n thresholdNorm: number,\n isPointNearStrokeFn: (pt: { x: number; y: number }, pts: { x: number; y: number }[], thresh: number) => boolean\n ): boolean {\n const state = this.getOrCreate(slideId);\n const toKeep: AnnotationStroke[] = [];\n let removedAny = false;\n\n for (const stroke of state.present) {\n if (isPointNearStrokeFn(testPt, stroke.points, thresholdNorm)) {\n removedAny = true;\n } else {\n toKeep.push(stroke);\n }\n }\n\n if (removedAny) {\n state.past.push([...state.present]);\n state.present = toKeep;\n state.future = [];\n this.notify(slideId);\n return true;\n }\n return false;\n }\n\n public clear(slideId: string): void {\n const state = this.getOrCreate(slideId);\n if (state.present.length === 0) return;\n state.past.push([...state.present]);\n state.present = [];\n state.future = [];\n this.notify(slideId);\n }\n\n public undo(slideId: string): boolean {\n const state = this.getOrCreate(slideId);\n if (state.past.length === 0) return false;\n\n const previous = state.past.pop()!;\n state.future.unshift([...state.present]);\n state.present = previous;\n this.notify(slideId);\n return true;\n }\n\n public redo(slideId: string): boolean {\n const state = this.getOrCreate(slideId);\n if (state.future.length === 0) return false;\n\n const next = state.future.shift()!;\n state.past.push([...state.present]);\n state.present = next;\n this.notify(slideId);\n return true;\n }\n\n public subscribe(listener: (slideId: string, strokes: AnnotationStroke[]) => void): () => void {\n this.listeners.add(listener);\n return () => {\n this.listeners.delete(listener);\n };\n }\n\n private notify(slideId: string): void {\n const strokes = this.getStrokes(slideId);\n for (const listener of this.listeners) {\n listener(slideId, strokes);\n }\n }\n\n public exportJSON(): SlideAnnotations[] {\n const result: SlideAnnotations[] = [];\n for (const [slideId, state] of this.slides.entries()) {\n if (state.present.length > 0) {\n result.push({\n slideId,\n strokes: [...state.present],\n });\n }\n }\n return result;\n }\n\n public importJSON(data: SlideAnnotations[]): void {\n for (const item of data) {\n this.slides.set(item.slideId, {\n past: [],\n present: [...item.strokes],\n future: [],\n });\n this.notify(item.slideId);\n }\n }\n}\n","import type { AnnotationStroke, ExportOptions } from \"../types\";\nimport { hexToRgba } from \"../annotations/smooth\";\n\n/**\n * Loads an image from a URL or source element and renders it\n * along with its annotations onto an off-screen HTMLCanvasElement\n * at original native resolution.\n */\nexport async function renderToOffscreenCanvas(\n imageSource: string | HTMLImageElement,\n strokes: AnnotationStroke[],\n options: ExportOptions = {}\n): Promise<HTMLCanvasElement> {\n const { includeAnnotations = true, backgroundColor } = options;\n\n // 1. Resolve source HTMLImageElement\n let img: HTMLImageElement;\n if (typeof imageSource === \"string\") {\n img = new Image();\n img.crossOrigin = \"anonymous\";\n await new Promise<void>((resolve, reject) => {\n img.onload = () => resolve();\n img.onerror = (e) => reject(new Error(`Failed to load image from source: ${imageSource}`));\n img.src = imageSource;\n });\n } else {\n img = imageSource;\n }\n\n const naturalWidth = img.naturalWidth || img.width;\n const naturalHeight = img.naturalHeight || img.height;\n\n if (naturalWidth === 0 || naturalHeight === 0) {\n throw new Error(\"Invalid image dimensions: width or height is 0\");\n }\n\n // 2. Setup high-res canvas matching native dimensions\n const canvas = document.createElement(\"canvas\");\n canvas.width = naturalWidth;\n canvas.height = naturalHeight;\n const ctx = canvas.getContext(\"2d\");\n\n if (!ctx) {\n throw new Error(\"Unable to obtain 2D canvas context for export\");\n }\n\n // 3. Optional background color\n if (backgroundColor) {\n ctx.fillStyle = backgroundColor;\n ctx.fillRect(0, 0, naturalWidth, naturalHeight);\n }\n\n // 4. Draw base image\n ctx.drawImage(img, 0, 0, naturalWidth, naturalHeight);\n\n // 5. Burn in annotations if requested\n if (includeAnnotations && strokes.length > 0) {\n for (const stroke of strokes) {\n if (stroke.points.length === 0) continue;\n\n ctx.save();\n ctx.beginPath();\n ctx.lineCap = \"round\";\n ctx.lineJoin = \"round\";\n\n // Scale stroke width relative to the maximum dimension of the image\n // Stroke width in model is relative to fitted display (e.g. 3px on 800px)\n // We scale it proportionally so it looks identical to what user saw\n const strokeScale = Math.max(naturalWidth, naturalHeight) / 1000;\n ctx.lineWidth = Math.max(1, stroke.width * strokeScale);\n\n if (stroke.tool === \"highlighter\") {\n ctx.globalAlpha = stroke.opacity || 0.35;\n ctx.strokeStyle = hexToRgba(stroke.color, 1);\n // Multiply blend looks most natural on medical illustrations & documents\n ctx.globalCompositeOperation = \"multiply\";\n } else {\n ctx.globalAlpha = stroke.opacity || 1.0;\n ctx.strokeStyle = stroke.color;\n ctx.globalCompositeOperation = \"source-over\";\n }\n\n const p0 = stroke.points[0];\n const startX = p0.x * naturalWidth;\n const startY = p0.y * naturalHeight;\n ctx.moveTo(startX, startY);\n\n if (stroke.points.length === 1) {\n ctx.lineTo(startX + 0.5, startY + 0.5);\n } else {\n for (let i = 1; i < stroke.points.length - 1; i++) {\n const pt = stroke.points[i];\n const next = stroke.points[i + 1];\n const cx = (pt.x * naturalWidth + next.x * naturalWidth) / 2;\n const cy = (pt.y * naturalHeight + next.y * naturalHeight) / 2;\n ctx.quadraticCurveTo(pt.x * naturalWidth, pt.y * naturalHeight, cx, cy);\n }\n const last = stroke.points[stroke.points.length - 1];\n ctx.lineTo(last.x * naturalWidth, last.y * naturalHeight);\n }\n\n ctx.stroke();\n ctx.restore();\n }\n }\n\n return canvas;\n}\n\n/**\n * Triggers a browser download of the image (with or without annotations).\n */\nexport async function downloadAnnotatedImage(\n imageSource: string | HTMLImageElement,\n strokes: AnnotationStroke[],\n options: ExportOptions = {}\n): Promise<void> {\n const {\n format = \"image/png\",\n quality = 0.95,\n fileName = `tbib-annotated-image-${Date.now()}.${format === \"image/jpeg\" ? \"jpg\" : format === \"image/webp\" ? \"webp\" : \"png\"}`,\n } = options;\n\n try {\n const canvas = await renderToOffscreenCanvas(imageSource, strokes, options);\n\n return new Promise<void>((resolve, reject) => {\n canvas.toBlob(\n (blob) => {\n if (!blob) {\n reject(new Error(\"Failed to generate image blob from canvas\"));\n return;\n }\n\n const url = URL.createObjectURL(blob);\n const link = document.createElement(\"a\");\n link.href = url;\n link.download = fileName;\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n resolve();\n },\n format,\n quality\n );\n });\n } catch (err) {\n console.warn(\"Offscreen canvas render encountered an issue, falling back to direct asset download:\", err);\n const src = typeof imageSource === \"string\" ? imageSource : imageSource.src;\n const link = document.createElement(\"a\");\n link.href = src;\n link.download = fileName;\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n }\n}\n"]}
package/dist/index.mjs ADDED
@@ -0,0 +1,392 @@
1
+ // src/engine/transform.ts
2
+ var DEFAULT_TRANSFORM = {
3
+ scale: 1,
4
+ translateX: 0,
5
+ translateY: 0,
6
+ rotate: 0
7
+ };
8
+ var MIN_SCALE = 0.5;
9
+ var MAX_SCALE = 8;
10
+ function clampScale(scale, min = MIN_SCALE, max = MAX_SCALE) {
11
+ return Math.min(Math.max(scale, min), max);
12
+ }
13
+ function screenToNormalizedImage(screenPt, containerRect, imageDimensions, transform) {
14
+ if (imageDimensions.width <= 0 || imageDimensions.height <= 0) return null;
15
+ const centerX = containerRect.left + containerRect.width / 2;
16
+ const centerY = containerRect.top + containerRect.height / 2;
17
+ let dx = screenPt.x - centerX - transform.translateX;
18
+ let dy = screenPt.y - centerY - transform.translateY;
19
+ const rad = -transform.rotate * Math.PI / 180;
20
+ const cos = Math.cos(rad);
21
+ const sin = Math.sin(rad);
22
+ const unrotatedX = dx * cos - dy * sin;
23
+ const unrotatedY = dx * sin + dy * cos;
24
+ const scaledX = unrotatedX / transform.scale;
25
+ const scaledY = unrotatedY / transform.scale;
26
+ const aspect = imageDimensions.width / imageDimensions.height;
27
+ const containerAspect = containerRect.width / containerRect.height;
28
+ let fittedW;
29
+ let fittedH;
30
+ if (containerAspect > aspect) {
31
+ fittedH = containerRect.height;
32
+ fittedW = fittedH * aspect;
33
+ } else {
34
+ fittedW = containerRect.width;
35
+ fittedH = fittedW / aspect;
36
+ }
37
+ const normX = (scaledX + fittedW / 2) / fittedW;
38
+ const normY = (scaledY + fittedH / 2) / fittedH;
39
+ return {
40
+ x: Math.max(0, Math.min(1, normX)),
41
+ y: Math.max(0, Math.min(1, normY))
42
+ };
43
+ }
44
+ function normalizedImageToScreen(normPt, containerRect, imageDimensions, transform) {
45
+ const aspect = imageDimensions.width / imageDimensions.height;
46
+ const containerAspect = containerRect.width / containerRect.height;
47
+ let fittedW;
48
+ let fittedH;
49
+ if (containerAspect > aspect) {
50
+ fittedH = containerRect.height;
51
+ fittedW = fittedH * aspect;
52
+ } else {
53
+ fittedW = containerRect.width;
54
+ fittedH = fittedW / aspect;
55
+ }
56
+ const localX = normPt.x * fittedW - fittedW / 2;
57
+ const localY = normPt.y * fittedH - fittedH / 2;
58
+ const scaledX = localX * transform.scale;
59
+ const scaledY = localY * transform.scale;
60
+ const rad = transform.rotate * Math.PI / 180;
61
+ const cos = Math.cos(rad);
62
+ const sin = Math.sin(rad);
63
+ const rotatedX = scaledX * cos - scaledY * sin;
64
+ const rotatedY = scaledX * sin + scaledY * cos;
65
+ const centerX = containerRect.left + containerRect.width / 2;
66
+ const centerY = containerRect.top + containerRect.height / 2;
67
+ return {
68
+ x: centerX + transform.translateX + rotatedX,
69
+ y: centerY + transform.translateY + rotatedY
70
+ };
71
+ }
72
+ function getFittedImageBounds(containerW, containerH, imgW, imgH) {
73
+ if (imgW <= 0 || imgH <= 0 || containerW <= 0 || containerH <= 0) {
74
+ return { width: 0, height: 0 };
75
+ }
76
+ const aspect = imgW / imgH;
77
+ const containerAspect = containerW / containerH;
78
+ if (containerAspect > aspect) {
79
+ const height = containerH;
80
+ return { width: height * aspect, height };
81
+ } else {
82
+ const width = containerW;
83
+ return { width, height: width / aspect };
84
+ }
85
+ }
86
+
87
+ // src/annotations/smooth.ts
88
+ function pointsToSvgPath(points, width, height) {
89
+ if (points.length === 0) return "";
90
+ if (points.length === 1) {
91
+ const p = points[0];
92
+ const px = p.x * width;
93
+ const py = p.y * height;
94
+ return `M ${px} ${py} L ${px + 0.1} ${py + 0.1}`;
95
+ }
96
+ const p0 = points[0];
97
+ let d = `M ${p0.x * width} ${p0.y * height}`;
98
+ for (let i = 1; i < points.length - 1; i++) {
99
+ const current = points[i];
100
+ const next = points[i + 1];
101
+ const cx = (current.x * width + next.x * width) / 2;
102
+ const cy = (current.y * height + next.y * height) / 2;
103
+ d += ` Q ${current.x * width} ${current.y * height}, ${cx} ${cy}`;
104
+ }
105
+ const last = points[points.length - 1];
106
+ d += ` L ${last.x * width} ${last.y * height}`;
107
+ return d;
108
+ }
109
+ function distanceSq(p1, p2) {
110
+ const dx = p1.x - p2.x;
111
+ const dy = p1.y - p2.y;
112
+ return dx * dx + dy * dy;
113
+ }
114
+ function isPointNearStroke(testPt, points, thresholdNorm) {
115
+ const thresholdSq = thresholdNorm * thresholdNorm;
116
+ for (let i = 0; i < points.length; i++) {
117
+ if (distanceSq(testPt, points[i]) <= thresholdSq) {
118
+ return true;
119
+ }
120
+ }
121
+ return false;
122
+ }
123
+ function hexToRgba(hex, alpha = 1) {
124
+ if (hex.startsWith("rgba")) return hex;
125
+ const clean = hex.trim().replace("#", "");
126
+ if (clean.length === 3) {
127
+ const r = parseInt(clean[0] + clean[0], 16);
128
+ const g = parseInt(clean[1] + clean[1], 16);
129
+ const b = parseInt(clean[2] + clean[2], 16);
130
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
131
+ }
132
+ if (clean.length === 6) {
133
+ const r = parseInt(clean.substring(0, 2), 16);
134
+ const g = parseInt(clean.substring(2, 4), 16);
135
+ const b = parseInt(clean.substring(4, 6), 16);
136
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
137
+ }
138
+ return hex;
139
+ }
140
+
141
+ // src/annotations/store.ts
142
+ var AnnotationStore = class {
143
+ slides = /* @__PURE__ */ new Map();
144
+ listeners = /* @__PURE__ */ new Set();
145
+ constructor(initialData) {
146
+ if (initialData) {
147
+ for (const item of initialData) {
148
+ this.slides.set(item.slideId, {
149
+ past: [],
150
+ present: [...item.strokes],
151
+ future: []
152
+ });
153
+ }
154
+ }
155
+ }
156
+ getOrCreate(slideId) {
157
+ let state = this.slides.get(slideId);
158
+ if (!state) {
159
+ state = {
160
+ past: [],
161
+ present: [],
162
+ future: []
163
+ };
164
+ this.slides.set(slideId, state);
165
+ }
166
+ return state;
167
+ }
168
+ getStrokes(slideId) {
169
+ return this.getOrCreate(slideId).present;
170
+ }
171
+ canUndo(slideId) {
172
+ return this.getOrCreate(slideId).past.length > 0;
173
+ }
174
+ canRedo(slideId) {
175
+ return this.getOrCreate(slideId).future.length > 0;
176
+ }
177
+ addStroke(slideId, stroke) {
178
+ const state = this.getOrCreate(slideId);
179
+ state.past.push([...state.present]);
180
+ state.present = [...state.present, stroke];
181
+ state.future = [];
182
+ this.notify(slideId);
183
+ }
184
+ removeStroke(slideId, strokeId) {
185
+ const state = this.getOrCreate(slideId);
186
+ const filtered = state.present.filter((s) => s.id !== strokeId);
187
+ if (filtered.length !== state.present.length) {
188
+ state.past.push([...state.present]);
189
+ state.present = filtered;
190
+ state.future = [];
191
+ this.notify(slideId);
192
+ }
193
+ }
194
+ removeStrokesNear(slideId, testPt, thresholdNorm, isPointNearStrokeFn) {
195
+ const state = this.getOrCreate(slideId);
196
+ const toKeep = [];
197
+ let removedAny = false;
198
+ for (const stroke of state.present) {
199
+ if (isPointNearStrokeFn(testPt, stroke.points, thresholdNorm)) {
200
+ removedAny = true;
201
+ } else {
202
+ toKeep.push(stroke);
203
+ }
204
+ }
205
+ if (removedAny) {
206
+ state.past.push([...state.present]);
207
+ state.present = toKeep;
208
+ state.future = [];
209
+ this.notify(slideId);
210
+ return true;
211
+ }
212
+ return false;
213
+ }
214
+ clear(slideId) {
215
+ const state = this.getOrCreate(slideId);
216
+ if (state.present.length === 0) return;
217
+ state.past.push([...state.present]);
218
+ state.present = [];
219
+ state.future = [];
220
+ this.notify(slideId);
221
+ }
222
+ undo(slideId) {
223
+ const state = this.getOrCreate(slideId);
224
+ if (state.past.length === 0) return false;
225
+ const previous = state.past.pop();
226
+ state.future.unshift([...state.present]);
227
+ state.present = previous;
228
+ this.notify(slideId);
229
+ return true;
230
+ }
231
+ redo(slideId) {
232
+ const state = this.getOrCreate(slideId);
233
+ if (state.future.length === 0) return false;
234
+ const next = state.future.shift();
235
+ state.past.push([...state.present]);
236
+ state.present = next;
237
+ this.notify(slideId);
238
+ return true;
239
+ }
240
+ subscribe(listener) {
241
+ this.listeners.add(listener);
242
+ return () => {
243
+ this.listeners.delete(listener);
244
+ };
245
+ }
246
+ notify(slideId) {
247
+ const strokes = this.getStrokes(slideId);
248
+ for (const listener of this.listeners) {
249
+ listener(slideId, strokes);
250
+ }
251
+ }
252
+ exportJSON() {
253
+ const result = [];
254
+ for (const [slideId, state] of this.slides.entries()) {
255
+ if (state.present.length > 0) {
256
+ result.push({
257
+ slideId,
258
+ strokes: [...state.present]
259
+ });
260
+ }
261
+ }
262
+ return result;
263
+ }
264
+ importJSON(data) {
265
+ for (const item of data) {
266
+ this.slides.set(item.slideId, {
267
+ past: [],
268
+ present: [...item.strokes],
269
+ future: []
270
+ });
271
+ this.notify(item.slideId);
272
+ }
273
+ }
274
+ };
275
+
276
+ // src/export/canvas.ts
277
+ async function renderToOffscreenCanvas(imageSource, strokes, options = {}) {
278
+ const { includeAnnotations = true, backgroundColor } = options;
279
+ let img;
280
+ if (typeof imageSource === "string") {
281
+ img = new Image();
282
+ img.crossOrigin = "anonymous";
283
+ await new Promise((resolve, reject) => {
284
+ img.onload = () => resolve();
285
+ img.onerror = (e) => reject(new Error(`Failed to load image from source: ${imageSource}`));
286
+ img.src = imageSource;
287
+ });
288
+ } else {
289
+ img = imageSource;
290
+ }
291
+ const naturalWidth = img.naturalWidth || img.width;
292
+ const naturalHeight = img.naturalHeight || img.height;
293
+ if (naturalWidth === 0 || naturalHeight === 0) {
294
+ throw new Error("Invalid image dimensions: width or height is 0");
295
+ }
296
+ const canvas = document.createElement("canvas");
297
+ canvas.width = naturalWidth;
298
+ canvas.height = naturalHeight;
299
+ const ctx = canvas.getContext("2d");
300
+ if (!ctx) {
301
+ throw new Error("Unable to obtain 2D canvas context for export");
302
+ }
303
+ if (backgroundColor) {
304
+ ctx.fillStyle = backgroundColor;
305
+ ctx.fillRect(0, 0, naturalWidth, naturalHeight);
306
+ }
307
+ ctx.drawImage(img, 0, 0, naturalWidth, naturalHeight);
308
+ if (includeAnnotations && strokes.length > 0) {
309
+ for (const stroke of strokes) {
310
+ if (stroke.points.length === 0) continue;
311
+ ctx.save();
312
+ ctx.beginPath();
313
+ ctx.lineCap = "round";
314
+ ctx.lineJoin = "round";
315
+ const strokeScale = Math.max(naturalWidth, naturalHeight) / 1e3;
316
+ ctx.lineWidth = Math.max(1, stroke.width * strokeScale);
317
+ if (stroke.tool === "highlighter") {
318
+ ctx.globalAlpha = stroke.opacity || 0.35;
319
+ ctx.strokeStyle = hexToRgba(stroke.color, 1);
320
+ ctx.globalCompositeOperation = "multiply";
321
+ } else {
322
+ ctx.globalAlpha = stroke.opacity || 1;
323
+ ctx.strokeStyle = stroke.color;
324
+ ctx.globalCompositeOperation = "source-over";
325
+ }
326
+ const p0 = stroke.points[0];
327
+ const startX = p0.x * naturalWidth;
328
+ const startY = p0.y * naturalHeight;
329
+ ctx.moveTo(startX, startY);
330
+ if (stroke.points.length === 1) {
331
+ ctx.lineTo(startX + 0.5, startY + 0.5);
332
+ } else {
333
+ for (let i = 1; i < stroke.points.length - 1; i++) {
334
+ const pt = stroke.points[i];
335
+ const next = stroke.points[i + 1];
336
+ const cx = (pt.x * naturalWidth + next.x * naturalWidth) / 2;
337
+ const cy = (pt.y * naturalHeight + next.y * naturalHeight) / 2;
338
+ ctx.quadraticCurveTo(pt.x * naturalWidth, pt.y * naturalHeight, cx, cy);
339
+ }
340
+ const last = stroke.points[stroke.points.length - 1];
341
+ ctx.lineTo(last.x * naturalWidth, last.y * naturalHeight);
342
+ }
343
+ ctx.stroke();
344
+ ctx.restore();
345
+ }
346
+ }
347
+ return canvas;
348
+ }
349
+ async function downloadAnnotatedImage(imageSource, strokes, options = {}) {
350
+ const {
351
+ format = "image/png",
352
+ quality = 0.95,
353
+ fileName = `tbib-annotated-image-${Date.now()}.${format === "image/jpeg" ? "jpg" : format === "image/webp" ? "webp" : "png"}`
354
+ } = options;
355
+ try {
356
+ const canvas = await renderToOffscreenCanvas(imageSource, strokes, options);
357
+ return new Promise((resolve, reject) => {
358
+ canvas.toBlob(
359
+ (blob) => {
360
+ if (!blob) {
361
+ reject(new Error("Failed to generate image blob from canvas"));
362
+ return;
363
+ }
364
+ const url = URL.createObjectURL(blob);
365
+ const link = document.createElement("a");
366
+ link.href = url;
367
+ link.download = fileName;
368
+ document.body.appendChild(link);
369
+ link.click();
370
+ document.body.removeChild(link);
371
+ setTimeout(() => URL.revokeObjectURL(url), 1e3);
372
+ resolve();
373
+ },
374
+ format,
375
+ quality
376
+ );
377
+ });
378
+ } catch (err) {
379
+ console.warn("Offscreen canvas render encountered an issue, falling back to direct asset download:", err);
380
+ const src = typeof imageSource === "string" ? imageSource : imageSource.src;
381
+ const link = document.createElement("a");
382
+ link.href = src;
383
+ link.download = fileName;
384
+ document.body.appendChild(link);
385
+ link.click();
386
+ document.body.removeChild(link);
387
+ }
388
+ }
389
+
390
+ export { AnnotationStore, DEFAULT_TRANSFORM, MAX_SCALE, MIN_SCALE, clampScale, distanceSq, downloadAnnotatedImage, getFittedImageBounds, hexToRgba, isPointNearStroke, normalizedImageToScreen, pointsToSvgPath, renderToOffscreenCanvas, screenToNormalizedImage };
391
+ //# sourceMappingURL=index.mjs.map
392
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/engine/transform.ts","../src/annotations/smooth.ts","../src/annotations/store.ts","../src/export/canvas.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["import type { ImageDimensions, NormalizedPoint, ScreenPoint, TransformState } from \"../types\";\n\nexport const DEFAULT_TRANSFORM: TransformState = {\n scale: 1,\n translateX: 0,\n translateY: 0,\n rotate: 0,\n};\n\nexport const MIN_SCALE = 0.5;\nexport const MAX_SCALE = 8.0;\n\n/**\n * Clamps scale between min and max allowed bounds.\n */\nexport function clampScale(scale: number, min = MIN_SCALE, max = MAX_SCALE): number {\n return Math.min(Math.max(scale, min), max);\n}\n\n/**\n * Converts a screen/viewport coordinate (e.g. mouse or touch event)\n * into a normalized image coordinate (0 to 1) based on the image bounding box\n * and current scale/pan/rotation transform.\n */\nexport function screenToNormalizedImage(\n screenPt: ScreenPoint,\n containerRect: DOMRect,\n imageDimensions: ImageDimensions,\n transform: TransformState\n): NormalizedPoint | null {\n if (imageDimensions.width <= 0 || imageDimensions.height <= 0) return null;\n\n // 1. Point relative to container center\n const centerX = containerRect.left + containerRect.width / 2;\n const centerY = containerRect.top + containerRect.height / 2;\n\n let dx = screenPt.x - centerX - transform.translateX;\n let dy = screenPt.y - centerY - transform.translateY;\n\n // 2. Undo rotation\n const rad = (-transform.rotate * Math.PI) / 180;\n const cos = Math.cos(rad);\n const sin = Math.sin(rad);\n const unrotatedX = dx * cos - dy * sin;\n const unrotatedY = dx * sin + dy * cos;\n\n // 3. Undo scale\n const scaledX = unrotatedX / transform.scale;\n const scaledY = unrotatedY / transform.scale;\n\n // 4. Compute displayed image fitted bounds at scale = 1\n const aspect = imageDimensions.width / imageDimensions.height;\n const containerAspect = containerRect.width / containerRect.height;\n\n let fittedW: number;\n let fittedH: number;\n\n if (containerAspect > aspect) {\n fittedH = containerRect.height;\n fittedW = fittedH * aspect;\n } else {\n fittedW = containerRect.width;\n fittedH = fittedW / aspect;\n }\n\n // 5. Convert to normalized 0..1 coordinates relative to fitted image box\n const normX = (scaledX + fittedW / 2) / fittedW;\n const normY = (scaledY + fittedH / 2) / fittedH;\n\n return {\n x: Math.max(0, Math.min(1, normX)),\n y: Math.max(0, Math.min(1, normY)),\n };\n}\n\n/**\n * Converts a normalized image coordinate (0 to 1) to screen/pixel coordinates\n * inside the given container.\n */\nexport function normalizedImageToScreen(\n normPt: NormalizedPoint,\n containerRect: DOMRect,\n imageDimensions: ImageDimensions,\n transform: TransformState\n): ScreenPoint {\n const aspect = imageDimensions.width / imageDimensions.height;\n const containerAspect = containerRect.width / containerRect.height;\n\n let fittedW: number;\n let fittedH: number;\n\n if (containerAspect > aspect) {\n fittedH = containerRect.height;\n fittedW = fittedH * aspect;\n } else {\n fittedW = containerRect.width;\n fittedH = fittedW / aspect;\n }\n\n // Local unscaled, unrotated position relative to center\n const localX = normPt.x * fittedW - fittedW / 2;\n const localY = normPt.y * fittedH - fittedH / 2;\n\n // Apply scale\n const scaledX = localX * transform.scale;\n const scaledY = localY * transform.scale;\n\n // Apply rotation\n const rad = (transform.rotate * Math.PI) / 180;\n const cos = Math.cos(rad);\n const sin = Math.sin(rad);\n const rotatedX = scaledX * cos - scaledY * sin;\n const rotatedY = scaledX * sin + scaledY * cos;\n\n // Apply translation and container center\n const centerX = containerRect.left + containerRect.width / 2;\n const centerY = containerRect.top + containerRect.height / 2;\n\n return {\n x: centerX + transform.translateX + rotatedX,\n y: centerY + transform.translateY + rotatedY,\n };\n}\n\n/**\n * Computes fitted size of an image inside container maintaining aspect ratio.\n */\nexport function getFittedImageBounds(\n containerW: number,\n containerH: number,\n imgW: number,\n imgH: number\n): { width: number; height: number } {\n if (imgW <= 0 || imgH <= 0 || containerW <= 0 || containerH <= 0) {\n return { width: 0, height: 0 };\n }\n const aspect = imgW / imgH;\n const containerAspect = containerW / containerH;\n\n if (containerAspect > aspect) {\n const height = containerH;\n return { width: height * aspect, height };\n } else {\n const width = containerW;\n return { width, height: width / aspect };\n }\n}\n","import type { NormalizedPoint } from \"../types\";\n\n/**\n * Converts a list of normalized points into a smooth SVG path definition\n * using quadratic Bézier midpoint interpolation.\n */\nexport function pointsToSvgPath(\n points: NormalizedPoint[],\n width: number,\n height: number\n): string {\n if (points.length === 0) return \"\";\n if (points.length === 1) {\n const p = points[0];\n const px = p.x * width;\n const py = p.y * height;\n return `M ${px} ${py} L ${px + 0.1} ${py + 0.1}`;\n }\n\n const p0 = points[0];\n let d = `M ${p0.x * width} ${p0.y * height}`;\n\n for (let i = 1; i < points.length - 1; i++) {\n const current = points[i];\n const next = points[i + 1];\n\n const cx = (current.x * width + next.x * width) / 2;\n const cy = (current.y * height + next.y * height) / 2;\n\n d += ` Q ${current.x * width} ${current.y * height}, ${cx} ${cy}`;\n }\n\n const last = points[points.length - 1];\n d += ` L ${last.x * width} ${last.y * height}`;\n\n return d;\n}\n\n/**\n * Calculates squared Euclidean distance between two points in normalized coordinates.\n */\nexport function distanceSq(p1: NormalizedPoint, p2: NormalizedPoint): number {\n const dx = p1.x - p2.x;\n const dy = p1.y - p2.y;\n return dx * dx + dy * dy;\n}\n\n/**\n * Checks if a test point in normalized coordinates is close to any point in the stroke\n * within an interaction threshold (used for eraser hit detection).\n */\nexport function isPointNearStroke(\n testPt: NormalizedPoint,\n points: NormalizedPoint[],\n thresholdNorm: number\n): boolean {\n const thresholdSq = thresholdNorm * thresholdNorm;\n for (let i = 0; i < points.length; i++) {\n if (distanceSq(testPt, points[i]) <= thresholdSq) {\n return true;\n }\n }\n return false;\n}\n\n/**\n * Converts hex color (#rrggbb or #rgb) to an rgba string with alpha.\n */\nexport function hexToRgba(hex: string, alpha = 1): string {\n if (hex.startsWith(\"rgba\")) return hex;\n const clean = hex.trim().replace(\"#\", \"\");\n if (clean.length === 3) {\n const r = parseInt(clean[0] + clean[0], 16);\n const g = parseInt(clean[1] + clean[1], 16);\n const b = parseInt(clean[2] + clean[2], 16);\n return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n }\n if (clean.length === 6) {\n const r = parseInt(clean.substring(0, 2), 16);\n const g = parseInt(clean.substring(2, 4), 16);\n const b = parseInt(clean.substring(4, 6), 16);\n return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n }\n return hex;\n}\n","import type { AnnotationStroke, SlideAnnotations } from \"../types\";\n\nexport interface HistoryState {\n past: AnnotationStroke[][];\n present: AnnotationStroke[];\n future: AnnotationStroke[][];\n}\n\nexport class AnnotationStore {\n private slides = new Map<string, HistoryState>();\n private listeners = new Set<(slideId: string, strokes: AnnotationStroke[]) => void>();\n\n constructor(initialData?: SlideAnnotations[]) {\n if (initialData) {\n for (const item of initialData) {\n this.slides.set(item.slideId, {\n past: [],\n present: [...item.strokes],\n future: [],\n });\n }\n }\n }\n\n private getOrCreate(slideId: string): HistoryState {\n let state = this.slides.get(slideId);\n if (!state) {\n state = {\n past: [],\n present: [],\n future: [],\n };\n this.slides.set(slideId, state);\n }\n return state;\n }\n\n public getStrokes(slideId: string): AnnotationStroke[] {\n return this.getOrCreate(slideId).present;\n }\n\n public canUndo(slideId: string): boolean {\n return this.getOrCreate(slideId).past.length > 0;\n }\n\n public canRedo(slideId: string): boolean {\n return this.getOrCreate(slideId).future.length > 0;\n }\n\n public addStroke(slideId: string, stroke: AnnotationStroke): void {\n const state = this.getOrCreate(slideId);\n state.past.push([...state.present]);\n state.present = [...state.present, stroke];\n state.future = []; // Clear redo tree on new action\n this.notify(slideId);\n }\n\n public removeStroke(slideId: string, strokeId: string): void {\n const state = this.getOrCreate(slideId);\n const filtered = state.present.filter((s) => s.id !== strokeId);\n if (filtered.length !== state.present.length) {\n state.past.push([...state.present]);\n state.present = filtered;\n state.future = [];\n this.notify(slideId);\n }\n }\n\n public removeStrokesNear(\n slideId: string,\n testPt: { x: number; y: number },\n thresholdNorm: number,\n isPointNearStrokeFn: (pt: { x: number; y: number }, pts: { x: number; y: number }[], thresh: number) => boolean\n ): boolean {\n const state = this.getOrCreate(slideId);\n const toKeep: AnnotationStroke[] = [];\n let removedAny = false;\n\n for (const stroke of state.present) {\n if (isPointNearStrokeFn(testPt, stroke.points, thresholdNorm)) {\n removedAny = true;\n } else {\n toKeep.push(stroke);\n }\n }\n\n if (removedAny) {\n state.past.push([...state.present]);\n state.present = toKeep;\n state.future = [];\n this.notify(slideId);\n return true;\n }\n return false;\n }\n\n public clear(slideId: string): void {\n const state = this.getOrCreate(slideId);\n if (state.present.length === 0) return;\n state.past.push([...state.present]);\n state.present = [];\n state.future = [];\n this.notify(slideId);\n }\n\n public undo(slideId: string): boolean {\n const state = this.getOrCreate(slideId);\n if (state.past.length === 0) return false;\n\n const previous = state.past.pop()!;\n state.future.unshift([...state.present]);\n state.present = previous;\n this.notify(slideId);\n return true;\n }\n\n public redo(slideId: string): boolean {\n const state = this.getOrCreate(slideId);\n if (state.future.length === 0) return false;\n\n const next = state.future.shift()!;\n state.past.push([...state.present]);\n state.present = next;\n this.notify(slideId);\n return true;\n }\n\n public subscribe(listener: (slideId: string, strokes: AnnotationStroke[]) => void): () => void {\n this.listeners.add(listener);\n return () => {\n this.listeners.delete(listener);\n };\n }\n\n private notify(slideId: string): void {\n const strokes = this.getStrokes(slideId);\n for (const listener of this.listeners) {\n listener(slideId, strokes);\n }\n }\n\n public exportJSON(): SlideAnnotations[] {\n const result: SlideAnnotations[] = [];\n for (const [slideId, state] of this.slides.entries()) {\n if (state.present.length > 0) {\n result.push({\n slideId,\n strokes: [...state.present],\n });\n }\n }\n return result;\n }\n\n public importJSON(data: SlideAnnotations[]): void {\n for (const item of data) {\n this.slides.set(item.slideId, {\n past: [],\n present: [...item.strokes],\n future: [],\n });\n this.notify(item.slideId);\n }\n }\n}\n","import type { AnnotationStroke, ExportOptions } from \"../types\";\nimport { hexToRgba } from \"../annotations/smooth\";\n\n/**\n * Loads an image from a URL or source element and renders it\n * along with its annotations onto an off-screen HTMLCanvasElement\n * at original native resolution.\n */\nexport async function renderToOffscreenCanvas(\n imageSource: string | HTMLImageElement,\n strokes: AnnotationStroke[],\n options: ExportOptions = {}\n): Promise<HTMLCanvasElement> {\n const { includeAnnotations = true, backgroundColor } = options;\n\n // 1. Resolve source HTMLImageElement\n let img: HTMLImageElement;\n if (typeof imageSource === \"string\") {\n img = new Image();\n img.crossOrigin = \"anonymous\";\n await new Promise<void>((resolve, reject) => {\n img.onload = () => resolve();\n img.onerror = (e) => reject(new Error(`Failed to load image from source: ${imageSource}`));\n img.src = imageSource;\n });\n } else {\n img = imageSource;\n }\n\n const naturalWidth = img.naturalWidth || img.width;\n const naturalHeight = img.naturalHeight || img.height;\n\n if (naturalWidth === 0 || naturalHeight === 0) {\n throw new Error(\"Invalid image dimensions: width or height is 0\");\n }\n\n // 2. Setup high-res canvas matching native dimensions\n const canvas = document.createElement(\"canvas\");\n canvas.width = naturalWidth;\n canvas.height = naturalHeight;\n const ctx = canvas.getContext(\"2d\");\n\n if (!ctx) {\n throw new Error(\"Unable to obtain 2D canvas context for export\");\n }\n\n // 3. Optional background color\n if (backgroundColor) {\n ctx.fillStyle = backgroundColor;\n ctx.fillRect(0, 0, naturalWidth, naturalHeight);\n }\n\n // 4. Draw base image\n ctx.drawImage(img, 0, 0, naturalWidth, naturalHeight);\n\n // 5. Burn in annotations if requested\n if (includeAnnotations && strokes.length > 0) {\n for (const stroke of strokes) {\n if (stroke.points.length === 0) continue;\n\n ctx.save();\n ctx.beginPath();\n ctx.lineCap = \"round\";\n ctx.lineJoin = \"round\";\n\n // Scale stroke width relative to the maximum dimension of the image\n // Stroke width in model is relative to fitted display (e.g. 3px on 800px)\n // We scale it proportionally so it looks identical to what user saw\n const strokeScale = Math.max(naturalWidth, naturalHeight) / 1000;\n ctx.lineWidth = Math.max(1, stroke.width * strokeScale);\n\n if (stroke.tool === \"highlighter\") {\n ctx.globalAlpha = stroke.opacity || 0.35;\n ctx.strokeStyle = hexToRgba(stroke.color, 1);\n // Multiply blend looks most natural on medical illustrations & documents\n ctx.globalCompositeOperation = \"multiply\";\n } else {\n ctx.globalAlpha = stroke.opacity || 1.0;\n ctx.strokeStyle = stroke.color;\n ctx.globalCompositeOperation = \"source-over\";\n }\n\n const p0 = stroke.points[0];\n const startX = p0.x * naturalWidth;\n const startY = p0.y * naturalHeight;\n ctx.moveTo(startX, startY);\n\n if (stroke.points.length === 1) {\n ctx.lineTo(startX + 0.5, startY + 0.5);\n } else {\n for (let i = 1; i < stroke.points.length - 1; i++) {\n const pt = stroke.points[i];\n const next = stroke.points[i + 1];\n const cx = (pt.x * naturalWidth + next.x * naturalWidth) / 2;\n const cy = (pt.y * naturalHeight + next.y * naturalHeight) / 2;\n ctx.quadraticCurveTo(pt.x * naturalWidth, pt.y * naturalHeight, cx, cy);\n }\n const last = stroke.points[stroke.points.length - 1];\n ctx.lineTo(last.x * naturalWidth, last.y * naturalHeight);\n }\n\n ctx.stroke();\n ctx.restore();\n }\n }\n\n return canvas;\n}\n\n/**\n * Triggers a browser download of the image (with or without annotations).\n */\nexport async function downloadAnnotatedImage(\n imageSource: string | HTMLImageElement,\n strokes: AnnotationStroke[],\n options: ExportOptions = {}\n): Promise<void> {\n const {\n format = \"image/png\",\n quality = 0.95,\n fileName = `tbib-annotated-image-${Date.now()}.${format === \"image/jpeg\" ? \"jpg\" : format === \"image/webp\" ? \"webp\" : \"png\"}`,\n } = options;\n\n try {\n const canvas = await renderToOffscreenCanvas(imageSource, strokes, options);\n\n return new Promise<void>((resolve, reject) => {\n canvas.toBlob(\n (blob) => {\n if (!blob) {\n reject(new Error(\"Failed to generate image blob from canvas\"));\n return;\n }\n\n const url = URL.createObjectURL(blob);\n const link = document.createElement(\"a\");\n link.href = url;\n link.download = fileName;\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n resolve();\n },\n format,\n quality\n );\n });\n } catch (err) {\n console.warn(\"Offscreen canvas render encountered an issue, falling back to direct asset download:\", err);\n const src = typeof imageSource === \"string\" ? imageSource : imageSource.src;\n const link = document.createElement(\"a\");\n link.href = src;\n link.download = fileName;\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n }\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@tbib-image-viewer/core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Framework-agnostic coordinate math, annotation engine, stroke smoothing, and native-res canvas export for TBiB Image Viewer",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsup",
20
+ "dev": "tsup --watch",
21
+ "test": "vitest run"
22
+ },
23
+ "devDependencies": {
24
+ "tsup": "^8.0.2",
25
+ "typescript": "^5.4.5",
26
+ "vitest": "^1.6.0"
27
+ },
28
+ "keywords": [
29
+ "image-viewer",
30
+ "annotation",
31
+ "sketch",
32
+ "pen",
33
+ "highlighter",
34
+ "laser",
35
+ "gallery",
36
+ "tbib"
37
+ ],
38
+ "license": "MIT",
39
+ "publishConfig": {
40
+ "access": "public"
41
+ }
42
+ }