tokimeki-image-editor 0.1.13 → 0.2.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.
@@ -0,0 +1,15 @@
1
+ import type { Annotation, Viewport, TransformState, CropArea } from '../types';
2
+ interface Props {
3
+ canvas: HTMLCanvasElement | null;
4
+ image: HTMLImageElement | null;
5
+ viewport: Viewport;
6
+ transform: TransformState;
7
+ annotations: Annotation[];
8
+ cropArea?: CropArea | null;
9
+ onUpdate: (annotations: Annotation[]) => void;
10
+ onClose: () => void;
11
+ onViewportChange?: (viewport: Partial<Viewport>) => void;
12
+ }
13
+ declare const AnnotationTool: import("svelte").Component<Props, {}, "">;
14
+ type AnnotationTool = ReturnType<typeof AnnotationTool>;
15
+ export default AnnotationTool;
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">import { onMount, onDestroy } from 'svelte';
2
- import { drawImage, preloadStampImage, applyStamps } from '../utils/canvas';
2
+ import { drawImage, preloadStampImage, applyStamps, applyAnnotations } from '../utils/canvas';
3
3
  import { initWebGPUCanvas, uploadImageToGPU, renderWithAdjustments, cleanupWebGPU, isWebGPUInitialized } from '../utils/webgpu-render';
4
- let { canvas = $bindable(null), width, height, image, viewport, transform, adjustments, cropArea = null, blurAreas = [], stampAreas = [], onZoom, onViewportChange } = $props();
4
+ let { canvas = $bindable(null), width, height, image, viewport, transform, adjustments, cropArea = null, blurAreas = [], stampAreas = [], annotations = [], onZoom, onViewportChange } = $props();
5
5
  // Constants
6
6
  const PAN_OVERFLOW_MARGIN = 0.2; // Allow 20% overflow when panning
7
7
  // State
@@ -119,6 +119,7 @@ $effect(() => {
119
119
  cropArea?.height;
120
120
  blurAreas;
121
121
  stampAreas;
122
+ annotations;
122
123
  width;
123
124
  height;
124
125
  });
@@ -136,6 +137,7 @@ $effect(() => {
136
137
  cropArea;
137
138
  blurAreas;
138
139
  stampAreas;
140
+ annotations;
139
141
  imageLoadCounter;
140
142
  });
141
143
  // Preload stamp images
