@pictor-sdk/ui 0.2.2 → 0.4.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/index.d.mts CHANGED
@@ -87,6 +87,14 @@ interface ComposerInsets {
87
87
  /** Animation duration to match the platform's own, clamped to something sane. */
88
88
  duration: number;
89
89
  }
90
+ interface CanvasRect {
91
+ /** Size of the fitted canvas inside the container. */
92
+ width: number;
93
+ height: number;
94
+ /** Offset of the canvas inside the container, which centres it. */
95
+ left: number;
96
+ top: number;
97
+ }
90
98
 
91
99
  /**
92
100
  * The state and commands behind a text-editing session.
@@ -167,6 +175,11 @@ interface TextComposerProps {
167
175
  onDone(): void;
168
176
  /** Abandon, restoring the document. */
169
177
  onCancel(): void;
178
+ /**
179
+ * The canvas rectangle in this composer's coordinates, when the host draws a
180
+ * cover canvas. Omitted, the composer assumes a centred contain fit.
181
+ */
182
+ canvas?: CanvasRect;
170
183
  }
171
184
  /**
172
185
  * The full-screen Story text composer.
@@ -176,20 +189,22 @@ interface TextComposerProps {
176
189
  * `renderDocument` — so what is on screen while composing is what exports.
177
190
  *
178
191
  * The `TextInput` itself is invisible and stretched over the canvas: it owns
179
- * the caret, the platform selection handles and the IME, while the *visible*
180
- * text is the real Skia layer underneath. That is what keeps preview and export
181
- * identical without giving up native text editing.
192
+ * focus and the IME, while the *visible* text is the real Skia layer
193
+ * underneath. On Android the platform paints composing glyphs black regardless
194
+ * of the input's colour, so the input is fully hidden and a caret is drawn at
195
+ * the measured selection instead. That is what keeps preview and export
196
+ * identical without giving up text editing.
182
197
  *
183
198
  * @experimental
184
199
  */
185
- declare function TextComposer({ session, insets, onDone, onCancel }: TextComposerProps): React__default.JSX.Element;
200
+ declare function TextComposer({ session, insets, onDone, onCancel, canvas }: TextComposerProps): React__default.JSX.Element;
186
201
  /**
187
202
  * Mounts a text session and the composer for it, and keeps the keyboard inset
188
203
  * up to date. This is what `<Editor>` renders while the text tool is active.
189
204
  *
190
205
  * @experimental
191
206
  */
