@pixerate/editor-svelte 0.115.0 → 0.117.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/image-editor/CanvasSizePanel.svelte +171 -0
- package/dist/image-editor/CanvasSizePanel.svelte.d.ts +8 -0
- package/dist/image-editor/ImageEditorCanvas.svelte +188 -10
- package/dist/image-editor/ImageEditorCanvas.svelte.d.ts +6 -2
- package/dist/image-editor/LayerPanel.svelte +98 -0
- package/dist/image-editor/LayerPanel.svelte.d.ts +9 -0
- package/dist/image-editor/canvasTypes.d.ts +2 -2
- package/dist/image-editor/index.d.ts +6 -0
- package/dist/image-editor/index.js +2 -0
- package/package.json +2 -2
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { ImageEditorController, TransformAnchor } from '@pixerate/editor/image-editor';
|
|
3
|
+
import { CANVAS_SIZE_PRESETS, isTransformAnchor } from '@pixerate/editor/image-editor';
|
|
4
|
+
import { untrack } from 'svelte';
|
|
5
|
+
import { imagePrimaryButtonClass, imageSelectClass } from './styles.js';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
controller: ImageEditorController;
|
|
9
|
+
class?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let { controller, class: className = '' }: Props = $props();
|
|
13
|
+
|
|
14
|
+
let tick = $state(0);
|
|
15
|
+
$effect(() => {
|
|
16
|
+
const c = controller;
|
|
17
|
+
return untrack(() => c.subscribe(() => tick++));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const natural = $derived.by(() => {
|
|
21
|
+
void tick;
|
|
22
|
+
return controller.getCanvasSize();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
let width = $state(800);
|
|
26
|
+
let height = $state(600);
|
|
27
|
+
let lockAspect = $state(false);
|
|
28
|
+
let anchor = $state<TransformAnchor>('center');
|
|
29
|
+
let error = $state<string | null>(null);
|
|
30
|
+
|
|
31
|
+
// Sync initial dimensions with current canvas size
|
|
32
|
+
$effect(() => {
|
|
33
|
+
width = natural.width;
|
|
34
|
+
height = natural.height;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const ANCHOR_GRID: TransformAnchor[][] = [
|
|
38
|
+
['top-left', 'top', 'top-right'],
|
|
39
|
+
['left', 'center', 'right'],
|
|
40
|
+
['bottom-left', 'bottom', 'bottom-right'],
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
function onWidthChange(val: number) {
|
|
44
|
+
if (lockAspect && natural.width > 0) {
|
|
45
|
+
const ratio = natural.height / natural.width;
|
|
46
|
+
height = Math.max(1, Math.round(val * ratio));
|
|
47
|
+
}
|
|
48
|
+
width = val;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function onHeightChange(val: number) {
|
|
52
|
+
if (lockAspect && natural.height > 0) {
|
|
53
|
+
const ratio = natural.width / natural.height;
|
|
54
|
+
width = Math.max(1, Math.round(val * ratio));
|
|
55
|
+
}
|
|
56
|
+
height = val;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function onPresetSelect(e: Event) {
|
|
60
|
+
const target = e.currentTarget as HTMLSelectElement;
|
|
61
|
+
const presetId = target.value;
|
|
62
|
+
target.value = '';
|
|
63
|
+
const preset = CANVAS_SIZE_PRESETS.find((p) => p.id === presetId);
|
|
64
|
+
if (!preset) return;
|
|
65
|
+
width = preset.width;
|
|
66
|
+
height = preset.height;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
function apply() {
|
|
71
|
+
error = null;
|
|
72
|
+
try {
|
|
73
|
+
controller.resizeCanvas({ width, height, anchor });
|
|
74
|
+
} catch (err) {
|
|
75
|
+
error = err instanceof Error ? err.message : String(err);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
</script>
|
|
79
|
+
|
|
80
|
+
<div class="flex flex-col gap-3 text-xs text-foreground {className}" data-image-panel="canvas-size" data-testid="canvas-size-panel">
|
|
81
|
+
<div class="font-medium text-sm text-foreground">Canvas Size</div>
|
|
82
|
+
|
|
83
|
+
{#if error}
|
|
84
|
+
<div class="text-xs text-destructive bg-destructive/10 p-1.5 rounded">{error}</div>
|
|
85
|
+
{/if}
|
|
86
|
+
|
|
87
|
+
<div class="flex flex-col gap-1.5">
|
|
88
|
+
<label for="canvas-size-preset" class="text-muted-foreground text-[11px]">Preset</label>
|
|
89
|
+
<select
|
|
90
|
+
id="canvas-size-preset"
|
|
91
|
+
class="{imageSelectClass} w-full"
|
|
92
|
+
onchange={onPresetSelect}
|
|
93
|
+
aria-label="Preset canvas sizes"
|
|
94
|
+
>
|
|
95
|
+
<option value="">Choose preset…</option>
|
|
96
|
+
{#each CANVAS_SIZE_PRESETS as preset (preset.id)}
|
|
97
|
+
<option value={preset.id}>{preset.label} ({preset.width} × {preset.height})</option>
|
|
98
|
+
{/each}
|
|
99
|
+
</select>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<div class="grid grid-cols-2 gap-2 items-center">
|
|
103
|
+
<div class="flex flex-col gap-1">
|
|
104
|
+
<label for="canvas-size-width" class="text-muted-foreground text-[11px]">Width (px)</label>
|
|
105
|
+
<input
|
|
106
|
+
id="canvas-size-width"
|
|
107
|
+
type="number"
|
|
108
|
+
min="1"
|
|
109
|
+
max="16384"
|
|
110
|
+
class="w-full bg-background border border-border rounded px-2 py-1 text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
|
111
|
+
value={width}
|
|
112
|
+
oninput={(e) => onWidthChange(Number((e.currentTarget as HTMLInputElement).value))}
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<div class="flex flex-col gap-1">
|
|
117
|
+
<label for="canvas-size-height" class="text-muted-foreground text-[11px]">Height (px)</label>
|
|
118
|
+
<input
|
|
119
|
+
id="canvas-size-height"
|
|
120
|
+
type="number"
|
|
121
|
+
min="1"
|
|
122
|
+
max="16384"
|
|
123
|
+
class="w-full bg-background border border-border rounded px-2 py-1 text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
|
124
|
+
value={height}
|
|
125
|
+
oninput={(e) => onHeightChange(Number((e.currentTarget as HTMLInputElement).value))}
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div class="flex items-center gap-2">
|
|
131
|
+
<label class="flex items-center gap-1.5 cursor-pointer text-muted-foreground select-none">
|
|
132
|
+
<input
|
|
133
|
+
type="checkbox"
|
|
134
|
+
class="rounded border-border text-primary focus:ring-primary"
|
|
135
|
+
checked={lockAspect}
|
|
136
|
+
onchange={(e) => (lockAspect = (e.currentTarget as HTMLInputElement).checked)}
|
|
137
|
+
/>
|
|
138
|
+
<span>Lock aspect ratio</span>
|
|
139
|
+
</label>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<div class="flex flex-col gap-1.5">
|
|
143
|
+
<span class="text-muted-foreground text-[11px]">Anchor</span>
|
|
144
|
+
<div class="grid grid-cols-3 gap-1 w-24 h-24 p-1 bg-muted/40 border border-border rounded" role="radiogroup" aria-label="Resize anchor">
|
|
145
|
+
{#each ANCHOR_GRID as row}
|
|
146
|
+
{#each row as a (a)}
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
role="radio"
|
|
150
|
+
aria-checked={anchor === a}
|
|
151
|
+
aria-label={a}
|
|
152
|
+
class="w-full h-full rounded flex items-center justify-center transition-colors {anchor === a ? 'bg-primary text-primary-foreground' : 'bg-background hover:bg-muted text-muted-foreground'}"
|
|
153
|
+
onclick={() => (anchor = a)}
|
|
154
|
+
>
|
|
155
|
+
<div class="w-1.5 h-1.5 rounded-full {anchor === a ? 'bg-primary-foreground' : 'bg-muted-foreground/60'}"></div>
|
|
156
|
+
</button>
|
|
157
|
+
{/each}
|
|
158
|
+
{/each}
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div class="pt-2">
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
class="{imagePrimaryButtonClass} w-full"
|
|
166
|
+
onclick={apply}
|
|
167
|
+
>
|
|
168
|
+
Apply Canvas Size
|
|
169
|
+
</button>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ImageEditorController } from '@pixerate/editor/image-editor';
|
|
2
|
+
interface Props {
|
|
3
|
+
controller: ImageEditorController;
|
|
4
|
+
class?: string;
|
|
5
|
+
}
|
|
6
|
+
declare const CanvasSizePanel: import("svelte").Component<Props, {}, "">;
|
|
7
|
+
type CanvasSizePanel = ReturnType<typeof CanvasSizePanel>;
|
|
8
|
+
export default CanvasSizePanel;
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
handlePaintShortcut,
|
|
17
17
|
pointerPressure,
|
|
18
18
|
FreeTransformTool,
|
|
19
|
+
boxFrameHit,
|
|
20
|
+
boxFrameOverlay,
|
|
19
21
|
getViewportProjection,
|
|
20
22
|
measureCanvasViewport,
|
|
21
23
|
watchCanvasViewport,
|
|
@@ -26,6 +28,7 @@
|
|
|
26
28
|
type SelectionToolOptions,
|
|
27
29
|
type ShapeToolOptions,
|
|
28
30
|
type TextEditSession,
|
|
31
|
+
type TransformOverlay,
|
|
29
32
|
type TypeToolOptions,
|
|
30
33
|
} from '@pixerate/editor/image-editor';
|
|
31
34
|
import { untrack } from 'svelte';
|
|
@@ -86,8 +89,10 @@
|
|
|
86
89
|
locate?: (ref: PlaybackRef) => PlaybackRect | null;
|
|
87
90
|
/** Your own element for a playback ref, anywhere on the page; its box is mapped to document pixels. */
|
|
88
91
|
locateElement?: (ref: PlaybackRef) => Element | null;
|
|
89
|
-
/** Show a panel for an `open` step (default: click its `[data-image-tab]` under `chromeRoot`). */
|
|
92
|
+
/** Show a panel for an `open` step (default: click its `[data-image-tab]` or `[data-image-panel]` under `chromeRoot`). */
|
|
90
93
|
open?: (ref: PlaybackRef) => void | Promise<void>;
|
|
94
|
+
/** Whether wheel zoom and middle/space drag panning are enabled (default true). */
|
|
95
|
+
viewportGestures?: boolean;
|
|
91
96
|
class?: string;
|
|
92
97
|
style?: string;
|
|
93
98
|
}
|
|
@@ -112,10 +117,16 @@
|
|
|
112
117
|
locate,
|
|
113
118
|
locateElement,
|
|
114
119
|
open,
|
|
120
|
+
viewportGestures = true,
|
|
115
121
|
class: className = '',
|
|
116
122
|
style = '',
|
|
117
123
|
}: Props = $props();
|
|
118
124
|
|
|
125
|
+
/** Fits the image to view, resetting zoom to 1 and pan to (0, 0). */
|
|
126
|
+
export function fitToView(): void {
|
|
127
|
+
editor.controller.resetZoom();
|
|
128
|
+
}
|
|
129
|
+
|
|
119
130
|
let canvasEl = $state<HTMLCanvasElement | null>(null);
|
|
120
131
|
let containerEl = $state<HTMLDivElement | null>(null);
|
|
121
132
|
let rootEl = $state<HTMLDivElement | null>(null);
|
|
@@ -187,7 +198,64 @@
|
|
|
187
198
|
reveal: () => onplaybackreveal?.(),
|
|
188
199
|
}));
|
|
189
200
|
|
|
190
|
-
|
|
201
|
+
let isSpacePressed = $state(false);
|
|
202
|
+
let isPanning = $state(false);
|
|
203
|
+
let panStartPoint: { clientX: number; clientY: number } | null = null;
|
|
204
|
+
let panStartOffset: { x: number; y: number } = { x: 0, y: 0 };
|
|
205
|
+
|
|
206
|
+
function handleWheel(e: WheelEvent) {
|
|
207
|
+
if (!viewportGestures || !canvasEl) return;
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
|
|
210
|
+
const rect = canvasEl.getBoundingClientRect();
|
|
211
|
+
const sx = rect.width > 0 ? view.width / rect.width : 1;
|
|
212
|
+
const sy = rect.height > 0 ? view.height / rect.height : 1;
|
|
213
|
+
const canvasPt = {
|
|
214
|
+
x: (e.clientX - rect.left) * sx,
|
|
215
|
+
y: (e.clientY - rect.top) * sy,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const s = editor.controller.getState();
|
|
219
|
+
const currentZoom = s.zoom || 1;
|
|
220
|
+
const factor = e.deltaY < 0 ? 1.1 : 0.9;
|
|
221
|
+
const nextZoom = Math.min(10, Math.max(0.1, currentZoom * factor));
|
|
222
|
+
|
|
223
|
+
// Zoom centered at cursor:
|
|
224
|
+
// in canvas coordinates: img = (canvasPt - pan - center) / zoom
|
|
225
|
+
// We want canvasPt to remain invariant after zoom:
|
|
226
|
+
// (canvasPt - center - newPan) / newZoom = (canvasPt - center - oldPan) / oldZoom
|
|
227
|
+
// newPan = canvasPt - center - (canvasPt - center - oldPan) * (newZoom / oldZoom)
|
|
228
|
+
const centerX = view.width / 2;
|
|
229
|
+
const centerY = view.height / 2;
|
|
230
|
+
const curPan = s.pan || { x: 0, y: 0 };
|
|
231
|
+
const ratio = nextZoom / currentZoom;
|
|
232
|
+
|
|
233
|
+
const newPanX = canvasPt.x - centerX - (canvasPt.x - centerX - curPan.x) * ratio;
|
|
234
|
+
const newPanY = canvasPt.y - centerY - (canvasPt.y - centerY - curPan.y) * ratio;
|
|
235
|
+
|
|
236
|
+
editor.controller.setZoom(nextZoom);
|
|
237
|
+
editor.controller.setPan({ x: Math.round(newPanX), y: Math.round(newPanY) });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
$effect(() => {
|
|
241
|
+
if (typeof window === 'undefined') return;
|
|
242
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
243
|
+
if (e.code === 'Space' && !e.repeat && document.activeElement?.tagName !== 'INPUT' && document.activeElement?.tagName !== 'TEXTAREA') {
|
|
244
|
+
isSpacePressed = true;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
const onKeyUp = (e: KeyboardEvent) => {
|
|
248
|
+
if (e.code === 'Space') {
|
|
249
|
+
isSpacePressed = false;
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
window.addEventListener('keydown', onKeyDown);
|
|
253
|
+
window.addEventListener('keyup', onKeyUp);
|
|
254
|
+
return () => {
|
|
255
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
256
|
+
window.removeEventListener('keyup', onKeyUp);
|
|
257
|
+
};
|
|
258
|
+
});
|
|
191
259
|
const selectionGesture = $derived(new SelectionGesture(editor.controller, () => selectionOptions));
|
|
192
260
|
let selectionDraft = $state.raw<ImagePoint[] | null>(null);
|
|
193
261
|
// Paint tools (Move, Brush, Eraser): the pointer gesture and the brush cursor.
|
|
@@ -278,6 +346,63 @@
|
|
|
278
346
|
return () => window.removeEventListener('keydown', onKey);
|
|
279
347
|
});
|
|
280
348
|
|
|
349
|
+
/** How far outside a frame (in hit tolerances) a drag rotates instead of picking a layer. */
|
|
350
|
+
const ROTATE_BAND = 4;
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Transform mode, with no transform open: the active layer's frame, drawn as a transform's.
|
|
354
|
+
* A layer locked in place gets the outline without handles.
|
|
355
|
+
*/
|
|
356
|
+
function layerFrame(): TransformOverlay | null {
|
|
357
|
+
if (mode !== 'transform' || transformTool.active) return null;
|
|
358
|
+
const c = editor.controller;
|
|
359
|
+
const id = c.getState().activeLayerId;
|
|
360
|
+
const layer = id ? c.getLayer(id) : undefined;
|
|
361
|
+
if (!layer || layer.visible === false) return null;
|
|
362
|
+
const bounds = c.getLayerBounds(layer.id);
|
|
363
|
+
if (!bounds) return null;
|
|
364
|
+
return boxFrameOverlay(bounds, !(layer.locks?.all || layer.locks?.position));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Transform mode: a drag on the active layer's frame transforms it, and a press elsewhere picks
|
|
369
|
+
* the layer there (or none) and drags that. Each drag opens a Free Transform and closes it on
|
|
370
|
+
* release, one undo step when it changed something, so other edits never meet an open one.
|
|
371
|
+
*/
|
|
372
|
+
let frameDrag = false;
|
|
373
|
+
function handleFramePointerDown(e: PointerEvent): boolean {
|
|
374
|
+
if (mode !== 'transform' || transformTool.active || !canvasEl) return false;
|
|
375
|
+
// Middle-button and Space drags pan (see handleMouseDown).
|
|
376
|
+
if (e.button !== 0 || (viewportGestures && isSpacePressed)) return false;
|
|
377
|
+
const c = editor.controller;
|
|
378
|
+
const { pt, tolerance } = transformPoint(e);
|
|
379
|
+
const frame = layerFrame();
|
|
380
|
+
let target = frame && boxFrameHit(frame, pt, tolerance, tolerance * ROTATE_BAND) ? c.getState().activeLayerId : null;
|
|
381
|
+
if (!target) {
|
|
382
|
+
target = c.layerAt(pt);
|
|
383
|
+
c.setActiveLayer(target);
|
|
384
|
+
}
|
|
385
|
+
const layer = target ? c.getLayer(target) : undefined;
|
|
386
|
+
if (!layer || layer.locks?.all || layer.locks?.position) return true;
|
|
387
|
+
e.preventDefault();
|
|
388
|
+
if (transformTool.begin({ layerId: layer.id })) return true;
|
|
389
|
+
try {
|
|
390
|
+
canvasEl.setPointerCapture?.(e.pointerId);
|
|
391
|
+
} catch {
|
|
392
|
+
// Synthetic events have no active pointer to capture.
|
|
393
|
+
}
|
|
394
|
+
transformTool.down(pt, tolerance);
|
|
395
|
+
frameDrag = true;
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
function endFrameDrag(cancelled = false) {
|
|
399
|
+
if (!frameDrag) return;
|
|
400
|
+
frameDrag = false;
|
|
401
|
+
if (!transformTool.active) return;
|
|
402
|
+
if (!cancelled && transformTool.modified) transformTool.commit();
|
|
403
|
+
else transformTool.cancel();
|
|
404
|
+
}
|
|
405
|
+
|
|
281
406
|
/** The document point of a pointer event and the hit tolerance (6 screen px). */
|
|
282
407
|
function transformPoint(e: PointerEvent) {
|
|
283
408
|
const rect = canvasEl!.getBoundingClientRect();
|
|
@@ -383,7 +508,7 @@
|
|
|
383
508
|
mode === 'paint' && brushCursor && editor.controller.getState().activeTool !== 'move' && !transformTool.active
|
|
384
509
|
? { ...brushCursor, diameter: editor.controller.getState().paint.brush.size }
|
|
385
510
|
: null,
|
|
386
|
-
freeTransform: transformTool.overlay(),
|
|
511
|
+
freeTransform: transformTool.overlay() ?? layerFrame(),
|
|
387
512
|
textEdit: typeTool.session?.alive ? typeTool.session.overlay() : null,
|
|
388
513
|
});
|
|
389
514
|
}
|
|
@@ -403,6 +528,7 @@
|
|
|
403
528
|
}
|
|
404
529
|
function handlePaintPointerDown(e: PointerEvent) {
|
|
405
530
|
if (handleTransformPointerDown(e)) return;
|
|
531
|
+
if (handleFramePointerDown(e)) return;
|
|
406
532
|
if (mode !== 'paint' || e.button !== 0 || !canvasEl) return;
|
|
407
533
|
e.preventDefault();
|
|
408
534
|
try {
|
|
@@ -414,6 +540,12 @@
|
|
|
414
540
|
}
|
|
415
541
|
function handlePaintPointerMove(e: PointerEvent) {
|
|
416
542
|
if (handleTransformPointerMove(e)) return;
|
|
543
|
+
if (mode === 'transform' && canvasEl) {
|
|
544
|
+
const { pt, tolerance } = transformPoint(e);
|
|
545
|
+
const frame = layerFrame();
|
|
546
|
+
transformCursor = frame ? transformTool.cursor(boxFrameHit(frame, pt, tolerance, tolerance * ROTATE_BAND)) : 'default';
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
417
549
|
if (mode !== 'paint' || !canvasEl) return;
|
|
418
550
|
const samples = paintSamples(e);
|
|
419
551
|
brushCursor = samples[samples.length - 1] ?? null;
|
|
@@ -421,7 +553,10 @@
|
|
|
421
553
|
else void renderCanvas();
|
|
422
554
|
}
|
|
423
555
|
function handlePaintPointerUp() {
|
|
424
|
-
if (handleTransformPointerUp())
|
|
556
|
+
if (handleTransformPointerUp()) {
|
|
557
|
+
endFrameDrag();
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
425
560
|
if (mode !== 'paint' || !paintGesture.active) return;
|
|
426
561
|
paintGesture.up();
|
|
427
562
|
if (paintGesture.error) report('paint', paintGesture.error);
|
|
@@ -476,7 +611,21 @@
|
|
|
476
611
|
}
|
|
477
612
|
|
|
478
613
|
function handleMouseDown(e: MouseEvent) {
|
|
479
|
-
if (!canvasEl
|
|
614
|
+
if (!canvasEl) return;
|
|
615
|
+
|
|
616
|
+
if (viewportGestures && (e.button === 1 || (e.button === 0 && isSpacePressed))) {
|
|
617
|
+
e.preventDefault();
|
|
618
|
+
isPanning = true;
|
|
619
|
+
panStartPoint = { clientX: e.clientX, clientY: e.clientY };
|
|
620
|
+
const curPan = editor.controller.getState().pan || { x: 0, y: 0 };
|
|
621
|
+
panStartOffset = { ...curPan };
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Transform mode picks and drags layers on pointerdown (handleFramePointerDown).
|
|
626
|
+
if (mode === 'transform') return;
|
|
627
|
+
|
|
628
|
+
if (transformTool.active) return;
|
|
480
629
|
const canvasPt = getCanvasEventPoint(e);
|
|
481
630
|
|
|
482
631
|
if (mode === 'select') {
|
|
@@ -567,7 +716,23 @@
|
|
|
567
716
|
}
|
|
568
717
|
|
|
569
718
|
function handleMouseMove(e: MouseEvent) {
|
|
570
|
-
if (!canvasEl
|
|
719
|
+
if (!canvasEl) return;
|
|
720
|
+
|
|
721
|
+
if (isPanning && panStartPoint) {
|
|
722
|
+
const dx = e.clientX - panStartPoint.clientX;
|
|
723
|
+
const dy = e.clientY - panStartPoint.clientY;
|
|
724
|
+
editor.controller.setPan({
|
|
725
|
+
x: Math.round(panStartOffset.x + dx),
|
|
726
|
+
y: Math.round(panStartOffset.y + dy),
|
|
727
|
+
});
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (viewportGestures && isSpacePressed) {
|
|
732
|
+
canvasEl.style.cursor = 'grab';
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (transformTool.active) return;
|
|
571
736
|
const canvasPt = getCanvasEventPoint(e);
|
|
572
737
|
|
|
573
738
|
if (mode === 'select') {
|
|
@@ -713,6 +878,11 @@
|
|
|
713
878
|
}
|
|
714
879
|
|
|
715
880
|
function handleMouseUp(e: MouseEvent) {
|
|
881
|
+
if (isPanning) {
|
|
882
|
+
isPanning = false;
|
|
883
|
+
panStartPoint = null;
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
716
886
|
if (!canvasEl || transformTool.active) return;
|
|
717
887
|
const canvasPt = getCanvasEventPoint(e);
|
|
718
888
|
|
|
@@ -822,7 +992,12 @@
|
|
|
822
992
|
React's `<ImageEditorCanvas>`.
|
|
823
993
|
-->
|
|
824
994
|
<div bind:this={rootEl} class="relative {className}" {style} data-testid="image-editor-canvas-root">
|
|
825
|
-
|
|
995
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
996
|
+
<div
|
|
997
|
+
bind:this={containerEl}
|
|
998
|
+
onwheel={handleWheel}
|
|
999
|
+
class="absolute inset-0 overflow-hidden select-none"
|
|
1000
|
+
>
|
|
826
1001
|
<canvas
|
|
827
1002
|
bind:this={canvasEl}
|
|
828
1003
|
onmousedown={handleMouseDown}
|
|
@@ -832,10 +1007,13 @@
|
|
|
832
1007
|
onpointerdown={handlePaintPointerDown}
|
|
833
1008
|
onpointermove={handlePaintPointerMove}
|
|
834
1009
|
onpointerup={handlePaintPointerUp}
|
|
835
|
-
onpointercancel={() =>
|
|
1010
|
+
onpointercancel={() => {
|
|
1011
|
+
paintGesture.cancel();
|
|
1012
|
+
endFrameDrag(true);
|
|
1013
|
+
}}
|
|
836
1014
|
data-testid="image-editor-canvas"
|
|
837
|
-
style="width: 100%; height: 100%;{transformActive ? ` cursor: ${transformCursor};` : ''}"
|
|
838
|
-
class="absolute inset-0 w-full h-full block {transformActive ? 'touch-none' : mode === 'paint' ? 'touch-none cursor-crosshair' : mode === 'annotate' && editor.state.activeTool === 'type' ? 'cursor-text' : mode === 'annotate' && editor.state.activeTool === 'shape' ? 'cursor-crosshair' : ''}"
|
|
1015
|
+
style="width: 100%; height: 100%;{transformActive || mode === 'transform' ? ` cursor: ${transformCursor};` : ''}"
|
|
1016
|
+
class="absolute inset-0 w-full h-full block {transformActive || mode === 'transform' ? 'touch-none' : mode === 'paint' ? 'touch-none cursor-crosshair' : mode === 'annotate' && editor.state.activeTool === 'type' ? 'cursor-text' : mode === 'annotate' && editor.state.activeTool === 'shape' ? 'cursor-crosshair' : ''}"
|
|
839
1017
|
></canvas>
|
|
840
1018
|
{#if typeSession}
|
|
841
1019
|
<textarea
|
|
@@ -54,11 +54,15 @@ interface Props {
|
|
|
54
54
|
locate?: (ref: PlaybackRef) => PlaybackRect | null;
|
|
55
55
|
/** Your own element for a playback ref, anywhere on the page; its box is mapped to document pixels. */
|
|
56
56
|
locateElement?: (ref: PlaybackRef) => Element | null;
|
|
57
|
-
/** Show a panel for an `open` step (default: click its `[data-image-tab]` under `chromeRoot`). */
|
|
57
|
+
/** Show a panel for an `open` step (default: click its `[data-image-tab]` or `[data-image-panel]` under `chromeRoot`). */
|
|
58
58
|
open?: (ref: PlaybackRef) => void | Promise<void>;
|
|
59
|
+
/** Whether wheel zoom and middle/space drag panning are enabled (default true). */
|
|
60
|
+
viewportGestures?: boolean;
|
|
59
61
|
class?: string;
|
|
60
62
|
style?: string;
|
|
61
63
|
}
|
|
62
|
-
declare const ImageEditorCanvas: import("svelte").Component<Props, {
|
|
64
|
+
declare const ImageEditorCanvas: import("svelte").Component<Props, {
|
|
65
|
+
fitToView: () => void;
|
|
66
|
+
}, "typeSession">;
|
|
63
67
|
type ImageEditorCanvas = ReturnType<typeof ImageEditorCanvas>;
|
|
64
68
|
export default ImageEditorCanvas;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { ImageEditorController, ImageLayer } from '@pixerate/editor/image-editor';
|
|
3
|
+
import {
|
|
4
|
+
ADJUSTMENT_LAYER_OPACITY_CONTROL,
|
|
5
|
+
DEFAULT_SHAPE_TOOL_OPTIONS,
|
|
6
|
+
DEFAULT_TYPE_TOOL_OPTIONS,
|
|
7
|
+
DEFAULT_SELECTION_TOOL_OPTIONS,
|
|
8
|
+
} from '@pixerate/editor/image-editor';
|
|
9
|
+
import CanvasSizePanel from './CanvasSizePanel.svelte';
|
|
10
|
+
import ControlSlider from './ControlSlider.svelte';
|
|
11
|
+
import LayerEffectsPanel from './LayerEffectsPanel.svelte';
|
|
12
|
+
import BrushOptionsBar from './BrushOptionsBar.svelte';
|
|
13
|
+
import ShapeOptionsBar from './ShapeOptionsBar.svelte';
|
|
14
|
+
import TypeOptionsBar from './TypeOptionsBar.svelte';
|
|
15
|
+
import AdjustmentLayersPanel from './AdjustmentLayersPanel.svelte';
|
|
16
|
+
import SelectionOptionsBar from './SelectionOptionsBar.svelte';
|
|
17
|
+
|
|
18
|
+
interface Props {
|
|
19
|
+
controller: ImageEditorController;
|
|
20
|
+
panelId: string;
|
|
21
|
+
class?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let { controller, panelId, class: className = '' }: Props = $props();
|
|
25
|
+
|
|
26
|
+
const activeLayer = $derived(controller.getActiveLayer());
|
|
27
|
+
let shapeOptions = $state({ ...DEFAULT_SHAPE_TOOL_OPTIONS });
|
|
28
|
+
let typeOptions = $state({ ...DEFAULT_TYPE_TOOL_OPTIONS });
|
|
29
|
+
let selectionOptions = $state({ ...DEFAULT_SELECTION_TOOL_OPTIONS });
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<div class="layer-panel-container {className}" data-image-panel={panelId} data-testid="layer-panel-{panelId}">
|
|
33
|
+
{#if panelId === 'canvas-size'}
|
|
34
|
+
<CanvasSizePanel {controller} />
|
|
35
|
+
{:else if panelId === 'properties'}
|
|
36
|
+
<div class="flex flex-col gap-3 text-xs text-foreground">
|
|
37
|
+
<div class="font-medium text-sm">Properties</div>
|
|
38
|
+
{#if activeLayer}
|
|
39
|
+
<div class="flex flex-col gap-2">
|
|
40
|
+
<div class="flex items-center justify-between">
|
|
41
|
+
<span class="text-muted-foreground">Name</span>
|
|
42
|
+
<span class="font-medium">{activeLayer.name || activeLayer.id}</span>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="flex items-center justify-between">
|
|
45
|
+
<span class="text-muted-foreground">Type</span>
|
|
46
|
+
<span class="capitalize">{activeLayer.type}</span>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="flex items-center justify-between">
|
|
49
|
+
<span class="text-muted-foreground">Blend Mode</span>
|
|
50
|
+
<span class="capitalize">{activeLayer.blend}</span>
|
|
51
|
+
</div>
|
|
52
|
+
<ControlSlider
|
|
53
|
+
spec={ADJUSTMENT_LAYER_OPACITY_CONTROL}
|
|
54
|
+
label="Opacity"
|
|
55
|
+
value={Math.round(activeLayer.opacity * 100)}
|
|
56
|
+
onchange={(val) => controller.setLayerProperties(activeLayer.id, { opacity: val / 100 })}
|
|
57
|
+
/>
|
|
58
|
+
</div>
|
|
59
|
+
{:else}
|
|
60
|
+
<div class="text-muted-foreground">No active layer selected.</div>
|
|
61
|
+
{/if}
|
|
62
|
+
</div>
|
|
63
|
+
{:else if panelId === 'paint'}
|
|
64
|
+
<div class="flex flex-col gap-3">
|
|
65
|
+
<div class="font-medium text-sm text-foreground">Brush & Paint</div>
|
|
66
|
+
<BrushOptionsBar {controller} />
|
|
67
|
+
</div>
|
|
68
|
+
{:else if panelId === 'effects'}
|
|
69
|
+
<div class="flex flex-col gap-3">
|
|
70
|
+
<div class="font-medium text-sm text-foreground">Layer Effects</div>
|
|
71
|
+
<LayerEffectsPanel {controller} />
|
|
72
|
+
</div>
|
|
73
|
+
{:else if panelId === 'adjustment'}
|
|
74
|
+
<AdjustmentLayersPanel {controller} />
|
|
75
|
+
{:else if panelId === 'type'}
|
|
76
|
+
<div class="flex flex-col gap-3">
|
|
77
|
+
<div class="font-medium text-sm text-foreground">Typography</div>
|
|
78
|
+
<TypeOptionsBar {controller} options={typeOptions} onchange={(o) => (typeOptions = o)} />
|
|
79
|
+
</div>
|
|
80
|
+
{:else if panelId === 'shape'}
|
|
81
|
+
<div class="flex flex-col gap-3">
|
|
82
|
+
<div class="font-medium text-sm text-foreground">Shape Options</div>
|
|
83
|
+
<ShapeOptionsBar {controller} options={shapeOptions} onchange={(o) => (shapeOptions = o)} />
|
|
84
|
+
</div>
|
|
85
|
+
{:else if panelId === 'smart-filters'}
|
|
86
|
+
<div class="flex flex-col gap-3">
|
|
87
|
+
<div class="font-medium text-sm text-foreground">Smart Filters</div>
|
|
88
|
+
<LayerEffectsPanel {controller} />
|
|
89
|
+
</div>
|
|
90
|
+
{:else if panelId === 'selection'}
|
|
91
|
+
<div class="flex flex-col gap-3">
|
|
92
|
+
<div class="font-medium text-sm text-foreground">Selection</div>
|
|
93
|
+
<SelectionOptionsBar {controller} options={selectionOptions} onOptionsChange={(o) => (selectionOptions = o)} />
|
|
94
|
+
</div>
|
|
95
|
+
{:else}
|
|
96
|
+
<div class="text-xs text-muted-foreground">Panel {panelId}</div>
|
|
97
|
+
{/if}
|
|
98
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ImageEditorController } from '@pixerate/editor/image-editor';
|
|
2
|
+
interface Props {
|
|
3
|
+
controller: ImageEditorController;
|
|
4
|
+
panelId: string;
|
|
5
|
+
class?: string;
|
|
6
|
+
}
|
|
7
|
+
declare const LayerPanel: import("svelte").Component<Props, {}, "">;
|
|
8
|
+
type LayerPanel = ReturnType<typeof LayerPanel>;
|
|
9
|
+
export default LayerPanel;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/** What pointer gestures on `ImageEditorCanvas` do (and whether the crop overlay shows). */
|
|
2
|
-
export type ImageEditorCanvasMode = 'crop' | 'select' | 'paint' | 'annotate' | 'none';
|
|
2
|
+
export type ImageEditorCanvasMode = 'crop' | 'select' | 'paint' | 'annotate' | 'transform' | 'none';
|
|
3
3
|
/** Which gesture an `ImageEditorCanvas` `onerror` report comes from. */
|
|
4
|
-
export type ImageEditorCanvasErrorSource = 'paint' | 'select' | 'shape' | 'type';
|
|
4
|
+
export type ImageEditorCanvasErrorSource = 'paint' | 'select' | 'shape' | 'type' | 'transform';
|
|
@@ -16,8 +16,12 @@ import type FreeTransformOptionsBarComponent from "./FreeTransformOptionsBar.sve
|
|
|
16
16
|
import type ShapeOptionsBarComponent from "./ShapeOptionsBar.svelte";
|
|
17
17
|
import type TypeOptionsBarComponent from "./TypeOptionsBar.svelte";
|
|
18
18
|
import type ImageProviderEffectsPanelComponent from "./ImageProviderEffectsPanel.svelte";
|
|
19
|
+
import type CanvasSizePanelComponent from "./CanvasSizePanel.svelte";
|
|
20
|
+
import type LayerPanelComponent from "./LayerPanel.svelte";
|
|
19
21
|
export { default as ImageEditor } from './ImageEditor.svelte';
|
|
20
22
|
export { default as ImageEditorCanvas } from './ImageEditorCanvas.svelte';
|
|
23
|
+
export { default as CanvasSizePanel } from './CanvasSizePanel.svelte';
|
|
24
|
+
export { default as LayerPanel } from './LayerPanel.svelte';
|
|
21
25
|
export { default as ImageEffectControls } from './ImageEffectControls.svelte';
|
|
22
26
|
export type { ImageEditorCanvasMode, ImageEditorCanvasErrorSource } from './canvasTypes.js';
|
|
23
27
|
export { default as PaintGradientEditor } from './PaintGradientEditor.svelte';
|
|
@@ -38,6 +42,8 @@ export { default as ImageProviderEffectsPanel } from './ImageProviderEffectsPane
|
|
|
38
42
|
export * from './imageEditorState.svelte.js';
|
|
39
43
|
export type ImageEditorProps = ComponentProps<typeof ImageEditorComponent>;
|
|
40
44
|
export type ImageEditorCanvasProps = ComponentProps<typeof ImageEditorCanvasComponent>;
|
|
45
|
+
export type CanvasSizePanelProps = ComponentProps<typeof CanvasSizePanelComponent>;
|
|
46
|
+
export type LayerPanelPropsComponent = ComponentProps<typeof LayerPanelComponent>;
|
|
41
47
|
export type ImageEffectControlsProps = ComponentProps<typeof ImageEffectControlsComponent>;
|
|
42
48
|
export type HistogramProps = ComponentProps<typeof HistogramComponent>;
|
|
43
49
|
export type AdjustmentLayersPanelProps = ComponentProps<typeof AdjustmentLayersPanelComponent>;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { default as ImageEditor } from './ImageEditor.svelte';
|
|
2
2
|
export { default as ImageEditorCanvas } from './ImageEditorCanvas.svelte';
|
|
3
|
+
export { default as CanvasSizePanel } from './CanvasSizePanel.svelte';
|
|
4
|
+
export { default as LayerPanel } from './LayerPanel.svelte';
|
|
3
5
|
export { default as ImageEffectControls } from './ImageEffectControls.svelte';
|
|
4
6
|
export { default as PaintGradientEditor } from './PaintGradientEditor.svelte';
|
|
5
7
|
export { default as Histogram } from './Histogram.svelte';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pixerate/editor-svelte",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.117.0",
|
|
4
4
|
"description": "Svelte bindings and prompt/rich-text components for @pixerate/editor",
|
|
5
5
|
"author": "Pixerate",
|
|
6
6
|
"license": "MIT",
|
|
@@ -160,7 +160,7 @@
|
|
|
160
160
|
"@tiptap/extension-bubble-menu": "^2.27.3",
|
|
161
161
|
"@tiptap/extension-placeholder": "^2.27.3",
|
|
162
162
|
"@tiptap/starter-kit": "^2.27.3",
|
|
163
|
-
"@pixerate/editor": "0.
|
|
163
|
+
"@pixerate/editor": "0.159.0"
|
|
164
164
|
},
|
|
165
165
|
"peerDependencies": {
|
|
166
166
|
"@dagrejs/dagre": "^1.1.4",
|