File size: 10,767 Bytes
fd3b6ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
// DasuMae Frame β€” layout engine (pure geometry, no DOM, no dependencies).
//
// Single source of truth for where the screenshot(s) are drawn on the export
// canvas. The live preview and the export renderer both call the same
// functions here so what you see is exactly what you get.
//
// Model
// -----
// 1. The canvas is canvasW Γ— canvasH device pixels (an export preset size,
//    e.g. 1600Γ—900, or the Original fit size).
// 2. Padding insets the canvas on all four sides, leaving the *content rect*
//    β€” the area the image(s) are allowed to occupy. Padding per side is
//        pad = (paddingPct / 100) * (min(canvasW, canvasH) / 2)
//    i.e. paddingPct is a fraction of the shorter side split across the two
//    opposite edges. It is clamped so the content rect never collapses below
//    MIN_CONTENT px on either axis.
// 3. The content rect is split into *cells*: 1 cell in single mode (today's
//    behaviour, byte-for-byte unchanged), or 2 cells in 2-up ("duo") mode β€”
//    columns (left/right) or rows (top/bottom) β€” separated by a gap (px at
//    export scale). The gap lives only *between* cells; the padding still
//    sits outside, so the outer edges are never pushed in by the gap and the
//    gap is never doubled at the canvas edge. See cellRects().
// 4. Each cell runs today's per-image math independently (placeInRect): the
//    image is fitted into the cell rect with "contain" semantics
//    (baseScale = min(cellW/imageW, cellH/imageH)), then multiplied by the
//    user scale. drawnW/H = imageW/H * baseScale * scale.
// 5. The image is centred in its cell, then shifted by the drag offset
//    (offsetX, offsetY) in canvas pixels. The offset is clamped to that
//    cell (see below) and the *clamped* value is returned.
//
// Offset clamp rule (documented, one rule for both regimes)
// ---------------------------------------------------------
// On each axis independently let
//     limit = |contentSize - drawnSize| / 2
// and clamp the offset to [-limit, +limit].
//   β€’ Zoomed in  (drawnSize >= contentSize): limit = (drawn - content)/2.
//     The image always fully covers the content rect; an edge can travel up
//     to the matching content edge but never past it, so dragging never
//     exposes a background gap *inside* the content area.
//   β€’ Fitted      (drawnSize <  contentSize): limit = (content - drawn)/2.
//     There is intended padding slack; the image may slide until one of its
//     edges meets a content edge, but no further β€” so it can be repositioned
//     without being flung out and leaving an absurd gap.
// When drawnSize == contentSize the limit is 0 (no travel), which is correct.

export const MIN_CONTENT = 1; // px β€” content rect never smaller than this

/** Clamp a number to [lo, hi]. */
export function clamp(v, lo, hi) {
  if (hi < lo) return lo;
  return v < lo ? lo : v > hi ? hi : v;
}

/**
 * Content rect (the padded area the image lives in) for a canvas.
 * @returns {{x:number,y:number,w:number,h:number,pad:number}}
 */
export function contentRect(canvasW, canvasH, paddingPct) {
  const W = Math.max(0, canvasW);
  const H = Math.max(0, canvasH);
  const pct = clamp(paddingPct, 0, 100);
  let pad = (pct / 100) * (Math.min(W, H) / 2);
  // Keep at least MIN_CONTENT on each axis.
  const maxPad = Math.max(0, (Math.min(W, H) - MIN_CONTENT) / 2);
  pad = clamp(pad, 0, maxPad);
  return { x: pad, y: pad, w: W - 2 * pad, h: H - 2 * pad, pad };
}

/** "contain" fit scale of an image into a box. */
export function fitScale(boxW, boxH, imageW, imageH) {
  if (imageW <= 0 || imageH <= 0 || boxW <= 0 || boxH <= 0) return 0;
  return Math.min(boxW / imageW, boxH / imageH);
}

/** Per-axis max offset magnitude for the clamp rule above. */
export function offsetLimit(contentSize, drawnSize) {
  return Math.abs(contentSize - drawnSize) / 2;
}

