@pixerate/editor-svelte 0.156.0 → 0.158.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.
@@ -19,6 +19,7 @@
19
19
  FreeTransformTool,
20
20
  DEFAULT_SHAPE_TOOL_OPTIONS,
21
21
  DEFAULT_TYPE_TOOL_OPTIONS,
22
+ imageToolLabel,
22
23
  type SelectionToolOptions,
23
24
  type ShapeToolOptions,
24
25
  type TextEditSession,
@@ -702,16 +703,16 @@
702
703
 
703
704
  {#if activeTab === 'annotate'}
704
705
  <div class="flex flex-col gap-3 text-xs">
705
- <span class={imageSectionTitleClass}>Shape & Type Layers</span>
706
+ <span class={imageSectionTitleClass}>Shape & Type Layers, Paths</span>
706
707
  <div class="grid grid-cols-2 gap-1.5">
707
- {#each ['shape', 'type'] as const as t (t)}
708
+ {#each ['shape', 'type', 'path-pen', 'path-select', 'direct-select'] as const as t (t)}
708
709
  <button
709
710
  type="button"
710
711
  aria-pressed={editor.state.activeTool === t}
711
712
  onclick={() => editor.setTool(t)}
712
713
  class="py-1.5 px-2 rounded-lg font-medium border transition-colors {editor.state.activeTool === t
713
714
  ? 'bg-primary border-primary text-primary-foreground'
714
- : 'bg-muted border-border text-foreground hover:bg-accent'}">{t === 'shape' ? 'Shape' : 'Type'}</button>
715
+ : 'bg-muted border-border text-foreground hover:bg-accent'}">{imageToolLabel(t)}</button>
715
716
  {/each}
716
717
  </div>
717
718
  {#if editor.state.activeTool === 'shape'}
@@ -742,7 +743,7 @@
742
743
  ? 'bg-primary border-primary text-primary-foreground'
743
744
  : 'bg-muted border-border text-foreground hover:bg-accent'}"
744
745
  >
745
- {t}
746
+ {t === 'pen' ? 'freehand' : t}
746
747
  </button>
747
748
  {/each}
748
749
  </div>
@@ -27,6 +27,8 @@
27
27
  handleQuickMaskKey,
28
28
  handleMaskViewKey,
29
29
  ShapeDrawGesture,
30
+ PathEditGesture,
31
+ isPathEditTool,
30
32
  TypeToolGesture,
31
33
  DEFAULT_SHAPE_TOOL_OPTIONS,
32
34
  DEFAULT_TYPE_TOOL_OPTIONS,
@@ -279,6 +281,8 @@
279
281
 
280
282
  // Shape and type tools; type is edited on the canvas through a hidden textarea.
281
283
  const shapeGesture = $derived(new ShapeDrawGesture(editor.controller, () => shapeOptions));
284
+ // Path tools (Path Selection, Direct Selection, Pen): edit the active path, shape or vector mask.
285
+ const pathGesture = $derived(new PathEditGesture(editor.controller));
282
286
  const typeTool = $derived(new TypeToolGesture(editor.controller, () => typeOptions));
283
287
  let typeInputEl = $state<HTMLTextAreaElement | null>(null);
284
288
  $effect(() => {
@@ -521,7 +525,10 @@
521
525
  const viewport = measureCanvasViewport(containerEl);
522
526
  view = { width: viewport.width, height: viewport.height };
523
527
  // The transform overlay's rotate handle sits a fixed distance out on screen.
524
- transformTool.setView(getViewportProjection(editor.controller.getState(), view.width, view.height, mode === 'crop').scale);
528
+ const viewScale = getViewportProjection(editor.controller.getState(), view.width, view.height, mode === 'crop').scale;
529
+ transformTool.setView(viewScale);
530
+ pathGesture.setView(viewScale);
531
+ const activeTool = editor.controller.getState().activeTool;
525
532
 
526
533
  await editor.controller.render(canvasEl, {
527
534
  viewportWidth: viewport.width,
@@ -542,6 +549,7 @@
542
549
  : null,
543
550
  freeTransform: transformTool.overlay() ?? layerFrame(),
544
551
  textEdit: typeTool.session?.alive ? typeTool.session.overlay() : null,
552
+ pathEdit: mode === 'annotate' && isPathEditTool(activeTool) ? pathGesture.overlay(activeTool) : null,
545
553
  });
546
554
  }
547
555
 
@@ -623,6 +631,24 @@
623
631
  return () => window.removeEventListener('keydown', onKey);
624
632
  });
