sdragly commited on
Commit
4bc50bd
·
1 Parent(s): 2db339f

Pinch to resize

Browse files
Files changed (2) hide show
  1. js/dressup.js +139 -207
  2. style.css +4 -1
js/dressup.js CHANGED
@@ -1,25 +1,26 @@
1
  import { loadPrincesses, loadClothes, blobToURL } from './store.js';
2
- import {
3
- getCategoryInfo, DEFAULT_ANCHORS,
4
- SNAP_THRESHOLD_PX, MAGNETIC_START_PX, DRAG_DEAD_ZONE_PX, DOUBLE_TAP_MS,
5
- } from './models.js';
6
 
7
  let princesses = [];
8
  let clothes = [];
9
  let currentIdx = 0;
10
- let placedItems = []; // { clothingId, anchorUsed, x, y, scale, zIndex, url }
11
  let objectURLs = [];
12
  let clothesURLMap = {}; // id -> objectURL
13
 
14
- // Drag state
15
- let drag = null; // { source:'tray'|'canvas', id, url, anchor, scale, zIndex, startX, startY, canvasIdx?, hasMoved }
16
- let ghost = null; // the floating drag element
17
- let snapZones = []; // computed pixel positions [{name, x, y}]
18
- let activeSnap = null; // index into snapZones or null
19
 
20
- // Double-tap tracking for placed items
 
 
 
21
  let lastPlacedTap = { idx: -1, time: 0 };