/**
 * Place one image inside an arbitrary rectangle ("contain" fit + user scale +
 * clamped drag offset). This is today's per-image math, factored out so a
 * single cell (single mode) and each of two cells (2-up) share exactly the
 * same geometry β€” the clamp is always relative to the rect it is given.
 *
 * @param {{x:number,y:number,w:number,h:number}} rect  the cell the image lives in
 * @param {number} imageW  source image width  (px)
 * @param {number} imageH  source image height (px)
 * @param {number} [scale=1]   user scale multiplier (>0), applied on top of fit
 * @param {number} [offsetX=0] drag offset X (px, before clamp)
 * @param {number} [offsetY=0] drag offset Y (px, before clamp)
 * @returns {{
 *   image:{x:number,y:number,w:number,h:number},
 *   baseScale:number, effectiveScale:number,
 *   offset:{x:number,y:number}, limit:{x:number,y:number}
 * }}
 */
export function placeInRect(rect, imageW, imageH, scale, offsetX, offsetY) {
  const iW = Math.max(0, imageW || 0);
  const iH = Math.max(0, imageH || 0);
  const s = scale == null ? 1 : Math.max(0, scale);
  const oX = offsetX || 0;
  const oY = offsetY || 0;

  // Degenerate image: nothing to draw (anchored at the cell's top-left).
  if (iW === 0 || iH === 0) {
    return {
      image: { x: rect.x, y: rect.y, w: 0, h: 0 },
      baseScale: 0,
      effectiveScale: 0,
      offset: { x: 0, y: 0 },
      limit: { x: 0, y: 0 },
    };
  }

  const baseScale = fitScale(rect.w, rect.h, iW, iH);
  const effectiveScale = baseScale * s;
  const drawnW = iW * effectiveScale;
  const drawnH = iH * effectiveScale;

  const limitX = offsetLimit(rect.w, drawnW);
  const limitY = offsetLimit(rect.h, drawnH);
  const clampedX = clamp(oX, -limitX, limitX);
  const clampedY = clamp(oY, -limitY, limitY);

  // Centre of the cell.
  const cx = rect.x + rect.w / 2;
  const cy = rect.y + rect.h / 2;

  return {
    image: {
      x: cx - drawnW / 2 + clampedX,
      y: cy - drawnH / 2 + clampedY,
      w: drawnW,
      h: drawnH,
    },
    baseScale,
    effectiveScale,
    offset: { x: clampedX, y: clampedY },
    limit: { x: limitX, y: limitY },
  };
}

/**
 * Split a content rect into cells.
 *   β€’ single          β†’ one cell equal to the whole content rect.
 *   β€’ duo / columns    β†’ left + right cells, each (contentW βˆ’ gap)/2 wide,
 *                        full content height, with `gap` px between them.
 *   β€’ duo / rows       β†’ top + bottom cells, each (contentH βˆ’ gap)/2 tall,
 *                        full content width, with `gap` px between them.
 * The gap sits ONLY between the two cells; the padding stays outside, so the
 * outer edges are the content-rect edges (never inset by the gap) and the gap
 * is never doubled at the canvas edge. The gap is clamped so neither cell
 * collapses below MIN_CONTENT on the split axis.
 *
 * @returns {Array<{x:number,y:number,w:number,h:number}>}
 */
export function cellRects(content, mode = 'single', orientation = 'columns', gap = 0) {
  if (mode !== 'duo') {
    return [{ x: content.x, y: content.y, w: content.w, h: content.h }];
  }
  const g0 = Math.max(0, gap || 0);
  if (orientation === 'rows') {
    const g = clamp(g0, 0, Math.max(0, content.h - 2 * MIN_CONTENT));
    const cellH = (content.h - g) / 2;
    return [
      { x: content.x, y: content.y, w: content.w, h: cellH },
      { x: content.x, y: content.y + cellH + g, w: content.w, h: cellH },
    ];
  }
  const g = clamp(g0, 0, Math.max(0, content.w - 2 * MIN_CONTENT));
  const cellW = (content.w - g) / 2;
  return [
    { x: content.x, y: content.y, w: cellW, h: content.h },
    { x: content.x + cellW + g, y: content.y, w: cellW, h: content.h },
  ];
}

