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/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-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/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/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/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/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(); +}); 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); + }); +}); 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()); +});