From 74a08c2a057784389ea9b3d67e3f6a8df3cf9ce2 Mon Sep 17 00:00:00 2001 From: drakeraz09 Date: Mon, 21 Sep 2026 13:08:19 +0000 Subject: [PATCH 1/3] Add a draggable pivot crosshair The bounding box already drew a crosshair in the middle of the selection, but it did nothing. Everything rotated and scaled around the center of the bounds no matter what, so swinging an arm around its elbow meant moving the art, rotating it, and moving it back. Every single time. Now you can drag it. It snaps to the corners, edge midpoints and center of the selection, and to the costume's rotation center. Drop it back on the center, or double click, and it goes away. The anchor sits on paper's own `item.pivot`, which already transforms with the item and gets serialized into the project JSON so undo works. It belongs to the selection rather than one item. A group is a single item and answers for itself, and a loose multi-selection only counts as having an anchor while every member agrees on it. Grouping moves the anchor onto the new group and clears it off the members so there is one answer instead of five. The old static glyph and the selectionAnchor bookkeeping ScaleTool did to keep it upright are gone, since the crosshair is drawn on the guide layer now and rebuilt on every update. Also fixes the hover outline landing in the wrong spot once an item has an anchor. It was placed with `item.position`, which IS the anchor as soon as you set one. Uses the matrix instead. Co-Authored-By: Claude Opus 5 --- src/helper/group.js | 9 + src/helper/guides.js | 10 +- src/helper/pivot.js | 197 ++++++++++++++++++ .../selection-tools/bounding-box-tool.js | 60 +++--- src/helper/selection-tools/pivot-tool.js | 158 ++++++++++++++ src/helper/selection-tools/scale-tool.js | 13 -- src/helper/selection-tools/select-tool.js | 4 +- test/unit/pivot-handle.test.js | 45 ++++ test/unit/pivot.test.js | 162 ++++++++++++++ 9 files changed, 616 insertions(+), 42 deletions(-) create mode 100644 src/helper/pivot.js create mode 100644 src/helper/selection-tools/pivot-tool.js create mode 100644 test/unit/pivot-handle.test.js create mode 100644 test/unit/pivot.test.js diff --git a/src/helper/group.js b/src/helper/group.js index c7a8e715f8..ca2094e136 100644 --- a/src/helper/group.js +++ b/src/helper/group.js @@ -1,6 +1,7 @@ import paper from '@turbowarp/paper'; import {getRootItem, isGroupItem} from './item'; import {clearSelection, getSelectedRootItems, setItemSelection} from './selection'; +import {clearSelectionPivot, getSelectionPivot, setPivot} from './pivot'; const isGroup = function (item) { return isGroupItem(item); @@ -16,7 +17,15 @@ const isGroup = function (item) { */ const groupItems = function (items, clearSelectedItems, setSelectedItems, onUpdateImage) { if (items.length > 0) { + // Grab the anchor BEFORE grouping. The selection is about to become one item and + // we're not about to silently throw away where they put it. ASSHOLE! + const pivot = getSelectionPivot(items); const group = new paper.Group(items); + if (pivot) { + // Members give theirs up so the group is the only one talking. One answer, not five. + clearSelectionPivot(items); + setPivot(group, pivot); + } clearSelection(clearSelectedItems); setItemSelection(group, true); for (let i = 0; i < group.children.length; i++) { diff --git a/src/helper/guides.js b/src/helper/guides.js index 0d3fba65d8..649356ac29 100644 --- a/src/helper/guides.js +++ b/src/helper/guides.js @@ -20,7 +20,9 @@ const hoverItem = function (item) { clone.closed = true; } clone.parent = getGuideLayer(); - clone.position = item.position; + // Use the matrix, NOT position. Once an item has an anchor, `position` IS the anchor, + // so the hover outline wanders off somewhere stupid. bruh.... + clone.matrix = item.globalMatrix; clone.strokeColor = GUIDE_BLUE; clone.fillColor = null; clone.data.isHelperItem = true; @@ -36,7 +38,7 @@ const hoverBounds = function (item, expandBy) { bounds = bounds.expand(expandBy); } const rect = new paper.Path.Rectangle(bounds); - rect.matrix = item.matrix; + rect.matrix = item.globalMatrix; setDefaultGuideStyle(rect); rect.parent = getGuideLayer(); rect.strokeColor = GUIDE_BLUE; @@ -91,11 +93,11 @@ const _removePaperItemsByTags = function (tags) { }; const removeBoundsPath = function () { - _removePaperItemsByDataTags(['isSelectionBound', 'isRotHandle', 'isScaleHandle']); + _removePaperItemsByDataTags(['isSelectionBound', 'isRotHandle', 'isScaleHandle', 'isPivotHandle']); }; const removeBoundsHandles = function () { - _removePaperItemsByDataTags(['isRotHandle', 'isScaleHandle']); + _removePaperItemsByDataTags(['isRotHandle', 'isScaleHandle', 'isPivotHandle']); }; const removeAllGuides = function () { diff --git a/src/helper/pivot.js b/src/helper/pivot.js new file mode 100644 index 0000000000..692833abf5 --- /dev/null +++ b/src/helper/pivot.js @@ -0,0 +1,197 @@ +/** + * PIVOT!!! Hell yes....... + * + * The anchor relies on paper's pivot property, which is already pretty great so we nab it + * + * The UI works on the *selection*, not on one just one item!!! A group's a single root anchor + * and a loose multi-selection only has an anchor if each item was given the same one. + * + * In the SVG export, pivot is encoded so that it persists when you upload it elsewhere o algo asi + */ +import paper from '@turbowarp/paper'; +import {CENTER} from './view'; + +/** How close in screen pixels the snap threshold is (corners, edges, etc) */ +const SNAP_THRESHOLD = 8; + +/** + * How close in screen pixels the selection's center has to be to the pointer for it to + * drop the anchor on the center. It's MUCH fatter since people will actually aim for this + */ +const CENTER_SNAP_THRESHOLD = 20; + +/** How far apart two items' anchors may be and still count as the same anchor... */ +const AGREEMENT_TOLERANCE = 1e-6; + +/** + * @param {paper.Item} item Any item. + * @return {boolean} True if the user has placed an anchor on this item. + */ +const hasPivot = function (item) { + return Boolean(item && item.pivot); +}; + +/** + * @param {paper.Item} item Any item. + * @return {?paper.Point} The anchor in project space, or null if the item has none. + */ +const getPivot = function (item) { + if (!hasPivot(item)) return null; + return item.localToGlobal(item.pivot); +}; + +/** + * The actual realistic point that everything revolves around: the anchor if there is one, the center of + * the item's bounds otherwise. + * @param {paper.Item} item Any item. + * @return {paper.Point} A point in project space. + */ +const getEffectivePivot = function (item) { + return getPivot(item) || item.bounds.center; +}; + +/** + * @param {paper.Item} item Any item. + * @param {paper.Point} point The anchor, in project space. + */ +const setPivot = function (item, point) { + item.pivot = item.globalToLocal(point); +}; + +/** + * Drop the anchor, returning the item to transforming around its bounds center. + * @param {paper.Item} item Any item. + */ +const clearPivot = function (item) { + item.pivot = null; +}; + +/** + * @param {Array} items The selected root items. + * @return {?paper.Rectangle} The selection's bounds, or null if nothing is selected. + */ +const getSelectionBounds = function (items) { + let rect = null; + for (const item of items) { + rect = rect ? rect.unite(item.bounds) : item.bounds; + } + return rect; +}; + +/** + * The selection's anchor... There's a lot of agreeing and ensuring here + * @param {Array} items The selected root items. + * @return {?paper.Point} The anchor in project space, or null if there is none. + */ +const getSelectionPivot = function (items) { + if (!items || items.length === 0) return null; + const first = getPivot(items[0]); + if (!first) return null; + for (let i = 1; i < items.length; i++) { + const other = getPivot(items[i]); + if (!other || !other.isClose(first, AGREEMENT_TOLERANCE)) return null; + } + return first; +}; + +/** + * Where the selection's transforms happen; its anchor, or the center of its bounds. + * @param {Array} items The selected root items. + * @return {?paper.Point} A point in project space, or null if nothing is selected. + */ +const getEffectiveSelectionPivot = function (items) { + const pivot = getSelectionPivot(items); + if (pivot) return pivot; + const bounds = getSelectionBounds(items); + return bounds ? bounds.center : null; +}; + +/** + * @param {Array} items The selected root items. + * @param {paper.Point} point The anchor, in project space. + */ +const setSelectionPivot = function (items, point) { + for (const item of items) { + setPivot(item, point); + } +}; + +/** + * @param {Array} items The selected root items. + */ +const clearSelectionPivot = function (items) { + for (const item of items) { + clearPivot(item); + } +}; + +/** + * Points a dragged anchor snaps to, each with the radius it pulls from, e.g. the selection's + * bounds (corners, edge midpoints, center) and the costume's rotation center. The bounds + * center comes first so that it wins ties, dropping the anchor back on the center is how + * the user gets rid of it. GET OFFF!!!! + * @param {Array} items The selected root items. + * @return {Array<{point: paper.Point, radius: number}>} Snap targets in project space, + * with radii in screen pixels. + */ +const getSnapTargets = function (items) { + const strong = point => ({point, radius: CENTER_SNAP_THRESHOLD}); + const weak = point => ({point, radius: SNAP_THRESHOLD}); + const bounds = getSelectionBounds(items); + if (!bounds) return [strong(CENTER)]; + return [ + strong(bounds.center), strong(CENTER), + weak(bounds.topLeft), weak(bounds.topCenter), weak(bounds.topRight), + weak(bounds.leftCenter), weak(bounds.rightCenter), + weak(bounds.bottomLeft), weak(bounds.bottomCenter), weak(bounds.bottomRight) + ]; +}; + +/** + * @param {Array} items The selected root items. + * @param {paper.Point} point The pointer position in project space. + * @return {paper.Point} The nearest snap target within its radius, or the point itself. + */ +const snapPivot = function (items, point) { + let best = null; + // Targets have different radii (weird ass word), so weight them. This is just so weird, but the UX is great. + let bestScore = 1; + for (const target of getSnapTargets(items)) { + const radius = target.radius / paper.view.zoom; + const score = target.point.getDistance(point) / radius; // Bro + if (score < bestScore) { + best = target.point; + bestScore = score; + } + } + return best ? best.clone() : point; +}; + +/** + * Whether a point is the selection's own center. + * @param {Array} items The selected root items. + * @param {paper.Point} point A point in project space. + * @return {boolean} True if the anchor should be dropped rather than stored. + */ +const isDefaultPivot = function (items, point) { + const bounds = getSelectionBounds(items); + return Boolean(bounds && bounds.center.isClose(point, AGREEMENT_TOLERANCE)); +}; + +export { + SNAP_THRESHOLD, + CENTER_SNAP_THRESHOLD, + hasPivot, + getPivot, + getEffectivePivot, + setPivot, + clearPivot, + getSelectionBounds, + getSelectionPivot, + getEffectiveSelectionPivot, + setSelectionPivot, + clearSelectionPivot, + getSnapTargets, + snapPivot, + isDefaultPivot +}; diff --git a/src/helper/selection-tools/bounding-box-tool.js b/src/helper/selection-tools/bounding-box-tool.js index 387216f854..8830600ed5 100644 --- a/src/helper/selection-tools/bounding-box-tool.js +++ b/src/helper/selection-tools/bounding-box-tool.js @@ -6,11 +6,12 @@ import {getGuideColor, removeBoundsPath, removeBoundsHandles} from '../guides'; import {getGuideLayer, setGuideItem} from '../layer'; import Cursors from '../../lib/cursors'; +import {BitmapModes} from '../../lib/modes'; import ScaleTool from './scale-tool'; import RotateTool from './rotate-tool'; import MoveTool from './move-tool'; +import PivotTool, {drawPivotHandle} from './pivot-tool'; -const SELECTION_ANCHOR_SIZE = 12; /** SVG for the rotation icon on the bounding box */ const ARROW_PATH = 'M19.28,1.09C19.28.28,19,0,18.2,0c-1.67,0-3.34,0-5,0-.34,0-.88.24-1,.47a1.4,1.4,' + '0,0,0,.36,1.08,15.27,15.27,0,0,0,1.46,1.36A6.4,6.4,0,0,1,6.52,4,5.85,5.85,0,0,1,5.24,3,15.27,15.27,' + @@ -22,7 +23,8 @@ const ARROW_PATH = 'M19.28,1.09C19.28.28,19,0,18.2,0c-1.67,0-3.34,0-5,0-.34,0-.8 const BoundingBoxModes = keyMirror({ SCALE: null, ROTATE: null, - MOVE: null + MOVE: null, + PIVOT: null }); /** @@ -44,15 +46,20 @@ class BoundingBoxTool { constructor (mode, setSelectedItems, clearSelectedItems, setCursor, onUpdateImage, switchToTextTool) { this.dispatchSetCursor = setCursor; this.onUpdateImage = onUpdateImage; + // The paint editor mode this tool was built for, as opposed to `this.mode`, which + // is what the tool is currently doing. + this.paintMode = mode; this.mode = null; this.boundsPath = null; this.boundsScaleHandles = []; this.boundsRotHandles = []; + this.pivotHandle = null; this._modeMap = {}; this._modeMap[BoundingBoxModes.SCALE] = new ScaleTool(mode, onUpdateImage); this._modeMap[BoundingBoxModes.ROTATE] = new RotateTool(onUpdateImage); this._modeMap[BoundingBoxModes.MOVE] = new MoveTool(mode, setSelectedItems, clearSelectedItems, onUpdateImage, switchToTextTool); + this._modeMap[BoundingBoxModes.PIVOT] = new PivotTool(onUpdateImage); this._currentCursor = null; } @@ -102,9 +109,14 @@ class BoundingBoxTool { this.removeBoundsHandles(); } else if (this.mode === BoundingBoxModes.ROTATE) { this.setCursor(Cursors.GRABBING); - this._modeMap[this.mode].onMouseDown(hitResult, this.boundsPath, getSelectedRootItems()); + this._modeMap[this.mode].onMouseDown( + hitResult, this.boundsPath, getSelectedRootItems(), event.point); // While transforming, don't show bounds this.removeBoundsPath(); + } else if (this.mode === BoundingBoxModes.PIVOT) { + this.setCursor(Cursors.GRABBING); + this._modeMap[this.mode].onMouseDown( + hitResult, getSelectedRootItems(), doubleClicked, this.pivotHandle); } return true; @@ -117,6 +129,8 @@ class BoundingBoxTool { if (hitResult) { if (mode === BoundingBoxModes.MOVE) { this.setCursor(Cursors.DEFAULT); + } else if (mode === BoundingBoxModes.PIVOT) { + this.setCursor(Cursors.GRAB); } else if (mode === BoundingBoxModes.ROTATE) { this.setCursor(Cursors.GRAB); } else if (mode === BoundingBoxModes.SCALE) { @@ -147,8 +161,10 @@ class BoundingBoxTool { let mode; - // Prefer scale to trigger over rotate, and scale and rotate to trigger over other hits + // Prefer scale to trigger over rotate, rotate over dragging the pivot, and all + // three over other hits let hitResult = hitResults[0]; + let pivotHitResult = null; for (let i = 0; i < hitResults.length; i++) { if (hitResults[i].item.data && hitResults[i].item.data.isScaleHandle) { hitResult = hitResults[i]; @@ -157,8 +173,14 @@ class BoundingBoxTool { } else if (hitResults[i].item.data && hitResults[i].item.data.isRotHandle) { hitResult = hitResults[i]; mode = BoundingBoxModes.ROTATE; + } else if (hitResults[i].item.data && hitResults[i].item.data.isPivotHandle) { + pivotHitResult = hitResults[i]; } } + if (!mode && pivotHitResult) { + hitResult = pivotHitResult; + mode = BoundingBoxModes.PIVOT; + } if (!mode) { mode = BoundingBoxModes.MOVE; } @@ -212,23 +234,6 @@ class BoundingBoxTool { this.boundsRect.curves[4].divideAtTime(0.5); this.boundsRect.curves[6].divideAtTime(0.5); this.boundsPath.addChild(this.boundsRect); - - const vRect = new paper.Path.Rectangle({ - point: [-1, -6], - size: [2, 12], - radius: 1, - insert: false - }); - const hRect = new paper.Path.Rectangle({ - point: [-6, -1], - size: [12, 2], - radius: 1, - insert: false - }); - const anchorIcon = vRect.unite(hRect); - - this.boundsPath.addChild(anchorIcon); - this.boundsPath.selectionAnchor = anchorIcon; this._modeMap[BoundingBoxModes.MOVE].setBoundsPath(this.boundsPath); } setGuideItem(this.boundsPath); @@ -238,9 +243,6 @@ class BoundingBoxTool { this.boundsPath.parent = getGuideLayer(); this.boundsPath.strokeWidth = 1 / paper.view.zoom; this.boundsPath.strokeColor = getGuideColor(); - this.boundsPath.selectionAnchor.scale( - SELECTION_ANCHOR_SIZE / paper.view.zoom / this.boundsPath.selectionAnchor.bounds.width); - this.boundsPath.selectionAnchor.position = rect.center; // Make a template to copy const boundsScaleCircleShadow = @@ -314,6 +316,14 @@ class BoundingBoxTool { } // Remove the template boundsScaleHandle.remove(); + + /** + * The crosshair marking where transforms happen. It is drawn last so that it sits + * above the bounds, and in vector mode it is draggable in PivotTool. Bitmap mode + * gets the same marker but immovable and stupid, since a bitmap costume has no concept of a transform anchor. + * Foolish bitmap costume. Foolish! + */ + this.pivotHandle = drawPivotHandle(items, !(this.paintMode in BitmapModes)); } removeBoundsPath () { removeBoundsPath(); @@ -321,11 +331,13 @@ class BoundingBoxTool { this.boundsRect = null; this.boundsScaleHandles.length = 0; this.boundsRotHandles.length = 0; + this.pivotHandle = null; } removeBoundsHandles () { removeBoundsHandles(); this.boundsScaleHandles.length = 0; this.boundsRotHandles.length = 0; + this.pivotHandle = null; } deactivateTool () { this.removeBoundsPath(); diff --git a/src/helper/selection-tools/pivot-tool.js b/src/helper/selection-tools/pivot-tool.js new file mode 100644 index 0000000000..90d7540d34 --- /dev/null +++ b/src/helper/selection-tools/pivot-tool.js @@ -0,0 +1,158 @@ +import paper from '@turbowarp/paper'; + +import {getGuideColor} from '../guides'; +import {getGuideLayer} from '../layer'; +import { + clearSelectionPivot, getEffectiveSelectionPivot, isDefaultPivot, setSelectionPivot, snapPivot +} from '../pivot'; + +/** A little white halo to help you in your troubles, in case you have the exact shade of color that the crosshair uses + * and you don't want it to get lost + */ +const GUIDE_HALO = 'rgba(255, 255, 255, 0.9)'; + +/** Size of the pivot crosshair handle in screen pixels. */ +const PIVOT_HANDLE_SIZE = 18; +/** Radius of the invisible grab disc around the crosshair in screen pixels. */ +const PIVOT_GRAB_RADIUS = 9; +/** Extra width added to the halo behind the handle in screen pixels. */ +const PIVOT_HALO_WIDTH = 2; + +/** + * Draw the crosshair handle that marks where the selection transforms from. + * It sits on the anchor and can be dragged around unless locked for bitmap mode. + * @param {Array} items The selected root items. + * @param {boolean} draggable False to draw the crosshair as a plain marker (for bitmap mode). + * @return {?paper.Group} The handle group on the guide layer, or null if nothing is selected. + */ +const drawPivotHandle = function (items, draggable) { + const position = getEffectiveSelectionPivot(items); + if (!position) return null; + + const zoom = 1 / paper.view.zoom; + const data = { + isPivotHandle: true, + isHelperItem: true, + noSelect: true, + noHover: true + }; + const children = []; + + if (draggable) { + // I don't like pixel-hunting... pixel-hunting makes my back hurt. Big invisible disc works. + children.push(new paper.Path.Circle({ + center: new paper.Point(0, 0), + radius: PIVOT_GRAB_RADIUS * zoom, + fillColor: new paper.Color(1, 1, 1, 0.01), + insert: false, + data: Object.assign({}, data) + })); + } + + // Built at like 12 units wide so that proportions are consistent. + const buildGlyph = function () { + const vRect = new paper.Path.Rectangle({ + point: [-1, -6], + size: [2, 12], + radius: 1, + insert: false + }); + const hRect = new paper.Path.Rectangle({ + point: [-6, -1], + size: [12, 2], + radius: 1, + insert: false + }); + const cross = vRect.unite(hRect); + vRect.remove(); + hRect.remove(); + const ring = new paper.Path.Circle({ + center: [0, 0], + radius: 4.25, + insert: false + }); + const glyph = new paper.Group([cross, ring]); + glyph.scale(PIVOT_HANDLE_SIZE * zoom / 12); + // Style after scaling so the stroke width does not stretch with it. eurguhghjfkdjdfgkh + glyph.fillColor = null; + glyph.data = Object.assign({}, data); + return glyph; + }; + + const halo = buildGlyph(); + halo.strokeColor = GUIDE_HALO; + halo.strokeWidth = (PIVOT_HALO_WIDTH * 2 * zoom) + zoom; + children.push(halo); + + const crosshair = buildGlyph(); + crosshair.strokeColor = getGuideColor(); + crosshair.strokeWidth = 1.5 * zoom; + children.push(crosshair); + + const handle = new paper.Group(children); + handle.parent = getGuideLayer(); + handle.position = position; + handle.data = data; + handle.locked = !draggable; + return handle; +}; + +/** + * Tool to handle dragging, placing, and resetting the selection's transform anchor. + */ +class PivotTool { + /** + * @param {!function} onUpdateImage A callback to call when the image visibly changes. + */ + constructor (onUpdateImage) { + this.onUpdateImage = onUpdateImage; + this.items = null; + this.handle = null; + this.dragged = false; + } + + /** + * @param {!paper.HitResult} hitResult Data about the location of the mouse click. + * @param {!Array.} selectedItems Set of selected paper.Items. + * @param {?boolean} doubleClicked True to clear the anchor instead of dragging it. + * @param {?paper.Group} handle The crosshair being dragged so it can follow the anchor. + */ + onMouseDown (hitResult, selectedItems, doubleClicked, handle) { + this.items = selectedItems.length ? selectedItems : null; + this.handle = handle || null; + this.dragged = false; + if (!this.items) return; + + if (doubleClicked) { + // Double click resets the anchor back to the selection center. + clearSelectionPivot(this.items); + if (this.handle) this.handle.position = getEffectiveSelectionPivot(this.items); + this.items = null; + this.onUpdateImage(); + } + } + onMouseDrag (event) { + if (!this.items) return; + const point = snapPivot(this.items, event.point); + if (isDefaultPivot(this.items, point)) { + clearSelectionPivot(this.items); + } else { + setSelectionPivot(this.items, point); + } + if (this.handle) this.handle.position = point; + this.dragged = true; + } + onMouseUp (event) { + if (event.event.button > 0) return; // Only primary mouse clicks. + + const moved = this.dragged; + this.items = null; + this.handle = null; + this.dragged = false; + // It changed. Visibly! Update the image. + if (moved) this.onUpdateImage(); + } +} + +export default PivotTool; +export {drawPivotHandle, PIVOT_HANDLE_SIZE, PIVOT_GRAB_RADIUS}; diff --git a/src/helper/selection-tools/scale-tool.js b/src/helper/selection-tools/scale-tool.js index 248ebc6c04..65cc8e2d04 100644 --- a/src/helper/selection-tools/scale-tool.js +++ b/src/helper/selection-tools/scale-tool.js @@ -46,7 +46,6 @@ class ScaleTool { this.pivot = boundsPath.bounds[this._getOpposingRectCornerNameByIndex(index)].clone(); this.origPivot = boundsPath.bounds[this._getOpposingRectCornerNameByIndex(index)].clone(); this.corner = boundsPath.bounds[this._getRectCornerNameByIndex(index)].clone(); - this.selectionAnchor = boundsPath.selectionAnchor; this.origSize = this.corner.subtract(this.pivot); this.origCenter = boundsPath.bounds.center; this.isCorner = this._isCorner(index); @@ -117,9 +116,6 @@ class ScaleTool { const shearY = (sky / skewBounds.width) * shearMult; this.itemGroup.shear(shearX, shearY, position); - if (this.selectionAnchor) { - this.selectionAnchor.shear(-shearX, -shearY); - } }; // Revert skew @@ -138,9 +134,6 @@ class ScaleTool { // Reset position this.centered = false; this.itemGroup.scale(1 / this.lastSx, 1 / this.lastSy, this.pivot); - if (this.selectionAnchor) { - this.selectionAnchor.scale(this.lastSx, this.lastSy); - } this.lastSx = 1; this.lastSy = 1; } @@ -189,9 +182,6 @@ class ScaleTool { // Reset position if we were just in alt this.centered = false; this.itemGroup.scale(1 / this.lastSx, 1 / this.lastSy, this.pivot); - if (this.selectionAnchor) { - this.selectionAnchor.scale(this.lastSx, this.lastSy); - } this.lastSx = 1; this.lastSy = 1; } @@ -222,9 +212,6 @@ class ScaleTool { sx = signx * Math.max(Math.abs(sx), MIN_SCALE_FACTOR); sy = signy * Math.max(Math.abs(sy), MIN_SCALE_FACTOR); this.itemGroup.scale(sx / this.lastSx, sy / this.lastSy, this.pivot); - if (this.selectionAnchor) { - this.selectionAnchor.scale(this.lastSx / sx, this.lastSy / sy); - } this.lastSx = sx; this.lastSy = sy; } diff --git a/src/helper/selection-tools/select-tool.js b/src/helper/selection-tools/select-tool.js index e475cdc2ac..93ecf5f4a7 100644 --- a/src/helper/selection-tools/select-tool.js +++ b/src/helper/selection-tools/select-tool.js @@ -101,7 +101,9 @@ class SelectTool extends paper.Tool { match: hitResult => { // Don't match helper items, unless they are handles. if (!hitResult.item.data || !hitResult.item.data.isHelperItem) return true; - return hitResult.item.data.isScaleHandle || hitResult.item.data.isRotHandle; + return hitResult.item.data.isScaleHandle || + hitResult.item.data.isRotHandle || + hitResult.item.data.isPivotHandle; } }; if (preselectedOnly) { diff --git a/test/unit/pivot-handle.test.js b/test/unit/pivot-handle.test.js new file mode 100644 index 0000000000..c00fa3cd91 --- /dev/null +++ b/test/unit/pivot-handle.test.js @@ -0,0 +1,45 @@ +/* eslint-env jest, browser */ +import paper from '@turbowarp/paper'; + +import {drawPivotHandle} from '../../src/helper/selection-tools/pivot-tool'; +import {setSelectionPivot} from '../../src/helper/pivot'; +import {setupLayers} from '../../src/helper/layer'; +import Formats from '../../src/lib/format'; + +let square; + +const hitTestCenter = () => paper.project + .hitTestAll(square.bounds.center, {fill: true, stroke: true}) + .some(hit => hit.item.data && hit.item.data.isPivotHandle); + +beforeEach(() => { + paper.setup(new paper.Size(960, 720)); + setupLayers(Formats.VECTOR); + square = new paper.Path.Rectangle({point: [100, 100], size: [100, 100], insert: true}); + square.fillColor = 'red'; +}); + +test('it sits on the center until an anchor drags it off', () => { + expect(drawPivotHandle([square], true).position.round()).toEqual(square.bounds.center.round()); + + setSelectionPivot([square], square.bounds.topLeft); + expect(drawPivotHandle([square], true).position.round()).toEqual(square.bounds.topLeft.round()); +}); + +test('you can actually grab it', () => { + const handle = drawPivotHandle([square], true); + expect(handle.data.isPivotHandle).toBe(true); + expect(handle.locked).toBe(false); + // paper skips guide items in hit tests, so the handle better not be one. + expect(handle.guide).toBeFalsy(); + expect(hitTestCenter()).toBe(true); +}); + +test('but not in bitmap mode, where it is just a sticker', () => { + expect(drawPivotHandle([square], false).locked).toBe(true); + expect(hitTestCenter()).toBe(false); +}); + +test('nothing selected, nothing drawn', () => { + expect(drawPivotHandle([], true)).toBeNull(); +}); diff --git a/test/unit/pivot.test.js b/test/unit/pivot.test.js new file mode 100644 index 0000000000..2022bb757c --- /dev/null +++ b/test/unit/pivot.test.js @@ -0,0 +1,162 @@ +/* eslint-env jest, browser */ +import paper from '@turbowarp/paper'; + +import { + clearPivot, clearSelectionPivot, getEffectivePivot, getEffectiveSelectionPivot, getPivot, + getSelectionPivot, hasPivot, isDefaultPivot, setPivot, setSelectionPivot, snapPivot +} from '../../src/helper/pivot'; +import {CENTER} from '../../src/helper/view'; +import {groupItems} from '../../src/helper/group'; + +const noop = () => {}; + +const rect = (x, y, size) => new paper.Path.Rectangle({ + point: [x, y], + size: [size, size], + insert: true +}); + +let square; + +beforeEach(() => { + paper.setup(new paper.Size(960, 720)); + square = rect(100, 100, 50); + square.fillColor = 'red'; +}); + +test('no anchor by default', () => { + expect(hasPivot(square)).toBe(false); + expect(getPivot(square)).toBeNull(); + expect(getEffectivePivot(square)).toEqual(square.bounds.center); +}); + +test('set, get, clear', () => { + setPivot(square, new paper.Point(100, 100)); + expect(hasPivot(square)).toBe(true); + expect(getPivot(square).round()).toEqual(new paper.Point(100, 100)); + + clearPivot(square); + expect(hasPivot(square)).toBe(false); +}); + +test('the anchor follows the item around', () => { + setPivot(square, square.bounds.topLeft); + + square.translate(new paper.Point(40, 25)); + expect(getPivot(square).round()).toEqual(square.bounds.topLeft.round()); + + square.rotate(90, square.bounds.center); + expect(getPivot(square).round()).toEqual(square.bounds.topRight.round()); +}); + +test('dropping it back on the center means no anchor', () => { + expect(isDefaultPivot([square], square.bounds.center)).toBe(true); + expect(isDefaultPivot([square], square.bounds.topLeft)).toBe(false); +}); + +describe('selections', () => { + let other; + + beforeEach(() => { + other = rect(300, 300, 10); + }); + + test('a group is one item and speaks for itself', () => { + const group = new paper.Group([square, other]); + setSelectionPivot([group], group.bounds.topLeft); + expect(getSelectionPivot([group]).round()).toEqual(group.bounds.topLeft.round()); + + // Comes along for the ride. + group.rotate(90, group.bounds.center); + expect(getSelectionPivot([group]).round()).toEqual(group.bounds.topRight.round()); + }); + + test('a loose selection needs everyone to agree', () => { + const items = [square, other]; + expect(getSelectionPivot(items)).toBeNull(); + expect(getEffectiveSelectionPivot(items).round()) + .toEqual(square.bounds.unite(other.bounds).center.round()); + + const anchor = new paper.Point(200, 200); + setSelectionPivot(items, anchor); + expect(getSelectionPivot(items).round()).toEqual(anchor.round()); + + // One holdout ruins it for everybody. + setPivot(other, new paper.Point(210, 200)); + expect(getSelectionPivot(items)).toBeNull(); + + clearSelectionPivot(items); + expect(hasPivot(square)).toBe(false); + expect(hasPivot(other)).toBe(false); + }); + + test('snapping uses the whole selection, not just one item', () => { + const items = [square, other]; + const center = square.bounds.unite(other.bounds).center; + expect(snapPivot(items, center.add(1, 1)).round()).toEqual(center.round()); + }); +}); + +describe('snapping', () => { + test('corners, edges, center, rotation center', () => { + const corner = square.bounds.topLeft; + expect(snapPivot([square], corner.add(2, 2)).round()).toEqual(corner.round()); + expect(snapPivot([square], square.bounds.center.add(1, 0)).round()) + .toEqual(square.bounds.center.round()); + expect(snapPivot([square], CENTER.subtract(3, 0)).round()).toEqual(CENTER.round()); + + const free = square.bounds.center.add(25, 25); + expect(snapPivot([square], free)).toEqual(free); + }); + + test('the center has a fatter radius than the corners', () => { + const big = rect(100, 100, 200); + const middle = big.bounds.center; + + // Way outside the corner radius, comfy inside the center's. + expect(snapPivot([big], middle.add(12, 0)).round()).toEqual(middle.round()); + + // Corner still wins if you're genuinely closer to it. Weighted, not raw distance. + const corner = big.bounds.topLeft; + expect(snapPivot([big], corner.add(3, 3)).round()).toEqual(corner.round()); + + const free = middle.add(60, 60); + expect(snapPivot([big], free)).toEqual(free); + }); +}); + +describe('grouping', () => { + let left; + let right; + + beforeEach(() => { + left = rect(0, 0, 20); + right = rect(100, 0, 20); + }); + + test('the anchor moves onto the group', () => { + const anchor = new paper.Point(110, 10); + setSelectionPivot([left, right], anchor); + + const group = groupItems([left, right], noop, noop, noop); + expect(group).toBeTruthy(); + expect(getPivot(group).round()).toEqual(anchor.round()); + expect(getEffectiveSelectionPivot([group]).round()).toEqual(anchor.round()); + + // One answer, not three. The members shut up. + expect(hasPivot(left)).toBe(false); + expect(hasPivot(right)).toBe(false); + }); + + test('no anchor in, no anchor out', () => { + expect(hasPivot(groupItems([left, right], noop, noop, noop))).toBe(false); + }); + + test('and it will not just pick one if they disagree', () => { + setPivot(left, new paper.Point(0, 0)); + setPivot(right, new paper.Point(120, 20)); + + const group = groupItems([left, right], noop, noop, noop); + expect(hasPivot(group)).toBe(false); + }); +}); From 40b179b8e6fc7e7184fe132dd85d3caeaff64dee Mon Sep 17 00:00:00 2001 From: drakeraz09 Date: Mon, 21 Sep 2026 13:08:26 +0000 Subject: [PATCH 2/3] Rotate around the anchor RotateTool always span the selection about the center of its bounds. Now that the selection can carry an anchor, spin around that when everyone agrees on one, and fall back to the bounds center otherwise. This also kills a jolt that was there before any of this existed. `prevRot` got initialized to 90 degrees on mouse down, which assumes the rotation handle sits directly below whatever you are spinning around. Only true when that happens to be the bounds center. So the first drag event applied the gap between the real angle and the assumed one as a rotation, and the drawing jumped the instant you touched the handle. With an anchor set it was bad enough to throw the art off screen. Just use where the handle actually was. Co-Authored-By: Claude Opus 5 --- src/helper/selection-tools/rotate-tool.js | 15 ++++- test/unit/rotate-pivot.test.js | 72 +++++++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 test/unit/rotate-pivot.test.js diff --git a/src/helper/selection-tools/rotate-tool.js b/src/helper/selection-tools/rotate-tool.js index 38fb6fa87c..ee4167373e 100644 --- a/src/helper/selection-tools/rotate-tool.js +++ b/src/helper/selection-tools/rotate-tool.js @@ -1,5 +1,7 @@ import paper from '@turbowarp/paper'; +import {getSelectionPivot} from '../pivot'; + /** * Tool to handle rotation when dragging the rotation handle in the bounding box tool. */ @@ -18,16 +20,23 @@ class RotateTool { * @param {!paper.HitResult} hitResult Data about the location of the mouse click * @param {!object} boundsPath Where the boundaries of the hit item are * @param {!Array.} selectedItems Set of selected paper.Items + * @param {!paper.Point} point Where the rotation handle was actually grabbed */ - onMouseDown (hitResult, boundsPath, selectedItems) { - this.rotGroupPivot = boundsPath.bounds.center; + onMouseDown (hitResult, boundsPath, selectedItems, point) { + // Spin around the anchor if there is one, otherwise the center like always. + this.rotGroupPivot = getSelectionPivot(selectedItems) || boundsPath.bounds.center; for (const item of selectedItems) { // Rotate only root items if (item.parent instanceof paper.Layer) { this.rotItems.push(item); } } - this.prevRot = 90; + // This used to just assume 90 degrees, which is only true when you're spinning around + // the bounds center. So the art JOLTED the instant you touched the handle. With an + // anchor set it launched the drawing into orbit. Just use where the handle actually is. + this.prevRot = point ? + point.subtract(this.rotGroupPivot).angle : + 90; } onMouseDrag (event) { let rotAngle = (event.point.subtract(this.rotGroupPivot)).angle; diff --git a/test/unit/rotate-pivot.test.js b/test/unit/rotate-pivot.test.js new file mode 100644 index 0000000000..a20d47eaa7 --- /dev/null +++ b/test/unit/rotate-pivot.test.js @@ -0,0 +1,72 @@ +/* eslint-env jest, browser */ +import paper from '@turbowarp/paper'; + +import RotateTool from '../../src/helper/selection-tools/rotate-tool'; +import {setSelectionPivot} from '../../src/helper/pivot'; + +// Enough of a paper tool event for RotateTool to chew on. +const dragEvent = point => ({point: point, modifiers: {shift: false}}); + +let tool; +let square; +let boundsPath; + +beforeEach(() => { + paper.setup(new paper.Size(960, 720)); + tool = new RotateTool(() => {}); + square = new paper.Path.Rectangle({point: [100, 100], size: [100, 100], insert: true}); + square.fillColor = 'red'; + boundsPath = {bounds: square.bounds}; +}); + +// The handle sits below the bounds, which is exactly where prevRot used to just assume +// it was. Grabbing it should do nothing at all until you actually move. +test('grabbing the handle does not move anything', () => { + const grab = square.bounds.bottomCenter.add(0, 20); + const before = square.bounds.clone(); + + tool.onMouseDown(null, boundsPath, [square], grab); + tool.onMouseDrag(dragEvent(grab)); + + expect(square.bounds.topLeft.round()).toEqual(before.topLeft.round()); + expect(square.bounds.size.round()).toEqual(before.size.round()); +}); + +test('...and still does not with an anchor set', () => { + // Anchor in the corner, so the angle is nowhere near 90. This is the one that used to + // yeet the drawing across the canvas. + setSelectionPivot([square], square.bounds.topLeft); + const grab = square.bounds.bottomCenter.add(0, 20); + const before = square.bounds.clone(); + + tool.onMouseDown(null, boundsPath, [square], grab); + tool.onMouseDrag(dragEvent(grab)); + + expect(square.bounds.topLeft.round()).toEqual(before.topLeft.round()); +}); + +test('it spins around the anchor', () => { + const anchor = square.bounds.topLeft; + setSelectionPivot([square], anchor); + + tool.onMouseDown(null, boundsPath, [square], anchor.add(100, 0)); + tool.onMouseDrag(dragEvent(anchor.add(0, 100))); // quarter turn + + // Anchor stays put, the far corner swings around it. + expect(square.bounds.topRight.round()).toEqual(anchor.round()); +}); + +test('no agreement means it falls back to the selection center', () => { + const other = new paper.Path.Rectangle({point: [300, 300], size: [100, 100], insert: true}); + const bounds = square.bounds.unite(other.bounds); + + setSelectionPivot([square], square.bounds.topLeft); // only one of them, so it doesn't count + + const grab = bounds.bottomCenter.add(0, 20); + tool.onMouseDown(null, {bounds: bounds}, [square, other], grab); + tool.onMouseDrag(dragEvent(bounds.center.add(bounds.center.subtract(grab)))); + + // Half a turn about the center and the two squares trade places. + expect(square.bounds.center.round()).toEqual(bounds.center.add(bounds.center.subtract( + new paper.Point(150, 150))).round()); +}); From ba6acb52597585ef8679181044348041bdce4b37 Mon Sep 17 00:00:00 2001 From: drakeraz09 Date: Mon, 21 Sep 2026 13:08:34 +0000 Subject: [PATCH 3/3] Save the pivot with the costume `item.pivot` survives undo because paper serializes it into the project JSON, but it does not survive the SVG round trip a costume goes through on save. An anchor that quietly vanishes when you reopen the project is worse than no anchor at all. It goes on the item's own element as `data-pivot`. The element is the key, so there is no id to hand out and nothing to clean up later, and copying a subtree brings the anchor along for free. DOMPurify's svg profile keeps `data-*` attributes, so it survives the round trip scratch-vm puts costumes through. It crosses the SVG boundary as an inert copy on `item.data`, written before the export scales the layer, read back on import, and applied only once initializeSvg has finished shoving everything around. That is what stops it getting mangled. Stored relative to the art board center rather than in SVG coordinates, because the SVG export origin depends on the current content bounds and moves whenever the art does. An anchor written in SVG space would drift every time you nudged the drawing. Bitmap mode gets the crosshair as a plain marker. A bitmap costume has nowhere to save an anchor, and setting one would change what `position` means on the raster the bitmap tools move around. Co-Authored-By: Claude Opus 5 --- src/containers/paper-canvas.jsx | 9 +- src/helper/pivot-data.js | 178 ++++++++++++++++++++++++++++++++ src/hocs/update-image-hoc.jsx | 9 +- test/unit/pivot-data.test.js | 102 ++++++++++++++++++ 4 files changed, 294 insertions(+), 4 deletions(-) create mode 100644 src/helper/pivot-data.js create mode 100644 test/unit/pivot-data.test.js diff --git a/src/containers/paper-canvas.jsx b/src/containers/paper-canvas.jsx index b6ffbb53fd..f449ee1043 100644 --- a/src/containers/paper-canvas.jsx +++ b/src/containers/paper-canvas.jsx @@ -16,6 +16,7 @@ import { clampViewBounds, resetZoom, setWorkspaceBounds, zoomToFit, resizeCrosshair } from '../helper/view'; import {ensureClockwise, scaleWithStrokes} from '../helper/math'; +import {pivotImportOptions, restorePivotsToItems} from '../helper/pivot-data'; import {clearHoveredItem} from '../reducers/hover'; import {clearPasteOffset} from '../reducers/clipboard'; import {changeFormat} from '../reducers/format'; @@ -231,7 +232,7 @@ class PaperCanvas extends React.Component { } } - paper.project.importSVG(svg, { + paper.project.importSVG(svg, Object.assign({ expandShapes: true, insert: false, onLoad: function (item) { @@ -251,7 +252,7 @@ class PaperCanvas extends React.Component { paperCanvas.initializeSvg(item, rotationCenterX, rotationCenterY, viewBox); }); } - }); + }, pivotImportOptions())); } initializeSvg (item, rotationCenterX, rotationCenterY, viewBox) { setImportingImage(false); @@ -315,6 +316,10 @@ class PaperCanvas extends React.Component { ungroupItems([item]); } + // Tree's done moving around now, so the anchors can finally go back on. Do NOT + // move this earlier, initializeSvg will shove everything around and ruin it. + restorePivotsToItems(); + performSnapshot(this.props.undoSnapshot, Formats.VECTOR_SKIP_CONVERT); this.maybeZoomToFit(); } diff --git a/src/helper/pivot-data.js b/src/helper/pivot-data.js new file mode 100644 index 0000000000..f64210c2b0 --- /dev/null +++ b/src/helper/pivot-data.js @@ -0,0 +1,178 @@ +/** + * SERIALIZATION STUFF + * Saving the transform anchor directly onto the SVG element as `data-pivot`. + * + * This uses two states: + * `item.pivot` - live editor state in local space. Volatile! + * `item.data.persistedPivot` - inert JSON copy in art-board space that survives SVG export/import. Sturdy! + */ +import paper from '@turbowarp/paper'; + +import {getPivot, setPivot} from './pivot'; +import {CENTER} from './view'; + +const ATTRIBUTE = 'data-pivot'; +const PRECISION = 4; + +const _round = function (n) { + const factor = Math.pow(10, PRECISION); + return Math.round(n * factor) / factor; +}; + +/** + * @param {paper.Point} point A point in paper space. Smooth and inoffensive. + * @return {Array} [x, y] relative to the art board center, rounded. + */ +const toPersistedArray = function (point) { + return [_round(point.x - CENTER.x), _round(point.y - CENTER.y)]; +}; + +/** + * @param {Array} array [x, y] relative to the art board center. + * @return {?paper.Point} The point in paper space, or null if malformed. + */ +const fromPersistedArray = function (array) { + if (!Array.isArray(array) || array.length < 2) return null; + const x = Number(array[0]); + const y = Number(array[1]); + if (isNaN(x) || isNaN(y)) return null; + return new paper.Point(x + CENTER.x, y + CENTER.y); +}; + +/** + * @param {Array} array [x, y]. + * @return {string} Serialized attribute value. + */ +const serializePivot = function (array) { + return array.join(','); +}; + +/** + * @param {string} string An attribute value. + * @return {?Array} [x, y], or null if invalid. + */ +const parsePivot = function (string) { + if (typeof string !== 'string') return null; + const parts = string.split(','); + if (parts.length !== 2) return null; + const x = Number(parts[0]); + const y = Number(parts[1]); + return isNaN(x) || isNaN(y) ? null : [x, y]; +}; + +/** + * Walk the painting layer, dodging guides and clip masks. + * @param {function} callback Called with each valid item. + */ +const walkPaintingLayer = function (callback) { + let layer = null; + for (const child of paper.project.layers) { + if (child.data && child.data.isPaintingLayer) { + layer = child; + break; + } + } + if (!layer) return; + const recurse = function (item) { + if (item.guide || item.isClipMask()) return; + callback(item); + if (item.children) { + for (const child of item.children) recurse(child); + } + }; + for (const child of layer.children) recurse(child); +}; + +/** + * Copy live anchor to inert data before export scales things. + * @param {paper.Item} item Any item. + */ +const syncPivotData = function (item) { + const pivot = getPivot(item); + if (!item.data) item.data = {}; + if (pivot) { + item.data.persistedPivot = toPersistedArray(pivot); + } else { + delete item.data.persistedPivot; + } +}; + +/** + * Apply inert data back to the live anchor after import finishes positioning... + * @param {paper.Item} item Any item. + */ +const applyPivotData = function (item) { + if (!item.data || !item.data.persistedPivot) return; + const point = fromPersistedArray(item.data.persistedPivot); + if (point) setPivot(item, point); +}; + +/** + * Stamps the anchor attribute onto the exported SVG node. + * @param {paper.Item} item The item being exported. + * @param {Element} node The SVG node paper produced. + * @return {Element} The same node. + */ +const stampPivotOnExport = function (item, node) { + if (!node || !node.setAttribute) return node; + const value = item.data && item.data.persistedPivot; + if (value) { + node.setAttribute(ATTRIBUTE, serializePivot(value)); + } else if (node.removeAttribute) { + node.removeAttribute(ATTRIBUTE); + } + return node; +}; + +/** + * Stashes the anchor inertly on import so it survives shape expansion. + * @param {Element} node The incoming SVG node. + * @param {paper.Item} item The paper item born from it. + * @return {paper.Item} The same item. + */ +const readPivotOnImport = function (node, item) { + if (!item || !node || !node.getAttribute) return item; + const value = parsePivot(node.getAttribute(ATTRIBUTE)); + if (!value) return item; + if (!item.data) item.data = {}; + item.data.persistedPivot = value; + return item; +}; + +/** + * @return {object} Options for `paper.project.exportSVG`. + */ +const pivotExportOptions = function () { + return {onExport: stampPivotOnExport}; +}; + +/** + * @return {object} Options for `paper.project.importSVG`. + */ +const pivotImportOptions = function () { + return {onImport: readPivotOnImport}; +}; + +const syncPivotsFromItems = function () { + walkPaintingLayer(syncPivotData); +}; + +const restorePivotsToItems = function () { + walkPaintingLayer(applyPivotData); +}; + +export { + ATTRIBUTE, + toPersistedArray, + fromPersistedArray, + serializePivot, + parsePivot, + syncPivotData, + applyPivotData, + stampPivotOnExport, + readPivotOnImport, + pivotExportOptions, + pivotImportOptions, + syncPivotsFromItems, + restorePivotsToItems +}; diff --git a/src/hocs/update-image-hoc.jsx b/src/hocs/update-image-hoc.jsx index 8411f8e1f7..9886d94479 100644 --- a/src/hocs/update-image-hoc.jsx +++ b/src/hocs/update-image-hoc.jsx @@ -15,6 +15,7 @@ import {getRaster, hideGuideLayers, showGuideLayers} from '../helper/layer'; import {commitRectToBitmap, commitOvalToBitmap, commitSelectionToBitmap, getHitBounds} from '../helper/bitmap'; import {performSnapshot} from '../helper/undo'; import {scaleWithStrokes} from '../helper/math'; +import {pivotExportOptions, syncPivotsFromItems} from '../helper/pivot-data'; import { ART_BOARD_WIDTH, ART_BOARD_HEIGHT, SVG_ART_BOARD_WIDTH, SVG_ART_BOARD_HEIGHT, @@ -121,6 +122,10 @@ const UpdateImageHOC = function (WrappedComponent) { } } handleUpdateVector (skipSnapshot) { + // Snapshot the anchors while everything is still 1x and hasn't budged. They're + // stored relative to the art board, so doing this later gets you garbage. + syncPivotsFromItems(); + // Remove viewbox (this would make it export at MAX_WORKSPACE_BOUNDS) let workspaceMask; if (paper.project.activeLayer.clipped) { @@ -149,11 +154,11 @@ const UpdateImageHOC = function (WrappedComponent) { this.props.onUpdateImage( true /* isVector */, - paper.project.exportSVG({ + paper.project.exportSVG(Object.assign({ asString: true, bounds: 'content', matrix: new paper.Matrix().translate(-bounds.x, -bounds.y) - }), + }, pivotExportOptions())), centerX, centerY); scaleWithStrokes(paper.project.activeLayer, 2, new paper.Point()); diff --git a/test/unit/pivot-data.test.js b/test/unit/pivot-data.test.js new file mode 100644 index 0000000000..c81ad415c1 --- /dev/null +++ b/test/unit/pivot-data.test.js @@ -0,0 +1,102 @@ +/* eslint-env jest, browser */ +import paper from '@turbowarp/paper'; + +import {getPivot, hasPivot, setPivot} from '../../src/helper/pivot'; +import { + ATTRIBUTE, applyPivotData, readPivotOnImport, restorePivotsToItems, stampPivotOnExport, + syncPivotData, syncPivotsFromItems +} from '../../src/helper/pivot-data'; +import {CENTER} from '../../src/helper/view'; + +// A detached to stand in for whatever paper hands the export hook. +const pathNode = attributes => new DOMParser().parseFromString( + ``, + 'text/xml' +).documentElement; + +let layer; +let square; + +beforeEach(() => { + paper.setup(new paper.Size(960, 720)); + layer = paper.project.activeLayer; + layer.data.isPaintingLayer = true; + square = new paper.Path.Rectangle({point: [100, 100], size: [50, 50], insert: true}); + square.fillColor = 'red'; +}); + +test('out to an attribute and back again', () => { + setPivot(square, square.bounds.topLeft); + syncPivotData(square); + + expect(square.data.persistedPivot).toEqual([100 - CENTER.x, 100 - CENTER.y]); + + const node = pathNode(); + stampPivotOnExport(square, node); + expect(node.getAttribute(ATTRIBUTE)).toBe(`${100 - CENTER.x},${100 - CENTER.y}`); + const imported = new paper.Path.Rectangle({point: [100, 100], size: [50, 50]}); + readPivotOnImport(node, imported); + expect(hasPivot(imported)).toBe(false); // still inert, nothing applied yet + + applyPivotData(imported); + expect(getPivot(imported).round()).toEqual(new paper.Point(100, 100)); +}); + +test('no anchor, no attribute, and old ones get wiped', () => { + const node = pathNode(`${ATTRIBUTE}="1,2"`); + + syncPivotData(square); + expect(square.data.persistedPivot).toBeUndefined(); + + stampPivotOnExport(square, node); + expect(node.hasAttribute(ATTRIBUTE)).toBe(false); +}); + +test('garbage in the attribute is ignored', () => { + for (const junk of ['nonsense', '1', '', 'a,b']) { + readPivotOnImport(pathNode(`${ATTRIBUTE}="${junk}"`), square); + applyPivotData(square); + expect(hasPivot(square)).toBe(false); + } +}); + +test('the whole layer round trips, group children and all', () => { + const child = new paper.Path.Rectangle({point: [300, 300], size: [20, 20]}); + layer.addChild(new paper.Group([child])); + + setPivot(square, square.bounds.topLeft); + setPivot(child, child.bounds.bottomRight); + syncPivotsFromItems(); + + const saved = [square.data.persistedPivot, child.data.persistedPivot]; + expect(saved[0]).toBeTruthy(); + expect(saved[1]).toBeTruthy(); + + square.pivot = null; + child.pivot = null; + expect(hasPivot(square)).toBe(false); + + square.data.persistedPivot = saved[0]; + child.data.persistedPivot = saved[1]; + restorePivotsToItems(); + + expect(getPivot(square).round()).toEqual(square.bounds.topLeft.round()); + expect(getPivot(child).round()).toEqual(child.bounds.bottomRight.round()); +}); + +test('guides are skipped', () => { + const guide = new paper.Path.Rectangle({point: [0, 0], size: [10, 10]}); + guide.guide = true; + setPivot(guide, new paper.Point(5, 5)); + + syncPivotsFromItems(); + expect(guide.data.persistedPivot).toBeUndefined(); +}); + +test('and it does not blow up without a painting layer', () => { + delete layer.data.isPaintingLayer; + setPivot(square, square.bounds.topLeft); + + expect(() => syncPivotsFromItems()).not.toThrow(); + expect(square.data.persistedPivot).toBeUndefined(); +});