192
- declare function TextComposerHost({ Composer, safeArea, }: {
207
+ declare function TextComposerHost({ Composer, safeArea, canvas, }: {
193
208
  /** Replace the default composer without replacing the canvas or editor. */
194
209
  Composer?: React__default.ComponentType<TextComposerProps>;
195
210
  /**
@@ -203,6 +218,8 @@ declare function TextComposerHost({ Composer, safeArea, }: {
203
218
  top?: number;
204
219
  bottom?: number;
205
220
  };
221
+ /** The canvas rectangle, for a host that draws a cover canvas. */
222
+ canvas?: CanvasRect;
206
223
  }): React__default.JSX.Element;
207
224
 
208
225
  /** What the top bar slot receives: the transaction actions and undo state. */
@@ -605,6 +622,12 @@ declare function EditorGestures({ onSnap }: EditorGesturesProps): React__default
605
622
  interface LayerGestureSurfaceProps {
606
623
  /** Fired once per snap engagement — hook up haptics here if you want them. */
607
624
  onSnap?: (event: GestureSnapEvent) => void;
625
+ /**
626
+ * Fired when a tap lands on a layer that was already selected when the
627
+ * gesture began. Hosts use this for a second-tap action such as opening the
628
+ * text composer; the SDK itself stays unaware of what a layer means.
629
+ */
630
+ onSelectedLayerTap?: (layerId: string) => void;
608
631
  }
609
632
  /**
610
633
  * Direct manipulation of overlay layers: grab the top-most layer under the
@@ -622,7 +645,7 @@ interface LayerGestureSurfaceProps {
622
645
  * - **One history entry.** The document is written exactly once, when the
623
646
  * gesture settles, and not at all if nothing actually moved.
624
647
  */
625
- declare function LayerGestureSurface({ onSnap }: LayerGestureSurfaceProps): React__default.JSX.Element;
648
+ declare function LayerGestureSurface({ onSnap, onSelectedLayerTap }: LayerGestureSurfaceProps): React__default.JSX.Element;
626
649
 
627
650
  /** The centre and rotation guides shown while a layer snaps. */
628
651
  interface InteractionGuidesProps {
@@ -827,6 +850,22 @@ interface LayoutImageFit {
827
850
  declare function fitImageToLayoutCell(cell: Rect, documentSize: Size, intrinsic: Size, options?: LayoutCropOptions): LayoutImageFit;
828
851
  /** The transform patch that moves a layer into a cell, leaving its other fields alone. */
829
852
  declare function cellToTransformPatch(cell: LayoutCell): Partial<Transform>;
853
+ /**
854
+ * The box a photo draws into when it fills one cell, in document pixels.
855
+ *
856
+ * The cell fixes the box: `fitImageToLayoutCell` picks a crop whose aspect
857
+ * matches the cell, so zooming and panning inside it move the source window
858
+ * and leave the box alone. That is what makes a live crop expressible as a
859
+ * source-space override at all, and it is why a cell's bounds cannot drift
860
+ * while a gesture is running.
861
+ *
862
+ * Paired with `imageSourceTransform` from `@pictor-sdk/skia`, this is
863
+ * everything a host needs to draw an uncommitted crop from a worklet, using
864
+ * the renderer's own arithmetic rather than a second copy of it.
865
+ *
866
+ * @experimental
867
+ */
868
+ declare function layoutCellBox(cell: Rect, documentSize: Size): Size;
830
869
 
831
870
  /** The active brush: kind, colour and width. Width is a fraction of document width. */
832
871
  interface BrushConfig {
@@ -882,4 +921,4 @@ declare function UiStateProvider({ children }: {
882
921
  /** Read and write one key of the ephemeral UI state. Throws outside an `<Editor>`. */
883
922
  declare function useUiState<T>(key: string, fallback: T): [T, (value: T) => void];
884
923
 
885
- export { type AddPhotoController, type AddPhotoControllerOptions, AddPhotoPanel, AdjustPanel, type BrushConfig, CanvasStage, type CanvasStageProps, CropGestureSurface, type CropGestureSurfaceProps, CropGrid, CropPanel, DEFAULT_BRUSH, type DrawController, DrawPanel, DrawingSurface, Editor, EditorGestures, type EditorGesturesProps, type EditorProps, type EditorSlots, type EditorTheme, FilterStrip, type GestureController, type GestureControllerOptions, type GestureMetrics, type GestureSnapEvent, type GestureSurfaceKind, type HostCallbacks, HostCallbacksProvider, InteractionGuides, type InteractionGuidesProps, InteractionProvider, InvalidImageResultError, LayerGestureSurface, type LayerGestureSurfaceProps, LayersPanel, type LayoutCell, type LayoutCropOptions, type LayoutImageFit, LayoutPanel, type LayoutPreset, MediaPanel, type PartialTheme, STRAIGHTEN_LIMIT, Slider, type SliderProps, type StickerAsset, StickerPanel, StoryMediaGestureSurface, type StoryMediaGestureSurfaceProps, StraightenRuler, type StraightenRulerProps, TextComposer, TextComposerHost, type TextComposerProps, TextPanel, type TextSessionController, ThemeContext, ToolPanelHost, ToolRail, TopBar, type TopBarProps, UiStateProvider, type UiStateStore, type UseTextSessionOptions, canvasSizeForAspect, cellToTransformPatch, centeredCropForAspect, createAddPhotoController, createDrawController, createGestureController, createUiStateStore, defaultTheme, fitImageToLayoutCell, layoutCellBounds, layoutCells, mergeSlots, mergeTheme, radiansToStraighten, resolveGestureSurface, resolvePanel, straightenToRadians, useBaseImageLayer, useBaseMediaLayer, useHostCallbacks, useInteractionState, useTargetImageLayer, useTextSession, useTheme, useUiState };
924
+ export { type AddPhotoController, type AddPhotoControllerOptions, AddPhotoPanel, AdjustPanel, type BrushConfig, CanvasStage, type CanvasStageProps, CropGestureSurface, type CropGestureSurfaceProps, CropGrid, CropPanel, DEFAULT_BRUSH, type DrawController, DrawPanel, DrawingSurface, Editor, EditorGestures, type EditorGesturesProps, type EditorProps, type EditorSlots, type EditorTheme, FilterStrip, type GestureController, type GestureControllerOptions, type GestureMetrics, type GestureSnapEvent, type GestureSurfaceKind, type HostCallbacks, HostCallbacksProvider, InteractionGuides, type InteractionGuidesProps, InteractionProvider, InvalidImageResultError, LayerGestureSurface, type LayerGestureSurfaceProps, LayersPanel, type LayoutCell, type LayoutCropOptions, type LayoutImageFit, LayoutPanel, type LayoutPreset, MediaPanel, type PartialTheme, STRAIGHTEN_LIMIT, Slider, type SliderProps, type StickerAsset, StickerPanel, StoryMediaGestureSurface, type StoryMediaGestureSurfaceProps, StraightenRuler, type StraightenRulerProps, TextComposer, TextComposerHost, type TextComposerProps, TextPanel, type TextSessionController, ThemeContext, ToolPanelHost, ToolRail, TopBar, type TopBarProps, UiStateProvider, type UiStateStore, type UseTextSessionOptions, canvasSizeForAspect, cellToTransformPatch, centeredCropForAspect, createAddPhotoController, createDrawController, createGestureController, createUiStateStore, defaultTheme, fitImageToLayoutCell, layoutCellBounds, layoutCellBox, layoutCells, mergeSlots, mergeTheme, radiansToStraighten, resolveGestureSurface, resolvePanel, straightenToRadians, useBaseImageLayer, useBaseMediaLayer, useHostCallbacks, useInteractionState, useTargetImageLayer, useTextSession, useTheme, useUiState };
package/dist/index.d.ts CHANGED
@@ -87,6 +87,14 @@ interface ComposerInsets {
87
87
  /** Animation duration to match the platform's own, clamped to something sane. */
88
88
  duration: number;
89
89
  }
90
+ interface CanvasRect {
91
+ /** Size of the fitted canvas inside the container. */
92
+ width: number;
93
+ height: number;
94
+ /** Offset of the canvas inside the container, which centres it. */
95
+ left: number;
96
+ top: number;
97
+ }
90
98
 
91
99
  /**
92
100
  * The state and commands behind a text-editing session.
@@ -167,6 +175,11 @@ interface TextComposerProps {
167
175
  onDone(): void;
168
176
  /** Abandon, restoring the document. */
169
177
  onCancel(): void;
178
+ /**
179
+ * The canvas rectangle in this composer's coordinates, when the host draws a
180
+ * cover canvas. Omitted, the composer assumes a centred contain fit.
181
+ */
182
+ canvas?: CanvasRect;
170
183
  }
171
184
  /**
172
185
  * The full-screen Story text composer.
@@ -176,20 +189,22 @@ interface TextComposerProps {
176
189
  * `renderDocument` — so what is on screen while composing is what exports.
177
190
  *
178
191
  * The `TextInput` itself is invisible and stretched over the canvas: it owns
179
- * the caret, the platform selection handles and the IME, while the *visible*
180
- * text is the real Skia layer underneath. That is what keeps preview and export
181
- * identical without giving up native text editing.
192
+ * focus and the IME, while the *visible* text is the real Skia layer
193
+ * underneath. On Android the platform paints composing glyphs black regardless
194
+ * of the input's colour, so the input is fully hidden and a caret is drawn at
195
+ * the measured selection instead. That is what keeps preview and export
196
+ * identical without giving up text editing.
182
197
  *
183
198
  * @experimental
184
199
  */
185
- declare function TextComposer({ session, insets, onDone, onCancel }: TextComposerProps): React__default.JSX.Element;
200
+ declare function TextComposer({ session, insets, onDone, onCancel, canvas }: TextComposerProps): React__default.JSX.Element;
186
201
  /**
187
202
  * Mounts a text session and the composer for it, and keeps the keyboard inset
188
203
  * up to date. This is what `<Editor>` renders while the text tool is active.
189
204
  *
190
205
  * @experimental
191
206
  */
192
- declare function TextComposerHost({ Composer, safeArea, }: {
207
+ declare function TextComposerHost({ Composer, safeArea, canvas, }: {
193
208
  /** Replace the default composer without replacing the canvas or editor. */
194
209
  Composer?: React__default.ComponentType<TextComposerProps>;
195
210
  /**
@@ -203,6 +218,8 @@ declare function TextComposerHost({ Composer, safeArea, }: {
203
218
  top?: number;
204
219
  bottom?: number;
205
220
  };
221
+ /** The canvas rectangle, for a host that draws a cover canvas. */
222
+ canvas?: CanvasRect;
206
223
  }): React__default.JSX.Element;
207
224
 
208
225
  /** What the top bar slot receives: the transaction actions and undo state. */
@@ -605,6 +622,12 @@ declare function EditorGestures({ onSnap }: EditorGesturesProps): React__default
605
622
  interface LayerGestureSurfaceProps {
606
623
  /** Fired once per snap engagement — hook up haptics here if you want them. */
607
624
  onSnap?: (event: GestureSnapEvent) => void;
625
+ /**
626
+ * Fired when a tap lands on a layer that was already selected when the
627
+ * gesture began. Hosts use this for a second-tap action such as opening the
628
+ * text composer; the SDK itself stays unaware of what a layer means.
629
+ */
630
+ onSelectedLayerTap?: (layerId: string) => void;
608
631
  }
609
632
  /**
610
633
  * Direct manipulation of overlay layers: grab the top-most layer under the
@@ -622,7 +645,7 @@ interface LayerGestureSurfaceProps {
622
645
  * - **One history entry.** The document is written exactly once, when the
623
646
  * gesture settles, and not at all if nothing actually moved.
624
647
  */
625
- declare function LayerGestureSurface({ onSnap }: LayerGestureSurfaceProps): React__default.JSX.Element;
648
+ declare function LayerGestureSurface({ onSnap, onSelectedLayerTap }: LayerGestureSurfaceProps): React__default.JSX.Element;
626
649
 
627
650
  /** The centre and rotation guides shown while a layer snaps. */
628
651
  interface InteractionGuidesProps {
@@ -827,6 +850,22 @@ interface LayoutImageFit {
827
850
  declare function fitImageToLayoutCell(cell: Rect, documentSize: Size, intrinsic: Size, options?: LayoutCropOptions): LayoutImageFit;
828
851
  /** The transform patch that moves a layer into a cell, leaving its other fields alone. */
829
852
  declare function cellToTransformPatch(cell: LayoutCell): Partial<Transform>;
853
+ /**
854
+ * The box a photo draws into when it fills one cell, in document pixels.
855
+ *
856
+ * The cell fixes the box: `fitImageToLayoutCell` picks a crop whose aspect
857
+ * matches the cell, so zooming and panning inside it move the source window
858
+ * and leave the box alone. That is what makes a live crop expressible as a
859
+ * source-space override at all, and it is why a cell's bounds cannot drift
860
+ * while a gesture is running.
861
+ *
862
+ * Paired with `imageSourceTransform` from `@pictor-sdk/skia`, this is
863
+ * everything a host needs to draw an uncommitted crop from a worklet, using
864
+ * the renderer's own arithmetic rather than a second copy of it.
865
+ *
866
+ * @experimental
867
+ */
868
+ declare function layoutCellBox(cell: Rect, documentSize: Size): Size;
830
869
 
831
870
  /** The active brush: kind, colour and width. Width is a fraction of document width. */
832
871
  interface BrushConfig {
@@ -882,4 +921,4 @@ declare function UiStateProvider({ children }: {
882
921
  /** Read and write one key of the ephemeral UI state. Throws outside an `<Editor>`. */
883
922
  declare function useUiState<T>(key: string, fallback: T): [T, (value: T) => void];
884
923
 
885
- export { type AddPhotoController, type AddPhotoControllerOptions, AddPhotoPanel, AdjustPanel, type BrushConfig, CanvasStage, type CanvasStageProps, CropGestureSurface, type CropGestureSurfaceProps, CropGrid, CropPanel, DEFAULT_BRUSH, type DrawController, DrawPanel, DrawingSurface, Editor, EditorGestures, type EditorGesturesProps, type EditorProps, type EditorSlots, type EditorTheme, FilterStrip, type GestureController, type GestureControllerOptions, type GestureMetrics, type GestureSnapEvent, type GestureSurfaceKind, type HostCallbacks, HostCallbacksProvider, InteractionGuides, type InteractionGuidesProps, InteractionProvider, InvalidImageResultError, LayerGestureSurface, type LayerGestureSurfaceProps, LayersPanel, type LayoutCell, type LayoutCropOptions, type LayoutImageFit, LayoutPanel, type LayoutPreset, MediaPanel, type PartialTheme, STRAIGHTEN_LIMIT, Slider, type SliderProps, type StickerAsset, StickerPanel, StoryMediaGestureSurface, type StoryMediaGestureSurfaceProps, StraightenRuler, type StraightenRulerProps, TextComposer, TextComposerHost, type TextComposerProps, TextPanel, type TextSessionController, ThemeContext, ToolPanelHost, ToolRail, TopBar, type TopBarProps, UiStateProvider, type UiStateStore, type UseTextSessionOptions, canvasSizeForAspect, cellToTransformPatch, centeredCropForAspect, createAddPhotoController, createDrawController, createGestureController, createUiStateStore, defaultTheme, fitImageToLayoutCell, layoutCellBounds, layoutCells, mergeSlots, mergeTheme, radiansToStraighten, resolveGestureSurface, resolvePanel, straightenToRadians, useBaseImageLayer, useBaseMediaLayer, useHostCallbacks, useInteractionState, useTargetImageLayer, useTextSession, useTheme, useUiState };
924
+ export { type AddPhotoController, type AddPhotoControllerOptions, AddPhotoPanel, AdjustPanel, type BrushConfig, CanvasStage, type CanvasStageProps, CropGestureSurface, type CropGestureSurfaceProps, CropGrid, CropPanel, DEFAULT_BRUSH, type DrawController, DrawPanel, DrawingSurface, Editor, EditorGestures, type EditorGesturesProps, type EditorProps, type EditorSlots, type EditorTheme, FilterStrip, type GestureController, type GestureControllerOptions, type GestureMetrics, type GestureSnapEvent, type GestureSurfaceKind, type HostCallbacks, HostCallbacksProvider, InteractionGuides, type InteractionGuidesProps, InteractionProvider, InvalidImageResultError, LayerGestureSurface, type LayerGestureSurfaceProps, LayersPanel, type LayoutCell, type LayoutCropOptions, type LayoutImageFit, LayoutPanel, type LayoutPreset, MediaPanel, type PartialTheme, STRAIGHTEN_LIMIT, Slider, type SliderProps, type StickerAsset, StickerPanel, StoryMediaGestureSurface, type StoryMediaGestureSurfaceProps, StraightenRuler, type StraightenRulerProps, TextComposer, TextComposerHost, type TextComposerProps, TextPanel, type TextSessionController, ThemeContext, ToolPanelHost, ToolRail, TopBar, type TopBarProps, UiStateProvider, type UiStateStore, type UseTextSessionOptions, canvasSizeForAspect, cellToTransformPatch, centeredCropForAspect, createAddPhotoController, createDrawController, createGestureController, createUiStateStore, defaultTheme, fitImageToLayoutCell, layoutCellBounds, layoutCellBox, layoutCells, mergeSlots, mergeTheme, radiansToStraighten, resolveGestureSurface, resolvePanel, straightenToRadians, useBaseImageLayer, useBaseMediaLayer, useHostCallbacks, useInteractionState, useTargetImageLayer, useTextSession, useTheme, useUiState };
package/dist/index.js CHANGED
@@ -75,6 +75,7 @@ __export(index_exports, {
75
75
  defaultTheme: () => defaultTheme,
76
76
  fitImageToLayoutCell: () => fitImageToLayoutCell,
77
77
  layoutCellBounds: () => layoutCellBounds,
78
+ layoutCellBox: () => layoutCellBox,
78
79
  layoutCells: () => layoutCells,
79
80
  mergeSlots: () => mergeSlots,
80
81
  mergeTheme: () => mergeTheme,
@@ -792,10 +793,16 @@ function hitTestLayerCandidatesUI(x, y, candidates, documentWidth, documentHeigh
792
793
  return null;
793
794
  }
794
795
 
796
+ // src/gestures/selectionTap.ts
797
+ function selectedLayerTapId(hit, wasSelectedAtStart) {
798
+ if (!hit || hit.locked || !wasSelectedAtStart) return null;
799
+ return hit.id;
800
+ }
801
+
795
802
  // src/gestures/LayerGestureSurface.tsx
796
803
  var import_jsx_runtime8 = require("react/jsx-runtime");
797
- function LayerGestureSurface({ onSnap }) {
798
- const { document, dispatch, profile } = (0, import_core8.useEditor)();
804
+ function LayerGestureSurface({ onSnap, onSelectedLayerTap }) {
805
+ const { document, dispatch, profile, selection } = (0, import_core8.useEditor)();
799
806
  const store = (0, import_core8.useEditorStore)();
800
807
  const metrics = (0, import_skia4.useCanvasMetrics)();
801
808
  const { setPreview, setLayerDragActive, setOverDeleteTarget, setSnap } = useInteractionState();
@@ -886,10 +893,11 @@ function LayerGestureSurface({ onSnap }) {
886
893
  document.size.height
887
894
  );
888
895
  }, [document.size.width, document.size.height]);
889
- const latest = (0, import_react9.useRef)({ document, metrics, store, dispatch, onSnap, profile });
890
- latest.current = { document, metrics, store, dispatch, onSnap, profile };
896
+ const latest = (0, import_react9.useRef)({ document, metrics, store, dispatch, onSnap, profile, selection, onSelectedLayerTap });
897
+ latest.current = { document, metrics, store, dispatch, onSnap, profile, selection, onSelectedLayerTap };
891
898
  const grabbedId = (0, import_react9.useRef)(null);
892
899
  const grabbedFrom = (0, import_react9.useRef)(null);
900
+ const tapWasSelected = (0, import_react9.useRef)(false);
893
901
  const snapState = (0, import_react9.useRef)(import_core8.NO_SNAP);
894
902
  const onGrabLayer = (0, import_react9.useCallback)((id) => {
895
903
  const { document: doc, store: s } = latest.current;
@@ -902,9 +910,23 @@ function LayerGestureSurface({ onSnap }) {
902
910
  setPreview({ layerId: hit.id, transform: previewTransform });
903
911
  setLayerDragActive(true);
904
912
  }, [setPreview, setLayerDragActive, previewTransform]);
913
+ const snapshotTapAt = (0, import_react9.useCallback)((xDp, yDp) => {
914
+ const { document: doc, metrics: m, store: s, selection: selected } = latest.current;
915
+ if (!m) return;
916
+ const layerInteraction = latest.current.profile.interaction?.layers;
917
+ const hit = (0, import_core8.hitTestDocument)(
918
+ doc,
919
+ { x: xDp / m.width, y: yDp / m.height },
920
+ s.registry,
921
+ (layer, index) => (0, import_core8.isDirectManipulationLayer)(layer, index, layerInteraction)
922
+ );
923
+ tapWasSelected.current = Boolean(hit && selected.includes(hit.id));
924
+ }, []);
905
925
  const onSelectAt = (0, import_react9.useCallback)((xDp, yDp) => {
906
926
  const { document: doc, metrics: m, store: s } = latest.current;
907
927
  if (!m) return;
928
+ const wasSelected = tapWasSelected.current;
929
+ tapWasSelected.current = false;
908
930
  const layerInteraction = latest.current.profile.interaction?.layers;
909
931
  const hit = (0, import_core8.hitTestDocument)(
910
932
  doc,
@@ -913,6 +935,8 @@ function LayerGestureSurface({ onSnap }) {
913
935
  (layer, index) => (0, import_core8.isDirectManipulationLayer)(layer, index, layerInteraction)
914
936
  );
915
937
  s.select(hit && !hit.locked ? [hit.id] : []);
938
+ const editId = selectedLayerTapId(hit ?? null, wasSelected);
939
+ if (editId) latest.current.onSelectedLayerTap?.(editId);
916
940
  }, []);
917
941
  const onSnapChanged = (0, import_react9.useCallback)(
918
942
  (centerX, centerY, rotation) => {
@@ -1051,6 +1075,8 @@ function LayerGestureSurface({ onSnap }) {
1051
1075
  const pan = import_react_native_gesture_handler4.Gesture.Pan().minPointers(1).maxPointers(2).onTouchesDown((e) => {
1052
1076
  "worklet";
1053
1077
  livePointers.value = e.allTouches.length;
1078
+ const touch = e.changedTouches[0] ?? e.allTouches[0];
1079
+ if (touch) (0, import_react_native_worklets4.runOnJS)(snapshotTapAt)(touch.x, touch.y);
1054
1080
  }).onTouchesUp((e) => {
1055
1081
  "worklet";
1056
1082
  livePointers.value = Math.max(0, e.allTouches.length - e.changedTouches.length);
@@ -1141,6 +1167,7 @@ function LayerGestureSurface({ onSnap }) {
1141
1167
  deleteTarget,
1142
1168
  aspect,
1143
1169
  onGrabLayer,
1170
+ snapshotTapAt,
1144
1171
  onSelectAt,
1145
1172
  onSnapChanged,
1146
1173
  onHoverChanged,
@@ -1818,8 +1845,9 @@ function Slider({
1818
1845
  const valueRef = (0, import_react12.useRef)(value);
1819
1846
  valueRef.current = value;
1820
1847
  const onLayout = (e) => {
1821
- setWidth(e.nativeEvent.layout.width);
1822
- widthRef.current = e.nativeEvent.layout.width;
1848
+ const next = e.nativeEvent.layout.width;
1849
+ widthRef.current = next;
1850
+ setWidth((previous) => previous === next ? previous : next);
1823
1851
  };
1824
1852
  const handlers = (0, import_react12.useMemo)(() => {
1825
1853
  const begin = () => {
@@ -2924,6 +2952,13 @@ function fitImageToLayoutCell(cell, documentSize, intrinsic, options = {}) {
2924
2952
  function cellToTransformPatch(cell) {
2925
2953
  return { x: cell.x, y: cell.y, scale: cell.scale, rotation: 0 };
2926
2954
  }
2955
+ function layoutCellBox(cell, documentSize) {
2956
+ "worklet";
2957
+ return {
2958
+ width: cell.width * documentSize.width,
2959
+ height: cell.height * documentSize.height
2960
+ };
2961
+ }
2927
2962
 
2928
2963
  // src/panels/LayoutPanel.tsx
2929
2964
  var import_jsx_runtime19 = require("react/jsx-runtime");
@@ -3554,7 +3589,8 @@ function fitCanvasRect(container, document) {
3554
3589
  };
3555
3590
  }
3556
3591
  function composerInputBox(request) {
3557
- const canvas = fitCanvasRect(request.container, request.document);
3592
+ const provided = request.canvas;
3593
+ const canvas = provided && provided.width > 0 && provided.height > 0 ? provided : fitCanvasRect(request.container, request.document);
3558
3594
  const fontSize = Math.max(1, request.fontSize * canvas.width);
3559
3595
  const lineHeight = Math.max(1, fontSize * request.lineHeight);
3560
3596
  const scale = request.document.width > 0 ? canvas.width / request.document.width : 0;
@@ -3578,6 +3614,35 @@ function composerInputBox(request) {
3578
3614
  lineHeight
3579
3615
  };
3580
3616
  }
3617
+ function sameCaretLine(previous, next) {
3618
+ return previous === next || previous !== null && next !== null && previous.x === next.x && previous.y === next.y && previous.width === next.width && previous.height === next.height;
3619
+ }
3620
+ var CARET_WIDTH = 2;
3621
+ function composerInputOpacity(platform) {
3622
+ return platform === "android" ? 0 : 1;
3623
+ }
3624
+ function composerCaretBox(box, line) {
3625
+ if (!line || !Number.isFinite(line.x) || !Number.isFinite(line.y) || !Number.isFinite(line.width) || !Number.isFinite(line.height)) {
3626
+ return null;
3627
+ }
3628
+ const height = Math.max(1, line.height || box.lineHeight);
3629
+ return {
3630
+ left: box.left + line.x + line.width - CARET_WIDTH / 2,
3631
+ top: box.top + line.y,
3632
+ width: CARET_WIDTH,
3633
+ height
3634
+ };
3635
+ }
3636
+
3637
+ // src/composer/tapGesture.ts
3638
+ var TAP_SLOP = 8;
3639
+ function pressMoved(start, end, slop = TAP_SLOP) {
3640
+ if (!start || !end) return false;
3641
+ if (!Number.isFinite(start.x) || !Number.isFinite(start.y) || !Number.isFinite(end.x) || !Number.isFinite(end.y)) {
3642
+ return false;
3643
+ }
3644
+ return Math.hypot(end.x - start.x, end.y - start.y) > Math.max(0, slop);
3645
+ }
3581
3646
 
3582
3647
  // src/composer/useTextSession.ts
3583
3648
  var import_core26 = require("@pictor-sdk/core");
@@ -3709,7 +3774,7 @@ var SHADOWS = [
3709
3774
  { color: "#000000", blur: 0.012, dx: 2e-3, dy: 4e-3 },
3710
3775
  { color: "#000000", blur: 2e-3, dx: 6e-3, dy: 8e-3 }
3711
3776
  ];
3712
- function TextComposer({ session, insets, onDone, onCancel }) {
3777
+ function TextComposer({ session, insets, onDone, onCancel, canvas }) {
3713
3778
  const theme = useTheme();
3714
3779
  const store = (0, import_core27.useEditorStore)();
3715
3780
  const { document } = (0, import_core27.useEditor)();
@@ -3719,6 +3784,8 @@ function TextComposer({ session, insets, onDone, onCancel }) {
3719
3784
  const [selectionRange, setSelectionRange] = (0, import_react21.useState)(
3720
3785
  void 0
3721
3786
  );
3787
+ const [focused, setFocused] = (0, import_react21.useState)(false);
3788
+ const [caretLine, setCaretLine] = (0, import_react21.useState)(null);
3722
3789
  const data = session.data;
3723
3790
  const span = session.span;
3724
3791
  const layerId = session.layer?.id;
@@ -3728,7 +3795,12 @@ function TextComposer({ session, insets, onDone, onCancel }) {
3728
3795
  setMeasured(null);
3729
3796
  return;
3730
3797
  }
3731
- const refresh = () => setMeasured(renderer.textMeasures.get(layerId) ?? null);
3798
+ const refresh = () => {
3799
+ const next = renderer.textMeasures.get(layerId) ?? null;
3800
+ setMeasured(
3801
+ (previous) => previous && next && previous.width === next.width && previous.height === next.height ? previous : next
3802
+ );
3803
+ };
3732
3804
  const unsubscribe = renderer.textMeasures.subscribe(refresh);
3733
3805
  refresh();
3734
3806
  return unsubscribe;
@@ -3741,7 +3813,8 @@ function TextComposer({ session, insets, onDone, onCancel }) {
3741
3813
  lineHeight: span?.lineHeight ?? 1.2,
3742
3814
  centerY: session.layer?.transform.y ?? 0.42,
3743
3815
  measured,
3744
- lines: session.value.split("\n").length
3816
+ lines: session.value.split("\n").length,
3817
+ ...canvas ? { canvas } : {}
3745
3818
  }),
3746
3819
  [
3747
3820
  container,
@@ -3750,20 +3823,41 @@ function TextComposer({ session, insets, onDone, onCancel }) {
3750
3823
  span?.lineHeight,
3751
3824
  session.layer?.transform.y,
3752
3825
  session.value,
3753
- measured
3826
+ measured,
3827
+ canvas
3754
3828
  ]
3755
3829
  );
3756
3830
  const fonts = (0, import_react21.useMemo)(() => (0, import_core27.fontChoices)(store.registry.fonts), [store.registry.fonts]);
3757
3831
  (0, import_react21.useEffect)(() => {
3758
- const handle = setTimeout(() => input.current?.focus(), 50);
3832
+ const handle = setTimeout(() => {
3833
+ input.current?.focus();
3834
+ if (session.kind === "existing" && session.value.length > 0) {
3835
+ const end = session.value.length;
3836
+ setSelectionRange({ start: end, end });
3837
+ input.current?.setNativeProps({ selection: { start: end, end } });
3838
+ }
3839
+ }, 50);
3759
3840
  return () => clearTimeout(handle);
3760
3841
  }, []);
3761
3842
  const onSelectionChange = (0, import_react21.useCallback)(
3762
3843
  (event) => {
3763
- setSelectionRange(event.nativeEvent.selection);
3844
+ const next = event.nativeEvent.selection;
3845
+ setSelectionRange(
3846
+ (previous) => previous && previous.start === next.start && previous.end === next.end ? previous : next
3847
+ );
3764
3848
  },
3765
3849
  []
3766
3850
  );
3851
+ const caretAt = selectionRange?.end ?? session.value.length;
3852
+ const caretSource = session.value.slice(0, Math.max(0, caretAt));
3853
+ const onCaretLayout = (0, import_react21.useCallback)((event) => {
3854
+ const lines = event.nativeEvent.lines;
3855
+ const last = lines[lines.length - 1];
3856
+ const next = last ? { x: last.x, y: last.y, width: last.width, height: last.height } : null;
3857
+ setCaretLine((previous) => sameCaretLine(previous, next) ? previous : next);
3858
+ }, []);
3859
+ const caret = composerCaretBox(layout, caretLine);
3860
+ const letterSpacing = span?.letterSpacing && span.fontSize > 0 ? span.letterSpacing * layout.fontSize / span.fontSize : void 0;
3767
3861
  const keepFocus = (0, import_react21.useCallback)(
3768
3862
  (apply) => {
3769
3863
  apply();
@@ -3824,6 +3918,7 @@ function TextComposer({ session, insets, onDone, onCancel }) {
3824
3918
  });
3825
3919
  return () => subscription.remove();
3826
3920
  }, [onCancel, insets.keyboardVisible]);
3921
+ const tapStart = (0, import_react21.useRef)(null);
3827
3922
  const controlRow = {
3828
3923
  flexDirection: "row",
3829
3924
  alignItems: "center",
@@ -3848,6 +3943,54 @@ function TextComposer({ session, insets, onDone, onCancel }) {
3848
3943
  style: { position: "absolute", left: 0, right: 0, top: 0, bottom: 0, backgroundColor: "#00000055" }
3849
3944
  }
3850
3945
  ),
3946
+ /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
3947
+ import_react_native24.Pressable,
3948
+ {
3949
+ accessible: true,
3950
+ collapsable: false,
3951
+ accessibilityRole: "button",
3952
+ accessibilityLabel: "Finish editing text",
3953
+ onAccessibilityTap: onDone,
3954
+ onPressIn: (event) => {
3955
+ tapStart.current = {
3956
+ x: event.nativeEvent.pageX,
3957
+ y: event.nativeEvent.pageY
3958
+ };
3959
+ },
3960
+ onPress: (event) => {
3961
+ const start = tapStart.current;
3962
+ const end = { x: event.nativeEvent.pageX, y: event.nativeEvent.pageY };
3963
+ if (!pressMoved(start, end)) onDone();
3964
+ },
3965
+ style: { position: "absolute", left: 0, right: 0, top: 0, bottom: 0 }
3966
+ }
3967
+ ),
3968
+ /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
3969
+ import_react_native24.Text,
3970
+ {
3971
+ accessible: false,
3972
+ importantForAccessibility: "no-hide-descendants",
3973
+ pointerEvents: "none",
3974
+ onTextLayout: onCaretLayout,
3975
+ style: {
3976
+ position: "absolute",
3977
+ left: layout.left,
3978
+ width: layout.width,
3979
+ top: layout.top,
3980
+ height: layout.height,
3981
+ opacity: 0,
3982
+ fontSize: layout.fontSize,
3983
+ lineHeight: layout.lineHeight,
3984
+ fontFamily: span?.fontFamily,
3985
+ fontWeight: span?.fontWeight ? `${span.fontWeight}` : void 0,
3986
+ letterSpacing,
3987
+ textAlign: data?.align ?? "center",
3988
+ padding: 0
3989
+ },
3990
+ children: `${caretSource}\u200B`
3991
+ },
3992
+ `${caretSource}:${caretAt}`
3993
+ ),
3851
3994
  /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
3852
3995
  import_react_native24.TextInput,
3853
3996
  {
@@ -3855,6 +3998,8 @@ function TextComposer({ session, insets, onDone, onCancel }) {
3855
3998
  value: session.value,
3856
3999
  onChangeText: session.setValue,
3857
4000
  onSelectionChange,
4001
+ onFocus: () => setFocused(true),
4002
+ onBlur: () => setFocused(false),
3858
4003
  multiline: true,
3859
4004
  submitBehavior: "newline",
3860
4005
  autoCorrect: false,
@@ -3874,6 +4019,7 @@ function TextComposer({ session, insets, onDone, onCancel }) {
3874
4019
  // rgba rather than the 'transparent' keyword because the keyword is
3875
4020
  // not reliably honoured for TextInput colour on Android.
3876
4021
  color: "rgba(0,0,0,0)",
4022
+ opacity: composerInputOpacity(import_react_native24.Platform.OS),
3877
4023
  fontSize: layout.fontSize,
3878
4024
  lineHeight: layout.lineHeight,
3879
4025
  textAlign: data?.align ?? "center",
@@ -3884,6 +4030,22 @@ function TextComposer({ session, insets, onDone, onCancel }) {
3884
4030
  cursorColor: theme.colors.accent
3885
4031
  }
3886
4032
  ),
4033
+ focused && caret ? /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
4034
+ import_react_native24.View,
4035
+ {
4036
+ pointerEvents: "none",
4037
+ accessible: false,
4038
+ style: {
4039
+ position: "absolute",
4040
+ left: caret.left,
4041
+ top: caret.top,
4042
+ width: caret.width,
4043
+ height: caret.height,
4044
+ borderRadius: caret.width / 2,
4045
+ backgroundColor: theme.colors.accent
4046
+ }
4047
+ }
4048
+ ) : null,
3887
4049
  /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)(
3888
4050
  import_react_native24.View,
3889
4051
  {
@@ -4083,7 +4245,8 @@ function TextComposer({ session, insets, onDone, onCancel }) {
4083
4245
  }
4084
4246
  function TextComposerHost({
4085
4247
  Composer = TextComposer,
4086
- safeArea
4248
+ safeArea,
4249
+ canvas
4087
4250
  }) {
4088
4251
  const { setActiveTool } = (0, import_core27.useEditor)();
4089
4252
  const session = useTextSession();
@@ -4131,7 +4294,8 @@ function TextComposerHost({
4131
4294
  session,
4132
4295
  insets,
4133
4296
  onDone: () => finish(true),
4134
- onCancel: () => finish(false)
4297
+ onCancel: () => finish(false),
4298
+ ...canvas ? { canvas } : {}
4135
4299
  }
4136
4300
  );
4137
4301
  }
@@ -4720,6 +4884,7 @@ function TextPanel() {
4720
4884
  defaultTheme,
4721
4885
  fitImageToLayoutCell,
4722
4886
  layoutCellBounds,
4887
+ layoutCellBox,
4723
4888
  layoutCells,
4724
4889
  mergeSlots,
4725
4890
  mergeTheme,