@@ -161,18 +163,23 @@ function renderWebGPU() {
161
163
  return;
162
164
  ensureCanvasSize(canvasElement, width, height);
163
165
  renderWithAdjustments(adjustments, viewport, transform, width, height, currentImage.width, currentImage.height, cropArea, blurAreas);
164
- // Render stamps on overlay canvas
165
- if (overlayCanvasElement && stampAreas.length > 0) {
166
+ // Render stamps and annotations on overlay canvas
167
+ if (overlayCanvasElement && (stampAreas.length > 0 || annotations.length > 0)) {
166
168
  ensureCanvasSize(overlayCanvasElement, width, height);
167
169
  // Clear overlay canvas
168
170
  const ctx = overlayCanvasElement.getContext('2d');
169
171
  if (ctx) {
170
172
  ctx.clearRect(0, 0, width, height);
171
- applyStamps(overlayCanvasElement, currentImage, viewport, stampAreas, cropArea);
173
+ if (stampAreas.length > 0) {
174
+ applyStamps(overlayCanvasElement, currentImage, viewport, stampAreas, cropArea);
175
+ }
176
+ if (annotations.length > 0) {
177
+ applyAnnotations(overlayCanvasElement, currentImage, viewport, annotations, cropArea);
178
+ }
172
179
  }
173
180
  }
174
181
  else if (overlayCanvasElement) {
175
- // Clear overlay if no stamps
182
+ // Clear overlay if no stamps or annotations
176
183
  const ctx = overlayCanvasElement.getContext('2d');
177
184
  if (ctx) {
178
185
  ctx.clearRect(0, 0, width, height);
@@ -210,7 +217,7 @@ async function performRender() {
210
217
  return;
211
218
  canvasElement.width = width;
212
219
  canvasElement.height = height;
213
- await drawImage(canvasElement, image, viewport, transform, adjustments, cropArea, blurAreas, stampAreas);
220
+ await drawImage(canvasElement, image, viewport, transform, adjustments, cropArea, blurAreas, stampAreas, annotations);
214
221
  }
215
222
  function handleMouseDown(e) {
216
223
  if (e.button === 0 || e.button === 1) {
@@ -1,4 +1,4 @@
1
- import type { Viewport, TransformState, CropArea, AdjustmentsState, BlurArea, StampArea } from '../types';
1
+ import type { Viewport, TransformState, CropArea, AdjustmentsState, BlurArea, StampArea, Annotation } from '../types';
2
2
  interface Props {
3
3
  canvas?: HTMLCanvasElement | null;
4
4
  width: number;
@@ -10,6 +10,7 @@ interface Props {
10
10
  cropArea?: CropArea | null;
11
11
  blurAreas?: BlurArea[];
12
12
  stampAreas?: StampArea[];
13
+ annotations?: Annotation[];
13
14
  onZoom?: (delta: number, centerX?: number, centerY?: number) => void;
14
15
  onViewportChange?: (viewportUpdate: Partial<Viewport>) => void;
15
16
  }
@@ -11,6 +11,7 @@ import AdjustTool from './AdjustTool.svelte';
11
11
  import FilterTool from './FilterTool.svelte';
12
12
  import BlurTool from './BlurTool.svelte';
13
13
  import StampTool from './StampTool.svelte';
14
+ import AnnotationTool from './AnnotationTool.svelte';
14
15
  import ExportTool from './ExportTool.svelte';
15
16
  let { initialImage, width = 800, height = 600, isStandalone = false, onComplete, onCancel, onExport } = $props();
16
17
  let state = $state({
@@ -41,7 +42,8 @@ let state = $state({
41
42
  },
42
43
  history: createEmptyHistory(),
43
44
  blurAreas: [],
44
- stampAreas: []
45
+ stampAreas: [],
46
+ annotations: []
45
47
  });
46
48
  let canvasElement = $state(null);
47
49
  let fileInput = $state(null);
@@ -80,6 +82,7 @@ $effect(() => {
80
82
  };
81
83
  state.blurAreas = [];
82
84
  state.stampAreas = [];
85
+ state.annotations = [];
83
86
  // Reset history and save initial state
84
87
  state.history = createEmptyHistory();
85
88
  saveToHistory();
@@ -119,6 +122,8 @@ async function handleFileUpload(file) {
119
122
  scale: fitScale
120
123
  };
121
124
  state.blurAreas = [];
125
+ state.stampAreas = [];
126
+ state.annotations = [];
122
127
  // Reset history and save initial state
123
128
  state.history = createEmptyHistory();
124
129
  saveToHistory();
@@ -200,11 +205,15 @@ function handleStampAreasChange(stampAreas) {
200
205
  state.stampAreas = stampAreas;
201
206
  saveToHistory();
202
207
  }
208
+ function handleAnnotationsChange(annotations) {
209
+ state.annotations = annotations;
210
+ saveToHistory();
211
+ }
203
212
  async function handleExport() {
204
213
  if (!state.imageData.original)
205
214
  return;
206
215
  // Use WebGPU for export when available
207
- const exportCanvas = await applyTransformWithWebGPU(state.imageData.original, state.transform, state.adjustments, state.cropArea, state.blurAreas, state.stampAreas);
216
+ const exportCanvas = await applyTransformWithWebGPU(state.imageData.original, state.transform, state.adjustments, state.cropArea, state.blurAreas, state.stampAreas, state.annotations);
208
217
  const dataUrl = exportCanvas.toDataURL(state.exportOptions.format === 'jpeg' ? 'image/jpeg' : 'image/png', state.exportOptions.quality);
209
218
  const filename = `edited-image-${Date.now()}.${state.exportOptions.format}`;
210
219
  downloadImage(dataUrl, filename);
@@ -216,7 +225,7 @@ async function handleComplete() {
216
225
  if (!state.imageData.original || !onComplete)
217
226
  return;
218
227
  // Use WebGPU for export when available
219
- const exportCanvas = await applyTransformWithWebGPU(state.imageData.original, state.transform, state.adjustments, state.cropArea, state.blurAreas, state.stampAreas);
228
+ const exportCanvas = await applyTransformWithWebGPU(state.imageData.original, state.transform, state.adjustments, state.cropArea, state.blurAreas, state.stampAreas, state.annotations);
220
229
  const format = state.exportOptions.format === 'jpeg' ? 'image/jpeg' : 'image/png';
221
230
  const dataUrl = exportCanvas.toDataURL(state.exportOptions.format === 'jpeg' ? 'image/jpeg' : 'image/png', state.exportOptions.quality);
222
231
  // Convert to blob
@@ -271,7 +280,7 @@ function handleZoom(delta, centerX, centerY) {
271
280
  state.viewport.zoom = newZoom;
272
281
  }
273
282
  function saveToHistory() {
274
- const snapshot = createSnapshot(state.cropArea, state.transform, state.adjustments, state.viewport, state.blurAreas, state.stampAreas);
283
+ const snapshot = createSnapshot(state.cropArea, state.transform, state.adjustments, state.viewport, state.blurAreas, state.stampAreas, state.annotations);
275
284
  state.history = addToHistory(state.history, snapshot);
276
285
  }
277
286
  function applySnapshot(snapshot) {
@@ -283,6 +292,10 @@ function applySnapshot(snapshot) {
283
292
  state.viewport = { ...snapshot.viewport };
284
293
  state.blurAreas = snapshot.blurAreas ? snapshot.blurAreas.map((area) => ({ ...area })) : [];
285
294
  state.stampAreas = snapshot.stampAreas ? snapshot.stampAreas.map((area) => ({ ...area })) : [];
295
+ state.annotations = snapshot.annotations ? snapshot.annotations.map((a) => ({
296
+ ...a,
297
+ points: a.points.map((p) => ({ ...p }))
298
+ })) : [];
286
299
  }
287
300
  function handleUndo() {
288
301
  const result = undo(state.history);
@@ -417,6 +430,7 @@ function handleKeyDown(event) {
417
430
  cropArea={state.cropArea}
418
431
  blurAreas={state.blurAreas}
419
432
  stampAreas={state.stampAreas}
433
+ annotations={state.annotations}
420
434
  onZoom={handleZoom}
421
435
  onViewportChange={handleViewportChange}
422
436
  />
@@ -456,6 +470,18 @@ function handleKeyDown(event) {
456
470
  onClose={() => state.mode = null}
457
471
  onViewportChange={handleViewportChange}
458
472
  />
473
+ {:else if state.mode === 'annotate'}
474
+ <AnnotationTool
475
+ canvas={canvasElement}
476
+ image={state.imageData.original}
477
+ viewport={state.viewport}
478
+ transform={state.transform}
479
+ annotations={state.annotations}
480
+ cropArea={state.cropArea}
481
+ onUpdate={handleAnnotationsChange}
482
+ onClose={() => state.mode = null}
483
+ onViewportChange={handleViewportChange}
484
+ />
459
485
  {/if}
460
486
 
461
487
  {#if state.mode === 'adjust'}
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">import { _ } from 'svelte-i18n';
2
- import { Crop, Download, SlidersHorizontal, Sparkles, Droplet, Sticker } from 'lucide-svelte';
2
+ import { Crop, Download, SlidersHorizontal, Sparkles, Droplet, Sticker, PenLine } from 'lucide-svelte';
3
3
  let { mode, hasImage, isStandalone = false, onModeChange, } = $props();
4
4
  </script>
5
5
 
@@ -59,6 +59,17 @@ let { mode, hasImage, isStandalone = false, onModeChange, } = $props();
59
59
  <span>{$_('editor.stamp')}</span>
60
60
  </button>
61
61
 
62
+ <button
63
+ class="toolbar-btn"
64
+ class:active={mode === 'annotate'}
65
+ disabled={!hasImage}
66
+ onclick={() => onModeChange('annotate')}
67
+ title={$_('toolbar.annotate')}
68
+ >
69
+ <PenLine size={20} />
70
+ <span>{$_('editor.annotate')}</span>
71
+ </button>
72
+
62
73
  {#if isStandalone}
63
74
  <button
64
75
  class="toolbar-btn"
@@ -8,6 +8,7 @@
8
8
  "filter": "Filter",
9
9
  "blur": "Blur",
10
10
  "stamp": "Stamp",
11
+ "annotate": "Annotate",
11
12
  "none": "None",
12
13
  "flip": "Flip",
13
14
  "flipHorizontal": "Flip Horizontal",
@@ -37,6 +38,7 @@
37
38
  "filter": "Filters",
38
39
  "blur": "Blur Tool",
39
40
  "stamp": "Stamp Tool",
41
+ "annotate": "Annotation Tool",
40
42
  "export": "Export Image",
41
43
  "undo": "Undo (Ctrl+Z)",
42
44
  "redo": "Redo (Ctrl+Shift+Z)"
@@ -75,5 +77,16 @@
75
77
  "blur": {
76
78
  "strength": "Blur Strength",
77
79
  "hint": "Drag on the image to create a blur area. Click on an existing area to edit it."
80
+ },
81
+ "annotate": {
82
+ "tool": "Tool",
83
+ "pen": "Pen",
84
+ "eraser": "Eraser",
85
+ "arrow": "Arrow",
86
+ "rectangle": "Rectangle",
87
+ "color": "Color",
88
+ "strokeWidth": "Stroke Width",
89
+ "shadow": "Shadow",
90
+ "clearAll": "Clear All"
78
91
  }
79
92
  }
@@ -8,6 +8,7 @@
8
8
  "filter": "フィルター",
9
9
  "blur": "ぼかし",
10
10
  "stamp": "スタンプ",
11
+ "annotate": "ペン",
11
12
  "none": "なし",
12
13
  "flip": "反転",
13
14
  "flipHorizontal": "左右反転",
@@ -37,6 +38,7 @@
37
38
  "filter": "フィルター",
38
39
  "blur": "ぼかしツール",
39
40
  "stamp": "スタンプツール",
41
+ "annotate": "アノテーションツール",
40
42
  "export": "画像をエクスポート",
41
43
  "undo": "元に戻す (Ctrl+Z)",
42
44
  "redo": "やり直す (Ctrl+Shift+Z)"
@@ -75,5 +77,16 @@
75
77
  "blur": {
76
78
  "strength": "ぼかしの強さ",
77
79
  "hint": "画像上をドラッグしてぼかし領域を作成します。既存の領域をクリックすると編集できます。"
80
+ },
81
+ "annotate": {
82
+ "tool": "ツール",
83
+ "pen": "ペン",
84
+ "eraser": "消しゴム",
85
+ "arrow": "矢印",
86
+ "rectangle": "四角形",
87
+ "color": "色",
88
+ "strokeWidth": "線の太さ",
89
+ "shadow": "影",
90
+ "clearAll": "すべて消去"
78
91
  }
79
92
  }
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type EditorMode = 'crop' | 'rotate' | 'adjust' | 'filter' | 'blur' | 'stamp' | 'export' | null;
1
+ export type EditorMode = 'crop' | 'rotate' | 'adjust' | 'filter' | 'blur' | 'stamp' | 'annotate' | 'export' | null;
2
2
  export interface ImageData {
3
3
  original: HTMLImageElement | null;
4
4
  current: HTMLImageElement | null;
@@ -37,6 +37,19 @@ export interface StampArea {
37
37
  stampType: StampType;
38
38
  stampContent: string;
39
39
  }
40
+ export type AnnotationType = 'pen' | 'arrow' | 'rectangle';
41
+ export interface AnnotationPoint {
42
+ x: number;
43
+ y: number;
44
+ }
45
+ export interface Annotation {
46
+ id: string;
47
+ type: AnnotationType;
48
+ color: string;
49
+ strokeWidth: number;
50
+ points: AnnotationPoint[];
51
+ shadow: boolean;
52
+ }
40
53
  export interface TransformState {
41
54
  rotation: number;
42
55
  flipHorizontal: boolean;
@@ -79,6 +92,7 @@ export interface HistorySnapshot {
79
92
  viewport: Viewport;
80
93
  blurAreas: BlurArea[];
81
94
  stampAreas: StampArea[];
95
+ annotations: Annotation[];
82
96
  }
83
97
  export interface EditorHistory {
84
98
  past: HistorySnapshot[];
@@ -96,4 +110,5 @@ export interface EditorState {
96
110
  history: EditorHistory;
97
111
  blurAreas: BlurArea[];
98
112
  stampAreas: StampArea[];
113
+ annotations: Annotation[];
99
114
  }
@@ -1,17 +1,17 @@
1
- import type { CropArea, TransformState, ExportOptions, Viewport, AdjustmentsState, BlurArea, StampArea } from '../types';
1
+ import type { CropArea, TransformState, ExportOptions, Viewport, AdjustmentsState, BlurArea, StampArea, Annotation } from '../types';
2
2
  export declare function preloadStampImage(url: string): Promise<HTMLImageElement>;
3
3
  export declare function getStampImage(url: string): HTMLImageElement | null;
4
4
  export declare function loadImage(file: File): Promise<HTMLImageElement>;
5
5
  export declare function calculateFitScale(imageWidth: number, imageHeight: number, canvasWidth: number, canvasHeight: number): number;
6
- export declare function drawImage(canvas: HTMLCanvasElement, img: HTMLImageElement, viewport: Viewport, transform: TransformState, adjustments: AdjustmentsState, cropArea?: CropArea | null, blurAreas?: BlurArea[], stampAreas?: StampArea[]): Promise<void>;
6
+ export declare function drawImage(canvas: HTMLCanvasElement, img: HTMLImageElement, viewport: Viewport, transform: TransformState, adjustments: AdjustmentsState, cropArea?: CropArea | null, blurAreas?: BlurArea[], stampAreas?: StampArea[], annotations?: Annotation[]): Promise<void>;
7
7
  export declare function exportCanvas(canvas: HTMLCanvasElement, options: ExportOptions): string;
8
8
  export declare function downloadImage(dataUrl: string, filename: string): void;
9
- export declare function applyTransform(img: HTMLImageElement, transform: TransformState, adjustments: AdjustmentsState, cropArea?: CropArea | null, blurAreas?: BlurArea[], stampAreas?: StampArea[]): Promise<HTMLCanvasElement>;
9
+ export declare function applyTransform(img: HTMLImageElement, transform: TransformState, adjustments: AdjustmentsState, cropArea?: CropArea | null, blurAreas?: BlurArea[], stampAreas?: StampArea[], annotations?: Annotation[]): Promise<HTMLCanvasElement>;
10
10
  /**
11
11
  * Apply all transformations and export using WebGPU (when available)
12
12
  * Falls back to Canvas2D if WebGPU is not supported
13
13
  */
14
- export declare function applyTransformWithWebGPU(img: HTMLImageElement, transform: TransformState, adjustments: AdjustmentsState, cropArea?: CropArea | null, blurAreas?: BlurArea[], stampAreas?: StampArea[]): Promise<HTMLCanvasElement>;
14
+ export declare function applyTransformWithWebGPU(img: HTMLImageElement, transform: TransformState, adjustments: AdjustmentsState, cropArea?: CropArea | null, blurAreas?: BlurArea[], stampAreas?: StampArea[], annotations?: Annotation[]): Promise<HTMLCanvasElement>;
15
15
  export declare function screenToImageCoords(screenX: number, screenY: number, canvas: HTMLCanvasElement, img: HTMLImageElement, viewport: Viewport, transform: TransformState): {
16
16
  x: number;
17
17
  y: number;
@@ -33,3 +33,8 @@ export declare function applyBlurAreas(canvas: HTMLCanvasElement, img: HTMLImage
33
33
  * Apply stamp decorations to the canvas
34
34
  */
35
35
  export declare function applyStamps(canvas: HTMLCanvasElement, img: HTMLImageElement, viewport: Viewport, stampAreas: StampArea[], cropArea?: CropArea | null): void;
36
+ /**
37
+ * Apply annotation drawings to the canvas
38
+ * Supports pen strokes, arrows, and rectangles
39
+ */
40
+ export declare function applyAnnotations(canvas: HTMLCanvasElement, img: HTMLImageElement, viewport: Viewport, annotations: Annotation[], cropArea?: CropArea | null): void;
@@ -42,7 +42,7 @@ export function calculateFitScale(imageWidth, imageHeight, canvasWidth, canvasHe
42
42
  const scaleY = canvasHeight / imageHeight;
43
43
  return Math.min(scaleX, scaleY, 1); // Don't scale up, only down
44
44
  }
45
- export async function drawImage(canvas, img, viewport, transform, adjustments, cropArea, blurAreas, stampAreas) {
45
+ export async function drawImage(canvas, img, viewport, transform, adjustments, cropArea, blurAreas, stampAreas, annotations) {
46
46
  const ctx = canvas.getContext('2d');
47
47
  if (!ctx)
48
48
  return;
@@ -83,6 +83,10 @@ export async function drawImage(canvas, img, viewport, transform, adjustments, c
83
83
  if (stampAreas && stampAreas.length > 0) {
84
84
  applyStamps(canvas, img, viewport, stampAreas, cropArea);
85
85
  }
86
+ // Apply annotations
87
+ if (annotations && annotations.length > 0) {
88
+ applyAnnotations(canvas, img, viewport, annotations, cropArea);
89
+ }
86
90
  }
87
91
  export function exportCanvas(canvas, options) {
88
92
  if (options.format === 'jpeg') {
@@ -96,7 +100,7 @@ export function downloadImage(dataUrl, filename) {
96
100
  link.href = dataUrl;
97
101
  link.click();
98
102
  }
99
- export async function applyTransform(img, transform, adjustments, cropArea = null, blurAreas = [], stampAreas = []) {
103
+ export async function applyTransform(img, transform, adjustments, cropArea = null, blurAreas = [], stampAreas = [], annotations = []) {
100
104
  const canvas = document.createElement('canvas');
101
105
  const ctx = canvas.getContext('2d');
102
106
  if (!ctx)
@@ -139,22 +143,26 @@ export async function applyTransform(img, transform, adjustments, cropArea = nul
139
143
  if (stampAreas.length > 0) {
140
144
  applyStamps(canvas, img, exportViewport, stampAreas, cropArea);
141
145
  }
146
+ // Apply annotations for export
147
+ if (annotations.length > 0) {
148
+ applyAnnotations(canvas, img, exportViewport, annotations, cropArea);
149
+ }
142
150
  return canvas;
143
151
  }
144
152
  /**
145
153
  * Apply all transformations and export using WebGPU (when available)
146
154
  * Falls back to Canvas2D if WebGPU is not supported
147
155
  */
148
- export async function applyTransformWithWebGPU(img, transform, adjustments, cropArea = null, blurAreas = [], stampAreas = []) {
156
+ export async function applyTransformWithWebGPU(img, transform, adjustments, cropArea = null, blurAreas = [], stampAreas = [], annotations = []) {
149
157
  // Try WebGPU export first
150
158
  if (navigator.gpu) {
151
159
  try {
152
160
  const { exportWithWebGPU } = await import('./webgpu-render');
153
161
  const webgpuCanvas = await exportWithWebGPU(img, adjustments, transform, cropArea, blurAreas);
154
162
  if (webgpuCanvas) {
155
- // Apply stamps on top (WebGPU doesn't handle stamps yet)
156
- if (stampAreas.length > 0) {
157
- // Create a new Canvas2D to composite WebGPU result + stamps
163
+ // Apply stamps and annotations on top (WebGPU doesn't handle these yet)
164
+ if (stampAreas.length > 0 || annotations.length > 0) {
165
+ // Create a new Canvas2D to composite WebGPU result + stamps + annotations
158
166
  const finalCanvas = document.createElement('canvas');
159
167
  finalCanvas.width = webgpuCanvas.width;
160
168
  finalCanvas.height = webgpuCanvas.height;
@@ -169,7 +177,12 @@ export async function applyTransformWithWebGPU(img, transform, adjustments, crop
169
177
  offsetY: 0,
170
178
  scale: 1
171
179
  };
172
- applyStamps(finalCanvas, img, exportViewport, stampAreas, cropArea);
180
+ if (stampAreas.length > 0) {
181
+ applyStamps(finalCanvas, img, exportViewport, stampAreas, cropArea);
182
+ }
183
+ if (annotations.length > 0) {
184
+ applyAnnotations(finalCanvas, img, exportViewport, annotations, cropArea);
185
+ }
173
186
  return finalCanvas;
174
187
  }
175
188
  }
@@ -181,7 +194,7 @@ export async function applyTransformWithWebGPU(img, transform, adjustments, crop
181
194
  }
182
195
  }
183
196
  // Fallback to Canvas2D
184
- return applyTransform(img, transform, adjustments, cropArea, blurAreas, stampAreas);
197
+ return applyTransform(img, transform, adjustments, cropArea, blurAreas, stampAreas, annotations);
185
198
  }
186
199
  export function screenToImageCoords(screenX, screenY, canvas, img, viewport, transform) {
187
200
  const rect = canvas.getBoundingClientRect();
@@ -334,3 +347,122 @@ export function applyStamps(canvas, img, viewport, stampAreas, cropArea) {
334
347
  ctx.restore();
335
348
  });
336
349
  }
350
+ /**
351
+ * Apply annotation drawings to the canvas
352
+ * Supports pen strokes, arrows, and rectangles
353
+ */
354
+ export function applyAnnotations(canvas, img, viewport, annotations, cropArea) {
355
+ const ctx = canvas.getContext('2d');
356
+ if (!ctx)
357
+ return;
358
+ const centerX = canvas.width / 2;
359
+ const centerY = canvas.height / 2;
360
+ const totalScale = viewport.scale * viewport.zoom;
361
+ // Determine source dimensions based on crop
362
+ const sourceWidth = cropArea ? cropArea.width : img.width;
363
+ const sourceHeight = cropArea ? cropArea.height : img.height;
364
+ const offsetX = cropArea ? cropArea.x : 0;
365
+ const offsetY = cropArea ? cropArea.y : 0;
366
+ // Helper to convert image coords to canvas coords
367
+ const toCanvasCoords = (x, y) => {
368
+ const relativeX = x - offsetX;
369
+ const relativeY = y - offsetY;
370
+ return {
371
+ x: (relativeX - sourceWidth / 2) * totalScale + centerX + viewport.offsetX,
372
+ y: (relativeY - sourceHeight / 2) * totalScale + centerY + viewport.offsetY
373
+ };
374
+ };
375
+ annotations.forEach(annotation => {
376
+ if (annotation.points.length === 0)
377
+ return;
378
+ ctx.save();
379
+ ctx.strokeStyle = annotation.color;
380
+ ctx.fillStyle = annotation.color;
381
+ ctx.lineWidth = annotation.strokeWidth * totalScale;
382
+ ctx.lineCap = 'round';
383
+ ctx.lineJoin = 'round';
384
+ // Apply shadow if enabled
385
+ if (annotation.shadow) {
386
+ ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
387
+ ctx.shadowBlur = 6;
388
+ ctx.shadowOffsetX = 3;
389
+ ctx.shadowOffsetY = 3;
390
+ }
391
+ if (annotation.type === 'pen') {
392
+ // Draw pen stroke with smooth curves
393
+ const points = annotation.points.map(p => toCanvasCoords(p.x, p.y));
394
+ if (points.length === 0)
395
+ return;
396
+ ctx.beginPath();
397
+ ctx.moveTo(points[0].x, points[0].y);
398
+ if (points.length === 1) {
399
+ // Single point - just a dot
400
+ ctx.lineTo(points[0].x, points[0].y);
401
+ }
402
+ else if (points.length === 2) {
403
+ // Two points - straight line
404
+ ctx.lineTo(points[1].x, points[1].y);
405
+ }
406
+ else {
407
+ // Use quadratic bezier curves for smooth lines
408
+ for (let i = 1; i < points.length - 1; i++) {
409
+ const prev = points[i - 1];
410
+ const curr = points[i];
411
+ const next = points[i + 1];
412
+ if (i === 1) {
413
+ // First segment: line to first midpoint
414
+ const firstMidX = (prev.x + curr.x) / 2;
415
+ const firstMidY = (prev.y + curr.y) / 2;
416
+ ctx.lineTo(firstMidX, firstMidY);
417
+ }
418
+ // Calculate end point (midpoint between current and next)
419
+ const endX = (curr.x + next.x) / 2;
420
+ const endY = (curr.y + next.y) / 2;
421
+ // Quadratic bezier curve with current point as control point
422
+ ctx.quadraticCurveTo(curr.x, curr.y, endX, endY);
423
+ }
424
+ // Final segment to last point
425
+ const lastPoint = points[points.length - 1];
426
+ ctx.lineTo(lastPoint.x, lastPoint.y);
427
+ }
428
+ ctx.stroke();
429
+ }
430
+ else if (annotation.type === 'arrow' && annotation.points.length >= 2) {
431
+ // Draw arrow
432
+ const start = toCanvasCoords(annotation.points[0].x, annotation.points[0].y);
433
+ const end = toCanvasCoords(annotation.points[1].x, annotation.points[1].y);
434
+ const angle = Math.atan2(end.y - start.y, end.x - start.x);
435
+ const scaledStroke = annotation.strokeWidth * totalScale;
436
+ const headLength = scaledStroke * 3;
437
+ const headWidth = scaledStroke * 2;
438
+ // Draw line (shortened to not overlap with arrowhead)
439
+ const lineEndX = end.x - headLength * 0.7 * Math.cos(angle);
440
+ const lineEndY = end.y - headLength * 0.7 * Math.sin(angle);
441
+ ctx.beginPath();
442
+ ctx.moveTo(start.x, start.y);
443
+ ctx.lineTo(lineEndX, lineEndY);
444
+ ctx.stroke();
445
+ // Draw arrowhead
446
+ ctx.beginPath();
447
+ ctx.moveTo(end.x, end.y);
448
+ ctx.lineTo(end.x - headLength * Math.cos(angle) + headWidth * Math.sin(angle), end.y - headLength * Math.sin(angle) - headWidth * Math.cos(angle));
449
+ ctx.lineTo(end.x - headLength * Math.cos(angle) - headWidth * Math.sin(angle), end.y - headLength * Math.sin(angle) + headWidth * Math.cos(angle));
450
+ ctx.closePath();
451
+ ctx.fill();
452
+ }
453
+ else if (annotation.type === 'rectangle' && annotation.points.length >= 2) {
454
+ // Draw rounded rectangle
455
+ const start = toCanvasCoords(annotation.points[0].x, annotation.points[0].y);
456
+ const end = toCanvasCoords(annotation.points[1].x, annotation.points[1].y);
457
+ const rectX = Math.min(start.x, end.x);
458
+ const rectY = Math.min(start.y, end.y);
459
+ const rectWidth = Math.abs(end.x - start.x);
460
+ const rectHeight = Math.abs(end.y - start.y);
461
+ const cornerRadius = annotation.strokeWidth * totalScale * 1.5;
462
+ ctx.beginPath();
463
+ ctx.roundRect(rectX, rectY, rectWidth, rectHeight, cornerRadius);
464
+ ctx.stroke();
465
+ }
466
+ ctx.restore();
467
+ });
468
+ }
@@ -1,7 +1,7 @@
1
1
  import type { HistorySnapshot, EditorHistory } from '../types';
2
2
  export declare const MAX_HISTORY_SIZE = 50;
3
3
  export declare function createEmptyHistory(): EditorHistory;
4
- export declare function createSnapshot(cropArea: any, transform: any, adjustments: any, viewport: any, blurAreas?: any[], stampAreas?: any[]): HistorySnapshot;
4
+ export declare function createSnapshot(cropArea: any, transform: any, adjustments: any, viewport: any, blurAreas?: any[], stampAreas?: any[], annotations?: any[]): HistorySnapshot;
5
5
  export declare function addToHistory(history: EditorHistory, snapshot: HistorySnapshot): EditorHistory;
6
6
  export declare function undo(history: EditorHistory): {
7
7
  history: EditorHistory;
@@ -7,14 +7,18 @@ export function createEmptyHistory() {
7
7
  future: []
8
8
  };
9
9
  }
10
- export function createSnapshot(cropArea, transform, adjustments, viewport, blurAreas = [], stampAreas = []) {
10
+ export function createSnapshot(cropArea, transform, adjustments, viewport, blurAreas = [], stampAreas = [], annotations = []) {
11
11
  return {
12
12
  cropArea: cropArea ? { ...cropArea } : null,
13
13
  transform: { ...transform },
14
14
  adjustments: { ...adjustments },
15
15
  viewport: { ...viewport },
16
16
  blurAreas: blurAreas.map(area => ({ ...area })),
17
- stampAreas: stampAreas.map(area => ({ ...area }))
17
+ stampAreas: stampAreas.map(area => ({ ...area })),
18
+ annotations: annotations.map(annotation => ({
19
+ ...annotation,
20
+ points: annotation.points.map((p) => ({ ...p }))
21
+ }))
18
22
  };
19
23
  }
20
24
  export function addToHistory(history, snapshot) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokimeki-image-editor",
3
- "version": "0.1.13",
3
+ "version": "0.2.1",
4
4
  "description": "A image editor for svelte.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,59 +0,0 @@
1
- struct VertexOutput {
2
- @builtin(position) position: vec4<f32>,
3
- @location(0) uv: vec2<f32>,
4
- };
5
-
6
- struct BlurUniforms {
7
- direction: vec2<f32>, // (1, 0) for horizontal, (0, 1) for vertical
8
- radius: f32,
9
- padding: f32,
10
- };
11
-
12
- @group(0) @binding(0) var mySampler: sampler;
13
- @group(0) @binding(1) var myTexture: texture_2d<f32>;
14
- @group(0) @binding(2) var<uniform> blur: BlurUniforms;
15
-
16
- @vertex
17
- fn vs_main(@builtin(vertex_index) VertexIndex: u32) -> VertexOutput {
18
- var pos = array<vec2<f32>, 3>(
19
- vec2<f32>(-1.0, -1.0),
20
- vec2<f32>( 3.0, -1.0),
21
- vec2<f32>(-1.0, 3.0)
22
- );
23
- var output: VertexOutput;
24
- output.position = vec4<f32>(pos[VertexIndex], 0.0, 1.0);
25
- // Convert NDC to UV with Y-flip (NDC bottom-left maps to UV top-left)
26
- let uv = pos[VertexIndex] * 0.5 + 0.5;
27
- output.uv = vec2<f32>(uv.x, 1.0 - uv.y);
28
- return output;
29
- }
30
-
31
- @fragment
32
- fn fs_main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
33
- let r = i32(blur.radius);
34
-
35
- // Pass-through if radius is 0
36
- if (r == 0) {
37
- return textureSample(myTexture, mySampler, uv);
38
- }
39
-
40
- let texSize = vec2<f32>(textureDimensions(myTexture));
41
- let texelSize = 1.0 / texSize;
42
-
43
- // Gaussian blur weights (for radius up to 10)
44
- // Pre-normalized weights for different radii
45
- var color = vec4<f32>(0.0);
46
-
47
- // Simple box blur for now (equal weights)
48
- // TODO: Use proper Gaussian weights
49
- let totalSamples = f32(r * 2 + 1);
50
-
51
- // Sample along the blur direction
52
- for (var i = -r; i <= r; i = i + 1) {
53
- let offset = vec2<f32>(f32(i)) * blur.direction * texelSize;
54
- let sampleUV = uv + offset;
55
- color = color + textureSample(myTexture, mySampler, sampleUV) / totalSamples;
56
- }
57
-
58
- return color;
59
- }