625
633
 
634
+ $effect(() => {
635
+ if (mode !== 'annotate' || typeof window === 'undefined') return;
636
+ const gesture = pathGesture;
637
+ // The path tools: Delete removes the selected anchors, Enter / Escape end the Pen's subpath.
638
+ const onKey = (e: KeyboardEvent) => {
639
+ const target = e.target as HTMLElement | null;
640
+ if (target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
641
+ if (!isPathEditTool(editor.controller.getState().activeTool)) return;
642
+ if (gesture.key(e.key)) {
643
+ e.preventDefault();
644
+ report('path', gesture.error);
645
+ void renderCanvas();
646
+ }
647
+ };
648
+ window.addEventListener('keydown', onKey);
649
+ return () => window.removeEventListener('keydown', onKey);
650
+ });
651
+
626
652
  $effect(() => {
627
653
  if (mode !== 'paint' || typeof window === 'undefined') return;
628
654
  const c = editor.controller;
@@ -728,6 +754,13 @@
728
754
  return;
729
755
  }
730
756
 
757
+ if (mode === 'annotate' && isPathEditTool(editor.state.activeTool)) {
758
+ pathGesture.down(canvasToImagePoint(canvasPt, editor.state, view.width, view.height, false), editor.state.activeTool, { shift: e.shiftKey, alt: e.altKey });
759
+ report('path', pathGesture.error);
760
+ void renderCanvas();
761
+ return;
762
+ }
763
+
731
764
  if (mode === 'annotate' && (editor.state.activeTool === 'shape' || editor.state.activeTool === 'type')) {
732
765
  const imgPt = canvasToImagePoint(canvasPt, editor.state, view.width, view.height, false);
733
766
  report(editor.state.activeTool, null);
@@ -854,6 +887,16 @@
854
887
  return;
855
888
  }
856
889
 
890
+ if (mode === 'annotate' && isPathEditTool(editor.state.activeTool)) {
891
+ const imgPt = canvasToImagePoint(canvasPt, editor.state, view.width, view.height, false);
892
+ if (pathGesture.active) {
893
+ pathGesture.move(imgPt, { shift: e.shiftKey, alt: e.altKey });
894
+ if (pathGesture.error) report('path', pathGesture.error);
895
+ } else pathGesture.hover(imgPt);
896
+ void renderCanvas();
897
+ return;
898
+ }
899
+
857
900
  if (mode === 'annotate' && editor.state.activeTool === 'shape') {
858
901
  if (shapeGesture.active) {
859
902
  shapeGesture.move(canvasToImagePoint(canvasPt, editor.state, view.width, view.height, false), { shift: e.shiftKey, alt: e.altKey });
@@ -975,6 +1018,11 @@
975
1018
  return;
976
1019
  }
977
1020
 
1021
+ if (mode === 'annotate' && isPathEditTool(editor.state.activeTool)) {
1022
+ pathGesture.up();
1023
+ void renderCanvas();
1024
+ return;
1025
+ }
978
1026
  if (mode === 'annotate' && editor.state.activeTool === 'shape') {
979
1027
  shapeGesture.up();
980
1028
  return;
@@ -1080,7 +1128,7 @@
1080
1128
  }}
1081
1129
  data-testid="image-editor-canvas"
1082
1130
  style="width: 100%; height: 100%;{panning ? ` cursor: ${isPanning ? 'grabbing' : 'grab'};` : transformActive || mode === 'transform' ? ` cursor: ${transformCursor};` : ''}"
1083
- 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' : ''}"
1131
+ 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' || editor.state.activeTool === 'path-pen') ? 'cursor-crosshair' : mode === 'annotate' && isPathEditTool(editor.state.activeTool) ? 'cursor-default' : ''}"
1084
1132
  ></canvas>
1085
1133
  {#if typeSession}
1086
1134
  <textarea
@@ -0,0 +1,76 @@
1
+ <script lang="ts">
2
+ import type { ImageEditorController, MaskEdgeReport, MaskEdgeTarget, SelectionMask } from '@pixerate/editor/image-editor';
3
+ import { MASK_EDGE_THRESHOLDS } from '@pixerate/editor/image-editor';
4
+
5
+ /**
6
+ * Check Edge: measures the selection's (or a mask's) edge, as the `inspect_mask_edge` tool does,
7
+ * and lists the scores (flagged past the thresholds), holes, islands and findings with fixes.
8
+ */
9
+ interface Props {
10
+ controller: ImageEditorController;
11
+ /** The current selection (`state.selection`): a report on an older selection is cleared. */
12
+ selection?: SelectionMask | null;
13
+ /** What to check (default the selection); masks need `layerId`. */
14
+ target?: MaskEdgeTarget;
15
+ layerId?: string;
16
+ class?: string;
17
+ }
18
+
19
+ let { controller, selection, target = 'selection', layerId, class: className = '' }: Props = $props();
20
+
21
+ const button =
22
+ 'px-2 py-1 rounded-md border border-border bg-background text-[11px] text-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-40';
23
+ const sel = $derived(selection === undefined ? controller.getSelection() : selection);
24
+ let result = $state<{ report: MaskEdgeReport; for: SelectionMask | null } | null>(null);
25
+ let error = $state<string | null>(null);
26
+ const report = $derived(result && (target !== 'selection' || result.for === sel) ? result.report : null);
27
+ const T = MASK_EDGE_THRESHOLDS;
28
+ const rows = $derived(
29
+ report
30
+ ? [
31
+ { label: 'Halo', value: report.haloScore, bad: report.haloScore >= T.halo },
32
+ { label: 'Edge alignment', value: report.edgeAlignment, bad: report.edgeAlignment < T.alignment },
33
+ { label: 'Jaggedness', value: report.jaggedness, bad: report.jaggedness >= T.jaggedness },
34
+ { label: 'Soft edge', value: report.softFraction, bad: report.softFraction >= T.soft },
35
+ ]
36
+ : []
37
+ );
38
+
39
+ function check() {
40
+ error = null;
41
+ try {
42
+ result = { report: controller.inspectMaskEdge(target, { layerId }), for: sel };
43
+ } catch (err) {
44
+ result = null;
45
+ error = err instanceof Error ? err.message : String(err);
46
+ }
47
+ }
48
+ </script>
49
+
50
+ <div data-testid="mask-edge-check" class="flex flex-col gap-1.5 text-xs {className}">
51
+ <button type="button" class="{button} self-start" disabled={target === 'selection' && !sel} onclick={check}>Check Edge</button>
52
+ {#if report}
53
+ <dl class="grid grid-cols-[1fr_auto] gap-x-2 gap-y-0.5">
54
+ {#each rows as r (r.label)}
55
+ <dt class="text-muted-foreground">{r.label}</dt>
56
+ <dd class="font-mono text-right {r.bad ? 'text-destructive' : 'text-foreground'}">{r.value}</dd>
57
+ {/each}
58
+ <dt class="text-muted-foreground">Holes / islands</dt>
59
+ <dd class="font-mono text-right {report.holes.count || report.islands.count ? 'text-destructive' : 'text-foreground'}">
60
+ {report.holes.count} / {report.islands.count}
61
+ </dd>
62
+ </dl>
63
+ {#if report.issues.length}
64
+ <ul data-testid="mask-edge-issues" class="list-disc pl-4 flex flex-col gap-1 text-foreground">
65
+ {#each report.issues as s (s)}
66
+ <li>{s}</li>
67
+ {/each}
68
+ </ul>
69
+ {:else}
70
+ <p class="text-muted-foreground">The edge looks clean.</p>
71
+ {/if}
72
+ {/if}
73
+ {#if error}
74
+ <p role="alert" class="text-destructive">{error}</p>
75
+ {/if}
76
+ </div>
@@ -0,0 +1,17 @@
1
+ import type { ImageEditorController, MaskEdgeTarget, SelectionMask } from '@pixerate/editor/image-editor';
2
+ /**
3
+ * Check Edge: measures the selection's (or a mask's) edge, as the `inspect_mask_edge` tool does,
4
+ * and lists the scores (flagged past the thresholds), holes, islands and findings with fixes.
5
+ */
6
+ interface Props {
7
+ controller: ImageEditorController;
8
+ /** The current selection (`state.selection`): a report on an older selection is cleared. */
9
+ selection?: SelectionMask | null;
10
+ /** What to check (default the selection); masks need `layerId`. */
11
+ target?: MaskEdgeTarget;
12
+ layerId?: string;
13
+ class?: string;
14
+ }
15
+ declare const MaskEdgeCheck: import("svelte").Component<Props, {}, "">;
16
+ type MaskEdgeCheck = ReturnType<typeof MaskEdgeCheck>;
17
+ export default MaskEdgeCheck;
@@ -9,7 +9,8 @@
9
9
  * thumbnails. Click selects a path, double-click renames it (renaming the Work Path saves it).
10
10
  * Load as Selection (Shift adds, Alt subtracts), Make Work Path from the selection, Fill and
11
11
  * Stroke on the active layer with a colour and width, Add Vector Mask to the active layer,
12
- * Duplicate and Delete. Every change is one undo step on the controller.
12
+ * Duplicate and Delete. Clicking empty space deselects the path. Every change is one undo step on
13
+ * the controller.
13
14
  */
14
15
  interface Props {
15
16
  controller: ImageEditorController;
@@ -62,12 +63,15 @@
62
63
  }`;
63
64
  </script>
64
65
 
65
- <div data-testid="paths-panel" class="flex flex-col gap-2 text-xs {className}">
66
+ <!-- Clicking the panel's empty space deselects the path (the path tools then edit the active layer). -->
67
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
68
+ <div data-testid="paths-panel" class="flex flex-col gap-2 text-xs {className}" onclick={(e) => e.target === e.currentTarget && attempt(() => controller.setActivePath(null))}>
66
69
  <span class={imageSectionTitleClass}>Paths</span>
67
70
  {#if paths.length === 0}
68
71
  <p class="text-muted-foreground">No paths. Make a work path from a selection.</p>
69
72
  {/if}
70
- <ul role="list" class="flex flex-col gap-0.5">
73
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
74
+ <ul role="list" class="flex flex-col gap-0.5 min-h-4" onclick={(e) => e.target === e.currentTarget && attempt(() => controller.setActivePath(null))}>
71
75
  {#each paths as p (p.id)}
72
76
  <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
73
77
  <li
@@ -4,7 +4,8 @@ import type { ImageEditorController } from '@pixerate/editor/image-editor';
4
4
  * thumbnails. Click selects a path, double-click renames it (renaming the Work Path saves it).
5
5
  * Load as Selection (Shift adds, Alt subtracts), Make Work Path from the selection, Fill and
6
6
  * Stroke on the active layer with a colour and width, Add Vector Mask to the active layer,
7
- * Duplicate and Delete. Every change is one undo step on the controller.
7
+ * Duplicate and Delete. Clicking empty space deselects the path. Every change is one undo step on
8
+ * the controller.
8
9
  */
9
10
  interface Props {
10
11
  controller: ImageEditorController;
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { imageRangeClass, imageSectionTitleClass } from './styles.js';
3
+ import MaskEdgeCheck from './MaskEdgeCheck.svelte';
3
4
  import type {
4
5
  AdjustmentKind,
5
6
  AdjustmentLayer,
@@ -157,6 +158,7 @@
157
158
  {#if error}
158
159
  <p role="alert" class="text-destructive">{error}</p>
159
160
  {/if}
161
+ <MaskEdgeCheck {controller} selection={sel} />
160
162
 
161
163
  <span class="font-semibold text-foreground uppercase tracking-wider text-[11px] mt-2">Layer Mask</span>
162
164
  <div class="flex gap-1 items-center">
@@ -1,4 +1,4 @@
1
1
  /** What pointer gestures on `ImageEditorCanvas` do (and whether the crop overlay shows). */
2
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' | 'transform';
4
+ export type ImageEditorCanvasErrorSource = 'paint' | 'select' | 'shape' | 'type' | 'transform' | 'path';
@@ -8,6 +8,7 @@ import type CurvesEditorComponent from "./CurvesEditor.svelte";
8
8
  import type ControlSliderComponent from "./ControlSlider.svelte";
9
9
  import type SelectionOptionsBarComponent from "./SelectionOptionsBar.svelte";
10
10
  import type RefineEdgePanelComponent from "./RefineEdgePanel.svelte";
11
+ import type MaskEdgeCheckComponent from "./MaskEdgeCheck.svelte";
11
12
  import type PathsPanelComponent from "./PathsPanel.svelte";
12
13
  import type SelectAndMaskWorkspaceComponent from "./SelectAndMaskWorkspace.svelte";
13
14
  import type ContentAwareFillWorkspaceComponent from "./ContentAwareFillWorkspace.svelte";
@@ -35,6 +36,7 @@ export { default as CurvesEditor } from './CurvesEditor.svelte';
35
36
  export { default as ControlSlider } from './ControlSlider.svelte';
36
37
  export { default as SelectionOptionsBar } from './SelectionOptionsBar.svelte';
37
38
  export { default as RefineEdgePanel } from './RefineEdgePanel.svelte';
39
+ export { default as MaskEdgeCheck } from './MaskEdgeCheck.svelte';
38
40
  export { default as PathsPanel } from './PathsPanel.svelte';
39
41
  export { default as SelectAndMaskWorkspace } from './SelectAndMaskWorkspace.svelte';
40
42
  export { default as ContentAwareFillWorkspace } from './ContentAwareFillWorkspace.svelte';
@@ -64,6 +66,7 @@ export type CurvesEditorProps = ComponentProps<typeof CurvesEditorComponent>;
64
66
  export type ControlSliderProps = ComponentProps<typeof ControlSliderComponent>;
65
67
  export type SelectionOptionsBarProps = ComponentProps<typeof SelectionOptionsBarComponent>;
66
68
  export type RefineEdgePanelProps = ComponentProps<typeof RefineEdgePanelComponent>;
69
+ export type MaskEdgeCheckProps = ComponentProps<typeof MaskEdgeCheckComponent>;
67
70
  export type PathsPanelProps = ComponentProps<typeof PathsPanelComponent>;
68
71
  export type SelectAndMaskWorkspaceProps = ComponentProps<typeof SelectAndMaskWorkspaceComponent>;
69
72
  export type ContentAwareFillWorkspaceProps = ComponentProps<typeof ContentAwareFillWorkspaceComponent>;
@@ -10,6 +10,7 @@ export { default as CurvesEditor } from './CurvesEditor.svelte';
10
10
  export { default as ControlSlider } from './ControlSlider.svelte';
11
11
  export { default as SelectionOptionsBar } from './SelectionOptionsBar.svelte';
12
12
  export { default as RefineEdgePanel } from './RefineEdgePanel.svelte';
13
+ export { default as MaskEdgeCheck } from './MaskEdgeCheck.svelte';
13
14
  export { default as PathsPanel } from './PathsPanel.svelte';
14
15
  export { default as SelectAndMaskWorkspace } from './SelectAndMaskWorkspace.svelte';
15
16
  export { default as ContentAwareFillWorkspace } from './ContentAwareFillWorkspace.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixerate/editor-svelte",
3
- "version": "0.156.0",
3
+ "version": "0.158.0",
4
4
  "description": "Svelte bindings and prompt/rich-text components for @pixerate/editor",
5
5
  "author": "Pixerate",
6
6
  "license": "MIT",
@@ -165,7 +165,7 @@
165
165
  "**/*.css"
166
166
  ],
167
167
  "dependencies": {
168
- "@pixerate/editor": "0.212.0"
168
+ "@pixerate/editor": "0.214.0"
169
169
  },
170
170
  "peerDependencies": {
171
171
  "@dagrejs/dagre": "^1.1.4",