diff --git a/src/index.html b/src/index.html index 6955cf8..ad47620 100644 --- a/src/index.html +++ b/src/index.html @@ -1720,6 +1720,29 @@ .lh-s { left: 50%; top: 100%; cursor: ns-resize; } .lh-sw { left: 0; top: 100%; cursor: nesw-resize; } .lh-w { left: 0; top: 50%; cursor: ew-resize; } + /* Free Transform rotation handle: sits above the bbox top edge, + connected by a thin line, with a circular pill so it's visually + distinct from the square scale handles. */ + .lh-rot { + left: 50%; top: 0; + margin-top: -36px; + margin-left: -8px; + width: 15px; height: 15px; + border-radius: 50%; + background: var(--accent); + border-color: #fff; + cursor: grab; + } + .lh-rot:active { cursor: grabbing; } + .lh-rot::after { + content: ''; + position: absolute; + left: 50%; top: 100%; + width: 1px; height: 23px; + background: var(--accent); + transform: translateX(-50%); + pointer-events: none; + } .freeLayer img { width: 100%; @@ -16684,11 +16707,11 @@ // Drag-resize handles — only on the selected, unlocked layer. if (layer.id === state.selectedLayerId && selectedLayerIds().length <= 1 && !layer.locked) { - for (const dir of ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']) { + for (const dir of ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w', 'rot']) { const handle = document.createElement('div'); handle.className = `layerHandle lh-${dir}`; handle.dataset.handle = dir; - handle.addEventListener('pointerdown', startLayerResize); + handle.addEventListener('pointerdown', dir === 'rot' ? startLayerRotate : startLayerResize); handle.addEventListener('click', (e) => e.stopPropagation()); el.appendChild(handle); } @@ -16723,11 +16746,11 @@ box.style.height = inches(bounds.h); } box.addEventListener('pointerdown', startLayerSelectionDrag); - for (const dir of ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']) { + for (const dir of ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w', 'rot']) { const handle = document.createElement('div'); handle.className = `layerHandle lh-${dir}`; handle.dataset.handle = dir; - handle.addEventListener('pointerdown', startLayerSelectionResize); + handle.addEventListener('pointerdown', dir === 'rot' ? startLayerSelectionRotate : startLayerSelectionResize); handle.addEventListener('click', (e) => e.stopPropagation()); box.appendChild(handle); } @@ -19861,6 +19884,123 @@ // Drag-resize a layer from one of its 8 handles. Resizes in the layer's // own (rotated) frame and keeps the opposite edge/corner anchored. // Text layers reflow inside the new box — fontSize is never touched. + // Free Transform rotation — drag the round pill above the bbox to + // spin the layer about its own centre. Shift snaps to 15°. The + // existing resize path already accounts for layer.rotate in its + // local-frame math, so other handles keep working at any angle. + function startLayerRotate(event) { + event.preventDefault(); + event.stopPropagation(); + blurEditableFocus(); + const handle = event.currentTarget; + const el = handle.parentElement; + const layer = getLayer(el?.dataset.layerId); + if (!layer || layer.locked) return; + setLayerSelection([layer.id], layer.id); + + const isDesignerLayer = layer.target === 'designer-disc'; + const dragHost = isDesignerLayer ? $('designerDiscWrap') : $('sheet'); + const hostRect = dragHost.getBoundingClientRect(); + const unitW = isDesignerLayer ? discTemplate.discD : sheetW(); + const unitH = isDesignerLayer ? discTemplate.discD : sheetH(); + const cxIn = (Number(layer.x) || 0) + (Number(layer.w) || 0) / 2; + const cyIn = (Number(layer.y) || 0) + (Number(layer.h) || 0) / 2; + const cxPx = hostRect.left + (cxIn / unitW) * hostRect.width; + const cyPx = hostRect.top + (cyIn / unitH) * hostRect.height; + + const rot0 = Number(layer.rotate) || 0; + const ang0 = Math.atan2(event.clientY - cyPx, event.clientX - cxPx) * 180 / Math.PI; + + let snapshotted = false; + document.body.classList.add('dragging-layer'); + try { handle.setPointerCapture(event.pointerId); } catch {} + const move = (e) => { + if (!snapshotted) { pushHistory('rotate layer'); snapshotted = true; } + const ang = Math.atan2(e.clientY - cyPx, e.clientX - cxPx) * 180 / Math.PI; + let next = rot0 + (ang - ang0); + if (e.shiftKey) next = Math.round(next / 15) * 15; + // Normalize to (-180, 180] for cleaner numeric display. + next = ((next + 540) % 360) - 180; + layer.rotate = next; + render(); + }; + const up = () => { + document.body.classList.remove('dragging-layer'); + handle.removeEventListener('pointermove', move); + handle.removeEventListener('pointerup', up); + handle.removeEventListener('pointercancel', up); + }; + handle.addEventListener('pointermove', move); + handle.addEventListener('pointerup', up); + handle.addEventListener('pointercancel', up); + } + + // Multi-layer rotation: each selected layer orbits the selection's + // bbox centre, and its own rotation increments by the same delta, + // so the group spins as a rigid body. + function startLayerSelectionRotate(event) { + event.preventDefault(); + event.stopPropagation(); + blurEditableFocus(); + const handle = event.currentTarget; + const box = handle.parentElement; + const isDesignerLayer = !!box.closest('#designerLayerOverlay'); + const layers = selectionTransformLayers(isDesignerLayer); + if (layers.length < 1) return; + const bounds = layersBounds(layers); + if (!bounds) return; + + const dragHost = isDesignerLayer ? $('designerDiscWrap') : $('sheet'); + const hostRect = dragHost.getBoundingClientRect(); + const unitW = isDesignerLayer ? discTemplate.discD : sheetW(); + const unitH = isDesignerLayer ? discTemplate.discD : sheetH(); + const cxIn = bounds.x + bounds.w / 2; + const cyIn = bounds.y + bounds.h / 2; + const cxPx = hostRect.left + (cxIn / unitW) * hostRect.width; + const cyPx = hostRect.top + (cyIn / unitH) * hostRect.height; + + const ang0 = Math.atan2(event.clientY - cyPx, event.clientX - cxPx) * 180 / Math.PI; + // Snapshot each layer's centre + rotation so the delta is + // applied from a stable starting state rather than accumulating. + const snaps = layers.map(l => ({ + layer: l, + cx: (Number(l.x) || 0) + (Number(l.w) || 0) / 2, + cy: (Number(l.y) || 0) + (Number(l.h) || 0) / 2, + rot: Number(l.rotate) || 0, + })); + + let snapshotted = false; + document.body.classList.add('dragging-layer'); + try { handle.setPointerCapture(event.pointerId); } catch {} + const move = (e) => { + if (!snapshotted) { pushHistory('rotate selection'); snapshotted = true; } + const ang = Math.atan2(e.clientY - cyPx, e.clientX - cxPx) * 180 / Math.PI; + let delta = ang - ang0; + if (e.shiftKey) delta = Math.round(delta / 15) * 15; + const rad = delta * Math.PI / 180; + const cos = Math.cos(rad), sin = Math.sin(rad); + for (const s of snaps) { + const dx = s.cx - cxIn, dy = s.cy - cyIn; + const nx = dx * cos - dy * sin + cxIn; + const ny = dx * sin + dy * cos + cyIn; + const w = Number(s.layer.w) || 0, h = Number(s.layer.h) || 0; + s.layer.x = nx - w / 2; + s.layer.y = ny - h / 2; + s.layer.rotate = ((s.rot + delta + 540) % 360) - 180; + } + render(); + }; + const up = () => { + document.body.classList.remove('dragging-layer'); + handle.removeEventListener('pointermove', move); + handle.removeEventListener('pointerup', up); + handle.removeEventListener('pointercancel', up); + }; + handle.addEventListener('pointermove', move); + handle.addEventListener('pointerup', up); + handle.addEventListener('pointercancel', up); + } + function startLayerResize(event) { event.preventDefault(); event.stopPropagation();