Squash Odysseus development history

This commit is contained in:
pewdiepie-archdaemon
2026-09-11 06:04:19 +00:00
parent e5c99a5eee
commit 6ee6502010
2050 changed files with 538359 additions and 57745 deletions
+129 -138
View File
@@ -8,8 +8,8 @@
*
* - `syncOverlay(margin)` positions the overlay canvas + sizes its
* bitmap based on the main canvas + zoom.
* - `drawHandles(margin)` draws the rotated bounding outline + 4
* corner handles + the rotation knob (with
* - `drawHandles(margin)` draws the rotated bounding outline + 8
* resize handles + the rotation knob (with
* hover / active visual states).
* - `getHandleAt(x, y)` returns the handle id under (x, y), or
* null. Geometry MUST mirror `drawHandles`
@@ -20,13 +20,16 @@
* pointer events.
*/
import { state } from '../state.js';
import {
hitTestTransformHandle,
pointInTransformFrame,
transformFrameGeometry,
} from '../transform-frame-geometry.js';
/**
* Position the transform overlay canvas + size its backing bitmap.
* Margin is the image-space slack each side so handles can render
* outside the main canvas (matches _TRANSFORM_OVERLAY_MARGIN in
* galleryEditor.js — kept as a parameter so this module has no
* dependency on a magic number defined elsewhere).
* Margin is screen-space slack on each side so controls remain visible
* at every zoom. It is converted to image pixels for the backing canvas.
*/
export function syncOverlay(margin) {
if (!state.transformOverlay || !state.mainCanvas) return;
@@ -34,8 +37,9 @@ export function syncOverlay(margin) {
state.transformOverlay.style.display = 'none';
return;
}
const W = state.mainCanvas.width + 2 * margin;
const H = state.mainCanvas.height + 2 * margin;
const imageMargin = margin / Math.max(0.0001, state.zoom);
const W = Math.ceil(state.mainCanvas.width + 2 * imageMargin);
const H = Math.ceil(state.mainCanvas.height + 2 * imageMargin);
if (state.transformOverlay.width !== W) state.transformOverlay.width = W;
if (state.transformOverlay.height !== H) state.transformOverlay.height = H;
// Overlay must scale with state.zoom so its handles render at the
@@ -55,103 +59,100 @@ export function syncOverlay(margin) {
// both canvas + overlay), so pan moves them together. Reading the
// layout offset (not getBoundingClientRect, which includes the pan
// transform) is what avoids the double-pan "bounce".
state.transformOverlay.style.left = Math.round(state.mainCanvas.offsetLeft - margin * state.zoom) + 'px';
state.transformOverlay.style.top = Math.round(state.mainCanvas.offsetTop - margin * state.zoom) + 'px';
state.transformOverlay.style.left = Math.round(state.mainCanvas.offsetLeft - margin) + 'px';
state.transformOverlay.style.top = Math.round(state.mainCanvas.offsetTop - margin) + 'px';
state.transformOverlay.style.transform = state.mainCanvas.style.transform || 'none';
}
/**
* Compute the on-screen position of the rotation knob given the
* layer's bbox center + rotation. The knob normally sits OUTSIDE the
* top edge of the rotated layer; if that would land beyond the canvas
* viewport, flip it INSIDE.
*
* Returned by `_knobPosition` and shared by drawHandles + getHandleAt
* so both compute the same point.
*/
function knobPosition(cxh, cyh, rotRad, baseInnerR, rotOffset) {
let rotInside = false;
const outsideR = baseInnerR + rotOffset;
const knobLocalX = cxh + Math.sin(rotRad) * outsideR;
const knobLocalY = cyh - Math.cos(rotRad) * outsideR;
// Primary check: anything drawn outside the main canvas's pixel
// buffer is invisible (canvas operations clip silently).
if (
knobLocalX < 0 || knobLocalY < 0 ||
knobLocalX > state.mainCanvas.width || knobLocalY > state.mainCanvas.height
) {
rotInside = true;
}
// Secondary check: even if the knob is inside the canvas bitmap, the
// viewport may have scrolled the canvas such that the knob falls
// outside the visible canvas-area window.
function rotationHandleInside(frame) {
let inside = false;
const outside = transformFrameGeometry(frame, { zoom: state.zoom }).rotationHandle;
// The overlay deliberately extends beyond the image, so only fold the
// rotation control inward when the surrounding viewport would clip it.
try {
const area = state.container && state.container.querySelector('.ge-canvas-area');
if (area && !rotInside) {
if (area && !inside) {
const aRect = area.getBoundingClientRect();
const mRect = state.mainCanvas.getBoundingClientRect();
const scaleX = mRect.width / state.mainCanvas.width;
const scaleY = mRect.height / state.mainCanvas.height;
const knobClientX = mRect.left + knobLocalX * scaleX;
const knobClientY = mRect.top + knobLocalY * scaleY;
if (knobClientY < aRect.top + 6) rotInside = true;
if (knobClientX < aRect.left + 6 || knobClientX > aRect.right - 6) rotInside = true;
const knobClientX = mRect.left + outside.x * scaleX;
const knobClientY = mRect.top + outside.y * scaleY;
if (knobClientY < aRect.top + 6) inside = true;
if (knobClientX < aRect.left + 6 || knobClientX > aRect.right - 6) inside = true;
const popupRect = state.transformPopup?.getBoundingClientRect?.();
if (popupRect
&& knobClientX >= popupRect.left
&& knobClientX <= popupRect.right
&& knobClientY >= popupRect.top
&& knobClientY <= popupRect.bottom) {
inside = true;
}
}
} catch {}
const innerR = rotInside ? Math.max(4, baseInnerR - rotOffset) : baseInnerR;
const rotR = rotInside ? innerR : baseInnerR + rotOffset;
return inside;
}
function transformFrame() {
if (state.transformTarget === 'selection') {
if (!state.transformSelectionBounds) return null;
return {
centerX: state.transformCenter?.x ?? state.transformSelectionBounds.centerX,
centerY: state.transformCenter?.y ?? state.transformSelectionBounds.centerY,
width: state.transformPendingW || state.transformSelectionBounds.width,
height: state.transformPendingH || state.transformSelectionBounds.height,
rotation: state.transformPendingRot || 0,
};
}
const layer = state.transformLayer;
if (!layer) return null;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const fallbackCenter = {
x: off.x + layer.canvas.width / 2,
y: off.y + layer.canvas.height / 2,
};
return {
rotInside,
innerR,
rotX: cxh + Math.sin(rotRad) * rotR,
rotY: cyh - Math.cos(rotRad) * rotR,
centerX: state.transformCenter?.x ?? fallbackCenter.x,
centerY: state.transformCenter?.y ?? fallbackCenter.y,
width: state.transformPendingW || layer.canvas.width,
height: state.transformPendingH || layer.canvas.height,
rotation: state.transformPendingRot || 0,
};
}
/**
* Draw the rotated bounding outline + 4 corner handles + the rotation
* Draw the rotated bounding outline + 8 resize handles + the rotation
* knob into the overlay canvas. The overlay is translated by `margin`
* so image (0,0) maps to overlay (margin, margin).
*/
export function drawHandles(margin) {
if (!state.transformActive || !state.transformLayer) return;
if (!state.transformActive || (state.transformTarget !== 'selection' && !state.transformLayer)) return;
syncOverlay(margin);
if (!state.transformOverlayCtx) return;
const layer = state.transformLayer;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const w = layer.canvas.width;
const h = layer.canvas.height;
const frame = transformFrame();
if (!frame) return;
const rotInside = rotationHandleInside(frame);
const geometry = transformFrameGeometry(frame, {
zoom: state.zoom,
rotationInside: rotInside,
});
const ctx = state.transformOverlayCtx;
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#e06c75';
const imageMargin = margin / Math.max(0.0001, state.zoom);
// Clear + shift drawing by margin so image (0,0) maps to overlay (M,M).
ctx.clearRect(0, 0, state.transformOverlay.width, state.transformOverlay.height);
ctx.save();
ctx.translate(margin, margin);
ctx.translate(imageMargin, imageMargin);
// Zoom-corrected handle size + stroke so they stay readable at any zoom.
const sz = 10 / state.zoom;
const stroke = 1.5 / state.zoom;
// Pre-rotation rectangle dims (what the user sees the layer as).
// Falls back to layer bbox before any popup values exist.
const preW = state.transformPendingW || w;
const preH = state.transformPendingH || h;
const cxBox = off.x + w / 2;
const cyBox = off.y + h / 2;
const rotRadBox = ((state.transformPendingRot || 0) * Math.PI) / 180;
const cosBox = Math.cos(rotRadBox);
const sinBox = Math.sin(rotRadBox);
const rotPt = (dx, dy) => ({
x: cxBox + dx * cosBox - dy * sinBox,
y: cyBox + dx * sinBox + dy * cosBox,
});
const tl = rotPt(-preW / 2, -preH / 2);
const tr = rotPt( preW / 2, -preH / 2);
const br = rotPt( preW / 2, preH / 2);
const bl = rotPt(-preW / 2, preH / 2);
const { tl, tr, br, bl } = geometry.corners;
// Outline of the rotated rectangle — solid white inner line with a
// thin black halo for contrast on light AND dark backgrounds.
// Use the editor accent instead of white so the frame remains distinct
// from both the canvas and the ruler/UI chrome.
const drawRectOutline = () => {
ctx.beginPath();
ctx.moveTo(tl.x, tl.y);
@@ -161,54 +162,41 @@ export function drawHandles(margin) {
ctx.closePath();
ctx.stroke();
};
ctx.lineWidth = 1 / state.zoom;
ctx.strokeStyle = 'rgba(0, 0, 0, 0.45)';
ctx.lineWidth = 3 / state.zoom;
ctx.strokeStyle = 'rgba(0, 0, 0, 0.82)';
ctx.setLineDash([6 / state.zoom, 4 / state.zoom]);
ctx.lineDashOffset = 1 / state.zoom;
drawRectOutline();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5 / state.zoom;
ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#e06c75';
ctx.lineDashOffset = 0;
drawRectOutline();
ctx.setLineDash([]);
// Corner handles + rotation knob anchored to the rotated layer's
// Resize handles + rotation knob anchored to the rotated layer's
// top-center (not bbox top), so the knob stays attached to the
// visible content as it spins.
const rotOffset = 24 / state.zoom;
const cxh = off.x + w / 2;
const cyh = off.y + h / 2;
const rotRad = ((state.transformPendingRot || 0) * Math.PI) / 180;
const baseInnerR = (state.transformPendingH || h) / 2;
const knob = knobPosition(cxh, cyh, rotRad, baseInnerR, rotOffset);
// Tether line collapses to a point when knob is inside the layer.
const drawTether = !knob.rotInside;
const innerX = cxh + Math.sin(rotRad) * baseInnerR;
const innerY = cyh - Math.cos(rotRad) * baseInnerR;
const corners = [
{ x: tl.x, y: tl.y, id: 'tl' },
{ x: tr.x, y: tr.y, id: 'tr' },
{ x: br.x, y: br.y, id: 'br' },
{ x: bl.x, y: bl.y, id: 'bl' },
{ x: knob.rotX, y: knob.rotY, id: 'rot' },
];
if (drawTether) {
const topHandle = geometry.resizeHandles.find(handle => handle.id === 't');
if (!rotInside) {
ctx.beginPath();
ctx.moveTo(innerX, innerY);
ctx.lineTo(knob.rotX, knob.rotY);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
ctx.moveTo(topHandle.x, topHandle.y);
ctx.lineTo(geometry.rotationHandle.x, geometry.rotationHandle.y);
ctx.strokeStyle = accent;
ctx.lineWidth = 1 / state.zoom;
ctx.stroke();
}
for (const c of corners) {
for (const c of geometry.handles) {
const active = c.id === state.transformHandle;
const hovered = !active && c.id === state.hoveredHandle;
const radius = (active ? sz * 0.75 : hovered ? sz * 0.6 : sz / 2);
const edge = c.id.length === 1 && c.id !== 'rot';
const baseRadius = edge ? sz * 0.42 : sz / 2;
const radius = active ? sz * 0.75 : hovered ? sz * 0.6 : baseRadius;
ctx.beginPath();
ctx.arc(c.x, c.y, radius, 0, Math.PI * 2);
ctx.fillStyle = active ? '#e06c75' : hovered ? '#ffd' : '#fff';
ctx.fillStyle = active ? '#fff' : hovered ? '#ffd' : accent;
ctx.fill();
ctx.lineWidth = stroke;
ctx.strokeStyle = active ? '#fff' : 'rgba(0, 0, 0, 0.5)';
ctx.strokeStyle = active ? accent : 'rgba(0, 0, 0, 0.72)';
ctx.stroke();
if (hovered) {
// Subtle red ring around the hovered handle for visual feedback.
@@ -219,53 +207,56 @@ export function drawHandles(margin) {
ctx.stroke();
}
}
// The center marker communicates the rotation pivot without becoming an
// interactive handle until pivot dragging lands in the transform workflow.
const pivotRadius = 3 / state.zoom;
ctx.beginPath();
ctx.arc(geometry.pivot.x, geometry.pivot.y, pivotRadius, 0, Math.PI * 2);
ctx.strokeStyle = accent;
ctx.lineWidth = 1 / state.zoom;
ctx.stroke();
ctx.restore();
}
/**
* Hit-test (x, y) against the transform handles. Returns the handle
* id ('tl' | 'tr' | 'br' | 'bl' | 'rot') or null.
* id ('tl' | 't' | 'tr' | 'r' | 'br' | 'b' | 'bl' | 'l' | 'rot') or null.
*
* Geometry MUST mirror `drawHandles` exactly, otherwise the user
* grabs phantom points.
*/
export function getHandleAt(x, y) {
if (!state.transformLayer) return null;
const layer = state.transformLayer;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const w = layer.canvas.width;
const h = layer.canvas.height;
const threshold = 8 / state.zoom;
const rotOffset = 24 / state.zoom;
const cxh = off.x + w / 2;
const cyh = off.y + h / 2;
const rotRad = ((state.transformPendingRot || 0) * Math.PI) / 180;
const baseInnerR = (state.transformPendingH || h) / 2;
const knob = knobPosition(cxh, cyh, rotRad, baseInnerR, rotOffset);
export function getHandleAt(x, y, options = {}) {
if (state.transformTarget !== 'selection' && !state.transformLayer) return null;
const frame = transformFrame();
if (!frame) return null;
const rotationInside = rotationHandleInside(frame);
const geometry = transformFrameGeometry(frame, {
zoom: state.zoom,
rotationInside,
});
const hit = hitTestTransformHandle(geometry, x, y, {
zoom: state.zoom,
pointerType: options.pointerType,
});
if (hit || !rotationInside) return hit;
// Rotate corners around centre — must match drawHandles.
const preW = state.transformPendingW || w;
const preH = state.transformPendingH || h;
const cosA = Math.cos(rotRad);
const sinA = Math.sin(rotRad);
const rotCorner = (dx, dy) => ({
x: cxh + dx * cosA - dy * sinA,
y: cyh + dx * sinA + dy * cosA,
// Keep the clipped grip reachable during viewport-edge transitions. The
// frame is drawn with the inward position, but a touch can begin from the
// previously rendered outward position for one frame after a resize/pan.
const unclippedGeometry = transformFrameGeometry(frame, {
zoom: state.zoom,
rotationInside: false,
});
return hitTestTransformHandle(unclippedGeometry, x, y, {
zoom: state.zoom,
pointerType: options.pointerType,
});
const tlH = rotCorner(-preW / 2, -preH / 2);
const trH = rotCorner( preW / 2, -preH / 2);
const brH = rotCorner( preW / 2, preH / 2);
const blH = rotCorner(-preW / 2, preH / 2);
const handles = [
{ x: tlH.x, y: tlH.y, id: 'tl' },
{ x: trH.x, y: trH.y, id: 'tr' },
{ x: brH.x, y: brH.y, id: 'br' },
{ x: blH.x, y: blH.y, id: 'bl' },
{ x: knob.rotX, y: knob.rotY, id: 'rot' },
];
for (const c of handles) {
if (Math.abs(x - c.x) < threshold && Math.abs(y - c.y) < threshold) return c.id;
}
return null;
}
/** True only for the visible rotated frame, not its empty AABB corners. */
export function containsFramePoint(x, y) {
const frame = transformFrame();
if (!frame) return false;
return pointInTransformFrame(transformFrameGeometry(frame, { zoom: state.zoom }), x, y);
}