/**
 * Compute the drawn image rectangle on the export canvas (single image).
 * Thin wrapper over contentRect() + placeInRect() β€” its result is byte-for-byte
 * identical to the original single-image engine, so Phase 1 is unchanged.
 *
 * @param {object} p
 * @param {number} p.canvasW   export/preview canvas width  (px)
 * @param {number} p.canvasH   export/preview canvas height (px)
 * @param {number} p.imageW    source image width  (px)
 * @param {number} p.imageH    source image height (px)
 * @param {number} [p.paddingPct=0] padding as % of shorter side (0..100)
 * @param {number} [p.scale=1]  user scale multiplier (>0), applied on top of fit
 * @param {number} [p.offsetX=0] drag offset X (px, before clamp)
 * @param {number} [p.offsetY=0] drag offset Y (px, before clamp)
 * @returns {{
 *   content:{x:number,y:number,w:number,h:number,pad:number},
 *   image:{x:number,y:number,w:number,h:number},
 *   baseScale:number,
 *   effectiveScale:number,
 *   offset:{x:number,y:number},
 *   limit:{x:number,y:number}
 * }}
 */
export function computeLayout(p) {
  const canvasW = Math.max(0, p.canvasW || 0);
  const canvasH = Math.max(0, p.canvasH || 0);
  const paddingPct = p.paddingPct == null ? 0 : p.paddingPct;
  const content = contentRect(canvasW, canvasH, paddingPct);
  const placed = placeInRect(content, p.imageW, p.imageH, p.scale, p.offsetX, p.offsetY);
  return { content, ...placed };
}

/**
 * Compute the full composite layout: content rect β†’ cells β†’ per-cell image
 * placement. Single mode returns exactly one cell (identical to computeLayout).
 *
 * @param {object} p
 * @param {number} p.canvasW
 * @param {number} p.canvasH
 * @param {number} [p.paddingPct=0]
 * @param {'single'|'duo'} [p.mode='single']
 * @param {'columns'|'rows'} [p.orientation='columns']  (duo only)
 * @param {number} [p.gap=0]  px at export scale, between the two cells (duo only)
 * @param {Array<{imageW:number,imageH:number,scale?:number,offsetX?:number,offsetY?:number}>} [p.images=[]]
 * @returns {{
 *   content:{x:number,y:number,w:number,h:number,pad:number},
 *   mode:'single'|'duo', orientation:'columns'|'rows', gap:number,
 *   cells: Array<{
 *     cell:{x:number,y:number,w:number,h:number},
 *     image:{x:number,y:number,w:number,h:number},
 *     baseScale:number, effectiveScale:number,
 *     offset:{x:number,y:number}, limit:{x:number,y:number}
 *   }>
 * }}
 */
export function computeComposite(p) {
  const canvasW = Math.max(0, p.canvasW || 0);
  const canvasH = Math.max(0, p.canvasH || 0);
  const paddingPct = p.paddingPct == null ? 0 : p.paddingPct;
  const content = contentRect(canvasW, canvasH, paddingPct);
  const mode = p.mode === 'duo' ? 'duo' : 'single';
  const orientation = p.orientation === 'rows' ? 'rows' : 'columns';
  const gap = mode === 'duo' ? Math.max(0, p.gap || 0) : 0;
  const rects = cellRects(content, mode, orientation, gap);
  const images = p.images || [];
  const cells = rects.map((rect, i) => {
    const im = images[i] || {};
    const placed = placeInRect(rect, im.imageW, im.imageH, im.scale, im.offsetX, im.offsetY);
    return { cell: rect, ...placed };
  });
  return { content, mode, orientation, gap, cells };
}