@pictor-sdk/ui 0.1.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pictor-sdk/ui",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Default slot-based editor UI for Pictor, built entirely on the public headless API.",
5
5
  "license": "MIT",
6
6
  "sideEffects": false,
@@ -33,8 +33,8 @@
33
33
  "react-native-gesture-handler": ">=2.20.0",
34
34
  "react-native-reanimated": ">=3.16.0",
35
35
  "react-native-worklets": ">=0.5.1",
36
- "@pictor-sdk/core": "^0.1.0",
37
- "@pictor-sdk/skia": "^0.1.0"
36
+ "@pictor-sdk/core": "^0.2.0",
37
+ "@pictor-sdk/skia": "^0.2.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@shopify/react-native-skia": "^2.0.0",
@@ -44,8 +44,8 @@
44
44
  "react-native-gesture-handler": "^2.28.0",
45
45
  "react-native-reanimated": "^4.1.0",
46
46
  "react-native-worklets": "0.5.1",
47
- "@pictor-sdk/core": "0.1.0",
48
- "@pictor-sdk/skia": "0.1.0"
47
+ "@pictor-sdk/core": "0.2.0",
48
+ "@pictor-sdk/skia": "0.2.0"
49
49
  },
50
50
  "publishConfig": {
51
51
  "access": "public",
package/src/index.ts CHANGED
@@ -67,6 +67,7 @@ export { StickerPanel, type StickerAsset } from './panels/StickerPanel'
67
67
  export { LayersPanel } from './panels/LayersPanel'
68
68
  export { LayoutPanel } from './panels/LayoutPanel'
69
69
  export { layoutCells, cellToTransformPatch, type LayoutCell, type LayoutPreset } from './panels/layoutMath'
70
+ export { layoutCellBounds, fitImageToLayoutCell, type LayoutCropOptions, type LayoutImageFit } from './panels/layoutMath'
70
71
  export { DrawingSurface, DEFAULT_BRUSH } from './gestures/DrawingSurface'
71
72
  export { createDrawController, type BrushConfig, type DrawController } from './gestures/drawController'
72
73
  export { UiStateProvider, useUiState, createUiStateStore, type UiStateStore } from './uiState'
@@ -1,8 +1,8 @@
1
- import { useEditor } from '@pictor-sdk/core'
1
+ import { useEditor, type ImageLayerData } from '@pictor-sdk/core'
2
2
  import React from 'react'
3
3
  import { Pressable, ScrollView, Text } from 'react-native'
4
4
  import { useTheme } from '../theme'
5
- import { cellToTransformPatch, layoutCells, type LayoutPreset } from './layoutMath'
5
+ import { fitImageToLayoutCell, layoutCellBounds, type LayoutPreset } from './layoutMath'
6
6
 
7
7
  const PRESETS: Array<{ id: LayoutPreset; label: string }> = [
8
8
  { id: '1', label: 'Single' },
@@ -10,6 +10,7 @@ const PRESETS: Array<{ id: LayoutPreset; label: string }> = [
10
10
  { id: '2h', label: '2 cols' },
11
11
  { id: '3v', label: '3 rows' },
12
12
  { id: '4grid', label: 'Grid' },
13
+ { id: '6grid', label: '6 grid' },
13
14
  ]
14
15
 
15
16
  /** Layout grids: snap the image layers into the chosen arrangement. */
@@ -18,11 +19,16 @@ export function LayoutPanel() {
18
19
  const theme = useTheme()
19
20
 
20
21
  const apply = (preset: LayoutPreset) => {
21
- const cells = layoutCells(preset, document.size)
22
+ const cells = layoutCellBounds(preset)
22
23
  const images = document.layers.filter((l) => l.type === 'image')
24
+ // Calculate before opening history: invalid intrinsic data cannot leave a batch open.
25
+ const fits = images.slice(0, cells.length).map((layer, i) => ({
26
+ layer, fit: fitImageToLayoutCell(cells[i]!, document.size, (layer.data as ImageLayerData).intrinsic),
27
+ }))
23
28
  dispatch({ type: 'history/beginBatch', label: 'layout' })
24
- images.slice(0, cells.length).forEach((layer, i) => {
25
- dispatch({ type: 'layer/transform', id: layer.id, patch: cellToTransformPatch(cells[i]!) })
29
+ fits.forEach(({ layer, fit }) => {
30
+ dispatch({ type: 'layer/transform', id: layer.id, patch: fit.transform })
31
+ dispatch({ type: 'layer/patchData', id: layer.id, patch: { crop: fit.crop, pixelSnap: fit.pixelSnap } })
26
32
  })
27
33
  dispatch({ type: 'history/endBatch' })
28
34
  }
@@ -1,7 +1,8 @@
1
- import { type Size, type Transform } from '@pictor-sdk/core'
1
+ import { type Rect, type Size, type Transform } from '@pictor-sdk/core'
2
+ import { centeredCropForAspect } from './cropMath'
2
3
 
3
4
  /** The bundled multi-photo arrangements, by cell count and split direction. */
4
- export type LayoutPreset = '1' | '2v' | '2h' | '3v' | '4grid'
5
+ export type LayoutPreset = '1' | '2v' | '2h' | '3v' | '4grid' | '6grid'
5
6
 
6
7
  /** One slot in a layout: where its photo sits, in normalized document units. */
7
8
  export interface LayoutCell {
@@ -18,38 +19,91 @@ export interface LayoutCell {
18
19
  * canvas size works.
19
20
  */
20
21
  export function layoutCells(preset: LayoutPreset, _doc: Size): LayoutCell[] {
21
- switch (preset) {
22
- case '1':
23
- return [{ x: 0.5, y: 0.5, scale: 1 }]
24
- case '2v': // two rows
25
- return [
26
- { x: 0.5, y: 0.25, scale: 1 },
27
- { x: 0.5, y: 0.75, scale: 1 },
28
- ]
29
- case '2h': // two columns
30
- return [
31
- { x: 0.25, y: 0.5, scale: 0.5 },
32
- { x: 0.75, y: 0.5, scale: 0.5 },
33
- ]
34
- case '3v':
35
- return [
36
- { x: 0.5, y: 1 / 6, scale: 1 },
37
- { x: 0.5, y: 0.5, scale: 1 },
38
- { x: 0.5, y: 5 / 6, scale: 1 },
39
- ]
40
- case '4grid':
41
- return [
42
- { x: 0.25, y: 0.25, scale: 0.5 },
43
- { x: 0.75, y: 0.25, scale: 0.5 },
44
- { x: 0.25, y: 0.75, scale: 0.5 },
45
- { x: 0.75, y: 0.75, scale: 0.5 },
46
- ]
47
- default: {
48
- const _exhaustive: never = preset
49
- void _exhaustive
50
- return [{ x: 0.5, y: 0.5, scale: 1 }]
22
+ return layoutCellBounds(preset).map((cell) => ({
23
+ x: (cell.x / cell.width + 0.5) * cell.width,
24
+ y: (cell.y / cell.height + 0.5) * cell.height,
25
+ scale: cell.width,
26
+ }))
27
+ }
28
+
29
+ /**
30
+ * Axis-aligned cell rectangles, row-major, normalized to document width/height.
31
+ * x/y are TOP-LEFT coordinates, unlike the centres in legacy LayoutCell.
32
+ * Returns fresh rectangles; no document aspect ratio is assumed.
33
+ */
34
+ export function layoutCellBounds(preset: LayoutPreset): Rect[] {
35
+ const dimensions: Record<LayoutPreset, readonly [number, number]> = {
36
+ '1': [1, 1], '2v': [1, 2], '2h': [2, 1], '3v': [1, 3],
37
+ '4grid': [2, 2], '6grid': [2, 3],
38
+ }
39
+ const grid = dimensions[preset]
40
+ if (!grid) throw new RangeError('Unknown layout preset')
41
+ const [columns, rows] = grid
42
+ return Array.from({ length: columns * rows }, (_, index) => ({
43
+ x: (index % columns) / columns, y: Math.floor(index / columns) / rows,
44
+ width: 1 / columns, height: 1 / rows,
45
+ }))
46
+ }
47
+
48
+ /** Source-window controls; pan is a source-space crop centre, not a layer translation. */
49
+ export interface LayoutCropOptions {
50
+ /** Magnification relative to crop-to-fill. Clamped to [1, 32]; default 1. */
51
+ zoom?: number
52
+ /** Desired source-normalized crop centre; clamped so the crop stays in the source. */
53
+ center?: { x: number; y: number }
54
+ }
55
+
56
+ /** Crop and complete transform to apply together in one history batch. */
57
+ export interface LayoutImageFit {
58
+ crop: Rect
59
+ transform: Transform
60
+ /** Apply to ImageLayerData together with crop for seam-free cell edges. */
61
+ pixelSnap: true
62
+ }
63
+
64
+ /**
65
+ * Fill a fixed cell without bleed. Zoom/pan alter only the source crop, never
66
+ * the cell bounds. intrinsic must describe the decoded, orientation-corrected
67
+ * source. Recompute after source replacement or document resize.
68
+ * Throws RangeError for non-finite controls, invalid sizes or out-of-document cells.
69
+ * Apply transform with layer/transform and crop + pixelSnap with layer/patchData in one batch.
70
+ * General layer gestures must not subsequently move/rotate/scale a fixed cell.
71
+ */
72
+ export function fitImageToLayoutCell(
73
+ cell: Rect, documentSize: Size, intrinsic: Size, options: LayoutCropOptions = {},
74
+ ): LayoutImageFit {
75
+ for (const size of [documentSize, intrinsic]) {
76
+ if (![size.width, size.height].every((v) => Number.isFinite(v) && v > 0)) {
77
+ throw new RangeError('Layout sizes must be finite and positive')
51
78
  }
52
79
  }
80
+ if (![cell.x, cell.y, cell.width, cell.height].every(Number.isFinite) ||
81
+ cell.x < 0 || cell.y < 0 || cell.width <= 0 || cell.height <= 0 ||
82
+ cell.x + cell.width > 1 + 1e-12 || cell.y + cell.height > 1 + 1e-12) {
83
+ throw new RangeError('Layout cell must lie inside the document')
84
+ }
85
+ const zoom = options.zoom ?? 1
86
+ const center = options.center ?? { x: 0.5, y: 0.5 }
87
+ if (![zoom, center.x, center.y].every(Number.isFinite)) {
88
+ throw new RangeError('Layout controls must be finite')
89
+ }
90
+ const base = centeredCropForAspect(intrinsic,
91
+ (cell.width * documentSize.width) / (cell.height * documentSize.height))
92
+ const factor = Math.max(1, Math.min(32, zoom))
93
+ const width = base.width / factor
94
+ const height = base.height / factor
95
+ return {
96
+ pixelSnap: true,
97
+ crop: {
98
+ x: Math.max(0, Math.min(1 - width, center.x - width / 2)),
99
+ y: Math.max(0, Math.min(1 - height, center.y - height / 2)), width, height,
100
+ },
101
+ transform: {
102
+ x: cell.x + cell.width / 2, y: cell.y + cell.height / 2,
103
+ scale: cell.width, rotation: 0, anchor: { x: 0.5, y: 0.5 },
104
+ flipX: false, flipY: false,
105
+ },
106
+ }
53
107
  }
54
108
 
55
109
  /** The transform patch that moves a layer into a cell, leaving its other fields alone. */