22
 
 
 
 
23
  export async function initDressUp() {
24
  const screen = document.getElementById('dressup-screen');
25
 
@@ -30,7 +31,6 @@ export async function initDressUp() {
30
  objectURLs = [];
31
  clothesURLMap = {};
32
 
33
- // Pre-create object URLs for all clothes
34
  clothes.forEach(c => {
35
  const url = blobToURL(c.imageBlob);
36
  objectURLs.push(url);
@@ -57,7 +57,6 @@ export async function initDressUp() {
57
  <div id="princess-canvas" class="princess-canvas">
58
  <img id="princess-img" class="princess-img" src="" alt=""/>
59
  <div id="placed-layer" class="placed-layer"></div>
60
- <div id="snap-rings" class="snap-rings"></div>
61
  </div>
62
  </div>
63
 
@@ -77,9 +76,10 @@ export async function initDressUp() {
77
  </div>
78
  `;
79
 
 
80
  renderPrincess();
81
  renderTray();
82
- bindDressUpEvents();
83
  }
84
 
85
  // ---- Rendering ----
@@ -100,7 +100,6 @@ function renderPrincess() {
100
 
101
  placedItems = [];
102
  renderPlaced();
103
- renderSnapRings();
104
  }
105
 
106
  function renderTray() {
@@ -112,7 +111,7 @@ function renderTray() {
112
  const catInfo = getCategoryInfo(c.category);
113
  return `
114
  <div class="tray-item" data-id="${c.id}"
115
- data-anchor="${c.targetAnchor}" data-scale="${c.scale}" data-z="${c.zIndex}">
116
  <img src="${url}" class="tray-thumb" alt="${c.name}" draggable="false"/>
117
  <span class="tray-label">${catInfo ? catInfo.emoji : ''} ${c.name}</span>
118
  </div>
@@ -141,80 +140,19 @@ function renderPlaced() {
141
  `).join('');
142
  }
143
 
144
- function renderSnapRings() {
145
- const container = document.getElementById('snap-rings');
146
- if (!container) return;
147
- const p = princesses[currentIdx];
148
- if (!p) return;
149
-
150
- const anchors = p.anchors || DEFAULT_ANCHORS;
151
- container.innerHTML = Object.entries(anchors).map(([name, pos]) => `
152
- <div class="snap-ring" data-anchor="${name}"
153
- style="left:${pos.x * 100}%; top:${pos.y * 100}%;">
154
- </div>
155
- `).join('');
156
- }
157
-
158
- function updateSnapRingActive(anchorName) {
159
- document.querySelectorAll('.snap-ring').forEach(el => {
160
- el.classList.toggle('active', el.dataset.anchor === anchorName);
161
- });
162
- }
163
-
164
- function clearSnapRingActive() {
165
- document.querySelectorAll('.snap-ring').forEach(el => el.classList.remove('active'));
166
- }
167
-
168
- // ---- Snap zone computation ----
169
-
170
- function computeSnapZones() {
171
- const canvas = document.getElementById('princess-canvas');
172
- if (!canvas) return [];
173
- const rect = canvas.getBoundingClientRect();
174
- const p = princesses[currentIdx];
175
- if (!p) return [];
176
-
177
- const anchors = p.anchors || DEFAULT_ANCHORS;
178
- return Object.entries(anchors).map(([name, pos]) => ({
179
- name,
180
- x: rect.left + pos.x * rect.width,
181
- y: rect.top + pos.y * rect.height,
182
- nx: pos.x, // normalized
183
- ny: pos.y,
184
- }));
185
- }
186
-
187
- function findNearestZone(fingerX, fingerY) {
188
- let best = null;
189
- let bestDist = Infinity;
190
- snapZones.forEach((z, i) => {
191
- const d = dist(fingerX, fingerY, z.x, z.y);
192
- if (d < bestDist) { bestDist = d; best = i; }
193
- });
194
- if (bestDist < SNAP_THRESHOLD_PX) return { idx: best, dist: bestDist, locked: true };
195
- if (bestDist < MAGNETIC_START_PX) return { idx: best, dist: bestDist, locked: false };
196
- return null;
197
- }
198
-
199
- function magneticPosition(fingerX, fingerY, zone) {
200
- const d = dist(fingerX, fingerY, zone.x, zone.y);
201
- if (d < SNAP_THRESHOLD_PX) return { x: zone.x, y: zone.y };
202
- const t = 1.0 - (d / MAGNETIC_START_PX);
203
- const pull = t * t; // quadratic
204
- return {
205
- x: lerp(fingerX, zone.x, pull),
206
- y: lerp(fingerY, zone.y, pull),
207
- };
208
- }
209
-
210
- function dist(x1, y1, x2, y2) {
211
- const dx = x1 - x2, dy = y1 - y2;
212
- return Math.sqrt(dx * dx + dy * dy);
213
  }
214
 
215
- function lerp(a, b, t) { return a + (b - a) * t; }
216
-
217
- // ---- Ghost element ----
218
 
219
  function createGhost(url) {
220
  if (ghost) ghost.remove();
@@ -223,7 +161,6 @@ function createGhost(url) {
223
  ghost.className = 'drag-ghost';
224
  ghost.draggable = false;
225
  document.body.appendChild(ghost);
226
- return ghost;
227
  }
228
 
229
  function moveGhost(x, y) {
@@ -233,14 +170,22 @@ function moveGhost(x, y) {
233
 
234
  function removeGhost() {
235
  if (ghost) { ghost.remove(); ghost = null; }
236
- clearSnapRingActive();
237
- activeSnap = null;
238
  }
239
 
240
- // ---- Drag & drop events ----
241
 
242
- function bindDressUpEvents() {
243
- // Princess switcher
 
 
 
 
 
 
 
 
 
 
244
  document.getElementById('prev-btn').addEventListener('click', () => {
245
  if (currentIdx > 0) { currentIdx--; renderPrincess(); }
246
  });
@@ -248,33 +193,28 @@ function bindDressUpEvents() {
248
  if (currentIdx < princesses.length - 1) { currentIdx++; renderPrincess(); }
249
  });
250
 
251
- // Tray: pointerdown starts potential drag
252
  const tray = document.getElementById('clothing-tray');
253
  tray.addEventListener('pointerdown', onTrayPointerDown, { passive: false });
254
 
255
- // Placed items: pointerdown for drag or double-tap
256
  const placedLayer = document.getElementById('placed-layer');
257
  placedLayer.addEventListener('pointerdown', onPlacedPointerDown, { passive: false });
258
 
259
- // Global move/up (on window so we don't lose events)
260
  window.addEventListener('pointermove', onPointerMove, { passive: false });
261
  window.addEventListener('pointerup', onPointerUp);
262
- window.addEventListener('pointercancel', onPointerCancel);
263
  }
264
 
 
 
265
  function onTrayPointerDown(e) {
266
  const item = e.target.closest('.tray-item');
267
  if (!item) return;
268
-
269
  e.preventDefault();
270
- const id = item.dataset.id;
271
- const url = clothesURLMap[id];
272
 
273
  drag = {
274
  source: 'tray',
275
- id,
276
- url,
277
- anchor: item.dataset.anchor,
278
  scale: parseFloat(item.dataset.scale),
279
  zIndex: parseInt(item.dataset.z),
280
  startX: e.clientX,
@@ -283,34 +223,60 @@ function onTrayPointerDown(e) {
283
  };
284
  }
285
 
 
 
286
  function onPlacedPointerDown(e) {
287
  const img = e.target.closest('.placed-item');
288
  if (!img) return;
289
-
290
  e.preventDefault();
 
291
  const origIdx = parseInt(img.dataset.origIdx);
292
- const item = placedItems[origIdx];
293
- if (!item) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
 
295
- // Check for double-tap
296
  const now = Date.now();
297
  if (lastPlacedTap.idx === origIdx && (now - lastPlacedTap.time) < DOUBLE_TAP_MS) {
298
- // Double tap - remove with poof
299
  img.classList.add('poof');
300
  setTimeout(() => {
301
  placedItems.splice(origIdx, 1);
302
  renderPlaced();
303
  }, 300);
304
  lastPlacedTap = { idx: -1, time: 0 };
 
305
  return;
306
  }
307
  lastPlacedTap = { idx: origIdx, time: now };
308
 
 
 
 
 
309
  drag = {
310
  source: 'canvas',
311
  id: item.clothingId,
312
  url: item.url,
313
- anchor: item.anchorUsed,
314
  scale: item.scale,
315
  zIndex: item.zIndex,
316
  startX: e.clientX,
@@ -321,6 +287,38 @@ function onPlacedPointerDown(e) {
321
  }
322
 
323
  function onPointerMove(e) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  if (!drag) return;
325
  e.preventDefault();
326
 
@@ -331,96 +329,64 @@ function onPointerMove(e) {
331
  if (Math.abs(dx) < DRAG_DEAD_ZONE_PX && Math.abs(dy) < DRAG_DEAD_ZONE_PX) return;
332
  drag.hasMoved = true;
333
 
334
- // Create ghost
335
- createGhost(drag.url);
336
- ghost.classList.add('lifting');
337
- snapZones = computeSnapZones();
338
-
339
- // If dragging from canvas, hide the original
340
- if (drag.source === 'canvas') {
341
  placedItems.splice(drag.canvasIdx, 1);
342
  renderPlaced();
 
 
343
  }
344
  }
345
 
346
- // Compute position (with magnetic snap)
347
- let gx = e.clientX, gy = e.clientY;
348
- const snap = findNearestZone(e.clientX, e.clientY);
349
-
350
- if (snap) {
351
- const zone = snapZones[snap.idx];
352
- const pos = magneticPosition(e.clientX, e.clientY, zone);
353
- gx = pos.x;
354
- gy = pos.y;
355
- activeSnap = snap.idx;
356
- updateSnapRingActive(zone.name);
357
- ghost.classList.toggle('snapping', snap.locked);
358
- } else {
359
- activeSnap = null;
360
- clearSnapRingActive();
361
- ghost.classList.remove('snapping');
362
- }
363
-
364
- moveGhost(gx, gy);
365
  }
366
 
367
  function onPointerUp(e) {
 
 
 
 
 
 
 
 
 
 
 
368
  if (!drag) return;
369
 
370
  if (!drag.hasMoved) {
371
- // It was a tap, not a drag
372
  if (drag.source === 'tray') {
373
- // Tap on tray -> auto-snap
374
- const p = princesses[currentIdx];
375
- const anchors = p?.anchors || DEFAULT_ANCHORS;
376
- const anchorPos = anchors[drag.anchor];
377
- if (anchorPos) {
378
- placedItems.push({
379
- clothingId: drag.id,
380
- anchorUsed: drag.anchor,
381
- x: anchorPos.x,
382
- y: anchorPos.y,
383
- scale: drag.scale,
384
- zIndex: drag.zIndex,
385
- url: drag.url,
386
- });
387
- renderPlaced();
388
- sparkleAt(anchorPos.x, anchorPos.y);
389
- }
390
  }
391
- // Tap on canvas item is handled by double-tap in pointerdown
392
  drag = null;
393
  return;
394
  }
395
 
396
- // Drag ended - place the item
397
  const canvas = document.getElementById('princess-canvas');
398
  const rect = canvas.getBoundingClientRect();
399
 
400
- if (activeSnap !== null) {
401
- // Snap to anchor
402
- const zone = snapZones[activeSnap];
403
- placedItems.push({
404
- clothingId: drag.id,
405
- anchorUsed: zone.name,
406
- x: zone.nx,
407
- y: zone.ny,
408
- scale: drag.scale,
409
- zIndex: drag.zIndex,
410
- url: drag.url,
411
- });
412
- renderPlaced();
413
- sparkleAt(zone.nx, zone.ny);
414
- } else if (
415
  e.clientX >= rect.left && e.clientX <= rect.right &&
416
  e.clientY >= rect.top && e.clientY <= rect.bottom
417
  ) {
418
- // Free-place on canvas
419
  const nx = (e.clientX - rect.left) / rect.width;
420
  const ny = (e.clientY - rect.top) / rect.height;
421
  placedItems.push({
422
  clothingId: drag.id,
423
- anchorUsed: null,
424
  x: nx,
425
  y: ny,
426
  scale: drag.scale,
@@ -429,41 +395,7 @@ function onPointerUp(e) {
429
  });
430
  renderPlaced();
431
  }
432
- // else: dropped outside canvas, item is lost (bounced back)
433
-
434
- removeGhost();
435
- drag = null;
436
- }
437
 
438
- function onPointerCancel() {
439
- // If dragging from canvas and we cancel, put the item back
440
- if (drag && drag.hasMoved && drag.source === 'canvas') {
441
- // Item was already removed from placedItems - it's gone. That's okay for now.
442
- }
443
  removeGhost();
444
  drag = null;
445
  }
446
-
447
- // ---- Sparkle effect ----
448
-
449
- function sparkleAt(nx, ny) {
450
- const canvas = document.getElementById('princess-canvas');
451
- if (!canvas) return;
452
-
453
- const sparkle = document.createElement('div');
454
- sparkle.className = 'sparkle-burst';
455
- sparkle.style.left = `${nx * 100}%`;
456
- sparkle.style.top = `${ny * 100}%`;
457
-
458
- // 8 stars radiating outward
459
- for (let i = 0; i < 8; i++) {
460
- const star = document.createElement('span');
461
- star.className = 'sparkle-star';
462
- star.textContent = i % 2 === 0 ? '\u2728' : '\u2B50';
463
- star.style.setProperty('--angle', `${i * 45}deg`);
464
- sparkle.appendChild(star);
465
- }
466
-
467
- canvas.appendChild(sparkle);
468
- setTimeout(() => sparkle.remove(), 700);
469
- }
 
1
  import { loadPrincesses, loadClothes, blobToURL } from './store.js';
2
+ import { getCategoryInfo, DRAG_DEAD_ZONE_PX, DOUBLE_TAP_MS } from './models.js';
 
 
 
3
 
4
  let princesses = [];
5
  let clothes = [];
6
  let currentIdx = 0;
7
+ let placedItems = []; // { clothingId, x, y, scale, zIndex, url }
8
  let objectURLs = [];
9
  let clothesURLMap = {}; // id -> objectURL
10
 
11
+ // Single-finger drag state
12
+ let drag = null;
13
+ let ghost = null;
 
 
14
 
15
+ // Pinch-to-resize state
16
+ let pinch = null; // { idx, startDist, startScale }
17
+
18
+ // Double-tap tracking
19
  let lastPlacedTap = { idx: -1, time: 0 };
20
 
21
+ // Track active pointers on placed items
22
+ let activePointers = new Map(); // pointerId -> { x, y, origIdx }
23
+
24
  export async function initDressUp() {
25
  const screen = document.getElementById('dressup-screen');
26
 
 
31
  objectURLs = [];
32
  clothesURLMap = {};
33
 
 
34
  clothes.forEach(c => {
35
  const url = blobToURL(c.imageBlob);
36
  objectURLs.push(url);
 
57
  <div id="princess-canvas" class="princess-canvas">
58
  <img id="princess-img" class="princess-img" src="" alt=""/>
59
  <div id="placed-layer" class="placed-layer"></div>
 
60
  </div>
61
  </div>
62
 
 
76
  </div>
77
  `;
78
 
79
+ currentIdx = 0;
80
  renderPrincess();
81
  renderTray();
82
+ bindEvents();
83
  }
84
 
85
  // ---- Rendering ----
 
100
 
101
  placedItems = [];
102
  renderPlaced();
 
103
  }
104
 
105
  function renderTray() {
 
111
  const catInfo = getCategoryInfo(c.category);
112
  return `
113
  <div class="tray-item" data-id="${c.id}"
114
+ data-scale="${c.scale}" data-z="${c.zIndex}">
115
  <img src="${url}" class="tray-thumb" alt="${c.name}" draggable="false"/>
116
  <span class="tray-label">${catInfo ? catInfo.emoji : ''} ${c.name}</span>
117
  </div>
 
140
  `).join('');
141
  }
142
 
143
+ // Update a single placed item's style without re-rendering the whole layer.
144
+ // Avoids flicker during pinch.
145
+ function updatePlacedStyle(origIdx) {
146
+ const el = document.querySelector(`.placed-item[data-orig-idx="${origIdx}"]`);
147
+ if (!el) return;
148
+ const item = placedItems[origIdx];
149
+ if (!item) return;
150
+ el.style.left = `${item.x * 100}%`;
151
+ el.style.top = `${item.y * 100}%`;
152
+ el.style.width = `${item.scale * 100}%`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  }
154
 
155
+ // ---- Ghost (for tray drag) ----
 
 
156
 
157
  function createGhost(url) {
158
  if (ghost) ghost.remove();
 
161
  ghost.className = 'drag-ghost';
162
  ghost.draggable = false;
163
  document.body.appendChild(ghost);
 
164
  }
165
 
166
  function moveGhost(x, y) {
 
170
 
171
  function removeGhost() {
172
  if (ghost) { ghost.remove(); ghost = null; }
 
 
173
  }
174
 
175
+ // ---- Helpers ----
176
 
177
+ function pointerDist(a, b) {
178
+ const dx = a.x - b.x, dy = a.y - b.y;
179
+ return Math.sqrt(dx * dx + dy * dy);
180
+ }
181
+
182
+ function pointerMid(a, b) {
183
+ return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
184
+ }
185
+
186
+ // ---- Events ----
187
+
188
+ function bindEvents() {
189
  document.getElementById('prev-btn').addEventListener('click', () => {
190
  if (currentIdx > 0) { currentIdx--; renderPrincess(); }
191
  });
 
193
  if (currentIdx < princesses.length - 1) { currentIdx++; renderPrincess(); }
194
  });
195
 
 
196
  const tray = document.getElementById('clothing-tray');
197
  tray.addEventListener('pointerdown', onTrayPointerDown, { passive: false });
198
 
 
199
  const placedLayer = document.getElementById('placed-layer');
200
  placedLayer.addEventListener('pointerdown', onPlacedPointerDown, { passive: false });
201
 
 
202
  window.addEventListener('pointermove', onPointerMove, { passive: false });
203
  window.addEventListener('pointerup', onPointerUp);
204
+ window.addEventListener('pointercancel', onPointerUp);
205
  }
206
 
207
+ // ---- Tray drag ----
208
+
209
  function onTrayPointerDown(e) {
210
  const item = e.target.closest('.tray-item');
211
  if (!item) return;
 
212
  e.preventDefault();
 
 
213
 
214
  drag = {
215
  source: 'tray',
216
+ id: item.dataset.id,
217
+ url: clothesURLMap[item.dataset.id],
 
218
  scale: parseFloat(item.dataset.scale),
219
  zIndex: parseInt(item.dataset.z),
220
  startX: e.clientX,
 
223
  };
224
  }
225
 
226
+ // ---- Placed item: drag, pinch, double-tap ----
227
+
228
  function onPlacedPointerDown(e) {
229
  const img = e.target.closest('.placed-item');
230
  if (!img) return;
 
231
  e.preventDefault();
232
+
233
  const origIdx = parseInt(img.dataset.origIdx);
234
+ const ptr = { x: e.clientX, y: e.clientY, origIdx };
235
+ activePointers.set(e.pointerId, ptr);
236
+
237
+ // Check if this is a second finger on the same item -> start pinch
238
+ const sameItem = [...activePointers.values()].filter(p => p.origIdx === origIdx);
239
+ if (sameItem.length === 2 && !pinch) {
240
+ // Cancel any single-finger drag
241
+ if (drag && drag.source === 'canvas') {
242
+ drag = null;
243
+ removeGhost();
244
+ }
245
+ const d = pointerDist(sameItem[0], sameItem[1]);
246
+ pinch = {
247
+ idx: origIdx,
248
+ startDist: d,
249
+ startScale: placedItems[origIdx].scale,
250
+ startMidX: (sameItem[0].x + sameItem[1].x) / 2,
251
+ startMidY: (sameItem[0].y + sameItem[1].y) / 2,
252
+ startItemX: placedItems[origIdx].x,
253
+ startItemY: placedItems[origIdx].y,
254
+ };
255
+ return;
256
+ }
257
 
258
+ // Single finger: check double-tap first
259
  const now = Date.now();
260
  if (lastPlacedTap.idx === origIdx && (now - lastPlacedTap.time) < DOUBLE_TAP_MS) {
 
261
  img.classList.add('poof');
262
  setTimeout(() => {
263
  placedItems.splice(origIdx, 1);
264
  renderPlaced();
265
  }, 300);
266
  lastPlacedTap = { idx: -1, time: 0 };
267
+ activePointers.delete(e.pointerId);
268
  return;
269
  }
270
  lastPlacedTap = { idx: origIdx, time: now };
271
 
272
+ // Start single-finger drag
273
+ const item = placedItems[origIdx];
274
+ if (!item) return;
275
+
276
  drag = {
277
  source: 'canvas',
278
  id: item.clothingId,
279
  url: item.url,
 
280
  scale: item.scale,
281
  zIndex: item.zIndex,
282
  startX: e.clientX,
 
287
  }
288
 
289
  function onPointerMove(e) {
290
+ // Update active pointer position
291
+ if (activePointers.has(e.pointerId)) {
292
+ const ptr = activePointers.get(e.pointerId);
293
+ ptr.x = e.clientX;
294
+ ptr.y = e.clientY;
295
+ }
296
+
297
+ // Pinch-to-resize
298
+ if (pinch) {
299
+ e.preventDefault();
300
+ const sameItem = [...activePointers.values()].filter(p => p.origIdx === pinch.idx);
301
+ if (sameItem.length >= 2) {
302
+ const d = pointerDist(sameItem[0], sameItem[1]);
303
+ const ratio = d / pinch.startDist;
304
+ const newScale = Math.max(0.1, Math.min(2.0, pinch.startScale * ratio));
305
+ placedItems[pinch.idx].scale = newScale;
306
+
307
+ // Also move item with the midpoint
308
+ const canvas = document.getElementById('princess-canvas');
309
+ const rect = canvas.getBoundingClientRect();
310
+ const mid = pointerMid(sameItem[0], sameItem[1]);
311
+ const dx = mid.x - pinch.startMidX;
312
+ const dy = mid.y - pinch.startMidY;
313
+ placedItems[pinch.idx].x = pinch.startItemX + dx / rect.width;
314
+ placedItems[pinch.idx].y = pinch.startItemY + dy / rect.height;
315
+
316
+ updatePlacedStyle(pinch.idx);
317
+ }
318
+ return;
319
+ }
320
+
321
+ // Single-finger drag
322
  if (!drag) return;
323
  e.preventDefault();
324
 
 
329
  if (Math.abs(dx) < DRAG_DEAD_ZONE_PX && Math.abs(dy) < DRAG_DEAD_ZONE_PX) return;
330
  drag.hasMoved = true;
331
 
332
+ if (drag.source === 'tray') {
333
+ createGhost(drag.url);
334
+ ghost.classList.add('lifting');
335
+ } else if (drag.source === 'canvas') {
336
+ // Remove from placed and drag as ghost
 
 
337
  placedItems.splice(drag.canvasIdx, 1);
338
  renderPlaced();
339
+ createGhost(drag.url);
340
+ ghost.classList.add('lifting');
341
  }
342
  }
343
 
344
+ moveGhost(e.clientX, e.clientY);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  }
346
 
347
  function onPointerUp(e) {
348
+ activePointers.delete(e.pointerId);
349
+
350
+ // End pinch if we lost a finger
351
+ if (pinch) {
352
+ const sameItem = [...activePointers.values()].filter(p => p.origIdx === pinch.idx);
353
+ if (sameItem.length < 2) {
354
+ pinch = null;
355
+ }
356
+ return;
357
+ }
358
+
359
  if (!drag) return;
360
 
361
  if (!drag.hasMoved) {
362
+ // Tap on tray -> place at center
363
  if (drag.source === 'tray') {
364
+ placedItems.push({
365
+ clothingId: drag.id,
366
+ x: 0.5,
367
+ y: 0.5,
368
+ scale: drag.scale,
369
+ zIndex: drag.zIndex,
370
+ url: drag.url,
371
+ });
372
+ renderPlaced();
 
 
 
 
 
 
 
 
373
  }
 
374
  drag = null;
375
  return;
376
  }
377
 
378
+ // Drop
379
  const canvas = document.getElementById('princess-canvas');
380
  const rect = canvas.getBoundingClientRect();
381
 
382
+ if (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  e.clientX >= rect.left && e.clientX <= rect.right &&
384
  e.clientY >= rect.top && e.clientY <= rect.bottom
385
  ) {
 
386
  const nx = (e.clientX - rect.left) / rect.width;
387
  const ny = (e.clientY - rect.top) / rect.height;
388
  placedItems.push({
389
  clothingId: drag.id,
 
390
  x: nx,
391
  y: ny,
392
  scale: drag.scale,
 
395
  });
396
  renderPlaced();
397
  }
 
 
 
 
 
398
 
 
 
 
 
 
399
  removeGhost();
400
  drag = null;
401
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
style.css CHANGED
@@ -442,10 +442,13 @@ h2 { font-size: 1.5rem; font-weight: 700; color: var(--lavender-dk); }
442
 
443
  /* ---- Placed items ---- */
444
 
 
 
 
 
445
  .placed-item {
446
  pointer-events: auto;
447
  cursor: grab;
448
- transition: transform 0.2s var(--spring);
449
  }
450
 
451
  .placed-item.poof {
 
442
 
443
  /* ---- Placed items ---- */
444
 
445
+ .placed-layer {
446
+ touch-action: none;
447
+ }
448
+
449
  .placed-item {
450
  pointer-events: auto;
451
  cursor: grab;
 
452
  }
453
 
454
  .placed-item.poof {