tokimeki-image-editor 0.1.13 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/AnnotationTool.svelte +844 -0
- package/dist/components/AnnotationTool.svelte.d.ts +15 -0
- package/dist/components/Canvas.svelte +14 -7
- package/dist/components/Canvas.svelte.d.ts +2 -1
- package/dist/components/ImageEditor.svelte +30 -4
- package/dist/components/Toolbar.svelte +12 -1
- package/dist/i18n/locales/en.json +13 -0
- package/dist/i18n/locales/ja.json +13 -0
- package/dist/types.d.ts +16 -1
- package/dist/utils/canvas.d.ts +9 -4
- package/dist/utils/canvas.js +140 -8
- package/dist/utils/history.d.ts +1 -1
- package/dist/utils/history.js +6 -2
- package/package.json +1 -1
- package/dist/shaders/blur.wgsl +0 -59
- package/dist/shaders/composite.wgsl +0 -46
- package/dist/shaders/grain.wgsl +0 -225
- package/dist/shaders/image-editor.wgsl +0 -296
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/utils/canvas.d.ts
CHANGED
|
@@ -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;
|
package/dist/utils/canvas.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/utils/history.d.ts
CHANGED
|
@@ -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;
|
package/dist/utils/history.js
CHANGED
|
@@ -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
package/dist/shaders/blur.wgsl
DELETED
|
@@ -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
|
-
}
|