Files
odysseus/static/js/editor/document-geometry.js
T

576 lines
24 KiB
JavaScript

/**
* Document-wide geometry operations.
*
* Layer pixels live in layer coordinates, while AI/selection masks live in
* document coordinates. Keeping those spaces explicit here prevents crop and
* resize commands from updating the visible pixels while leaving masks and
* selections behind.
*/
import { flipTextLayer, rotateTextLayer, scaleTextLayer } from './text-layer.js';
import { flipShapeLayer, rotateShapeLayer, scaleShapeLayer } from './shape-layer.js';
import { renderPlacedLayer, transformPlacedData } from './placed-layer.js';
function makeCanvas(width, height) {
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(width));
canvas.height = Math.max(1, Math.round(height));
return canvas;
}
function renderWindow(source, x, y, width, height) {
const out = makeCanvas(width, height);
out.getContext('2d').drawImage(source, -x, -y);
return out;
}
function renderTranslated(source, width, height, x, y) {
const out = makeCanvas(width, height);
out.getContext('2d').drawImage(source, x, y);
return out;
}
function renderScaled(source, width, height, smoothingQuality = 'high') {
const out = makeCanvas(width, height);
const ctx = out.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = smoothingQuality;
ctx.drawImage(source, 0, 0, out.width, out.height);
return out;
}
function renderRotated(source, degrees) {
const normalized = ((degrees % 360) + 360) % 360;
const swap = normalized === 90 || normalized === 270;
const out = makeCanvas(swap ? source.height : source.width, swap ? source.width : source.height);
const ctx = out.getContext('2d');
ctx.translate(out.width / 2, out.height / 2);
ctx.rotate((normalized * Math.PI) / 180);
ctx.drawImage(source, -source.width / 2, -source.height / 2);
return out;
}
function renderFlipped(source, axis) {
const out = makeCanvas(source.width, source.height);
const ctx = out.getContext('2d');
ctx.save();
if (axis === 'h') {
ctx.translate(out.width, 0);
ctx.scale(-1, 1);
} else {
ctx.translate(0, out.height);
ctx.scale(1, -1);
}
ctx.drawImage(source, 0, 0);
ctx.restore();
return out;
}
function replaceCanvas(holder, rendered) {
holder.canvas.width = rendered.width;
holder.canvas.height = rendered.height;
holder.ctx = holder.canvas.getContext('2d');
holder.ctx.clearRect(0, 0, rendered.width, rendered.height);
holder.ctx.drawImage(rendered, 0, 0);
}
function replaceStandaloneCanvas(canvas, rendered) {
canvas.width = rendered.width;
canvas.height = rendered.height;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, rendered.width, rendered.height);
ctx.drawImage(rendered, 0, 0);
}
function invalidateLayerCaches(layer) {
layer._adjCache = null;
layer._adjCacheKey = null;
layer._adjFinal = null;
layer._adjFinalKey = null;
layer._stagedAdj = null;
layer._editingAdjId = null;
}
function syncActiveMask(editorState) {
const activeGroup = (editorState.layerGroups || []).find(group => group.id === editorState.activeGroupId);
const groupMask = activeGroup?.masks?.find(item => item.id === activeGroup.activeMaskId) || null;
if (groupMask) {
editorState.maskCanvas = groupMask.canvas;
editorState.maskCtx = groupMask.ctx;
return;
}
const active = editorState.layers.find(layer => layer.id === editorState.activeLayerId);
const mask = active?.masks?.find(item => item.id === active.activeMaskId) || null;
editorState.maskCanvas = mask?.canvas || null;
editorState.maskCtx = mask?.ctx || null;
}
function syncDocumentCanvas(editorState, width, height) {
editorState.imgWidth = width;
editorState.imgHeight = height;
if (editorState.mainCanvas) {
editorState.mainCanvas.width = width;
editorState.mainCanvas.height = height;
editorState.mainCtx = editorState.mainCanvas.getContext('2d');
}
editorState.documentCompositeCanvas = null;
editorState.compositeMaskUnion = null;
editorState.wandSrcCache = null;
syncActiveMask(editorState);
}
function normalizedGuideValues(values, limit, mapper = value => value) {
const output = [];
for (const raw of Array.isArray(values) ? values : []) {
const value = mapper(Number(raw));
if (!Number.isFinite(value) || value < 0 || value > limit) continue;
if (!output.some(existing => Math.abs(existing - value) < 0.0001)) output.push(value);
}
return output.sort((a, b) => a - b);
}
function transformGuides(editorState, vertical, horizontal) {
editorState.guides = {
vertical: normalizedGuideValues(vertical.values, vertical.limit, vertical.map),
horizontal: normalizedGuideValues(horizontal.values, horizontal.limit, horizontal.map),
};
}
function clipPolygonAxis(points, inside, intersect) {
if (!points.length) return [];
const output = [];
let previous = points[points.length - 1];
let previousInside = inside(previous);
for (const current of points) {
const currentInside = inside(current);
if (currentInside !== previousInside) output.push(intersect(previous, current));
if (currentInside) output.push(current);
previous = current;
previousInside = currentInside;
}
return output;
}
/** Clip a polygon to a document rectangle using Sutherland-Hodgman. */
export function clipPolygonToRect(points, width, height) {
let out = (points || []).map(point => ({ x: point.x, y: point.y }));
const xAt = (value) => (a, b) => {
const dx = b.x - a.x;
const t = dx === 0 ? 0 : (value - a.x) / dx;
return { x: value, y: a.y + (b.y - a.y) * t };
};
const yAt = (value) => (a, b) => {
const dy = b.y - a.y;
const t = dy === 0 ? 0 : (value - a.y) / dy;
return { x: a.x + (b.x - a.x) * t, y: value };
};
out = clipPolygonAxis(out, p => p.x >= 0, xAt(0));
out = clipPolygonAxis(out, p => p.x <= width, xAt(width));
out = clipPolygonAxis(out, p => p.y >= 0, yAt(0));
out = clipPolygonAxis(out, p => p.y <= height, yAt(height));
return out.length >= 3 ? out : [];
}
function normalizeCropRect(editorState, rect) {
const x = Math.max(0, Math.min(editorState.imgWidth - 1, Math.round(rect.x)));
const y = Math.max(0, Math.min(editorState.imgHeight - 1, Math.round(rect.y)));
const width = Math.max(1, Math.min(editorState.imgWidth - x, Math.round(rect.w)));
const height = Math.max(1, Math.min(editorState.imgHeight - y, Math.round(rect.h)));
return { x, y, width, height };
}
function forEachStoredSelection(editorState, callback) {
for (const selection of editorState.savedSelections || []) {
if (selection?.canvas) callback(selection.canvas);
}
if (editorState.lastSelection?.canvas) callback(editorState.lastSelection.canvas);
}
/** Crop layer pixels, masks, and transient selections as one document. */
export function cropDocument(editorState, rect) {
if (!editorState.imgWidth || !editorState.imgHeight) return null;
const oldWidth = editorState.imgWidth;
const oldHeight = editorState.imgHeight;
const { x, y, width, height } = normalizeCropRect(editorState, rect);
const oldOffsets = new Map(editorState.layerOffsets);
for (const layer of editorState.layers) {
const offset = oldOffsets.get(layer.id) || { x: 0, y: 0 };
const retainedText = layer.kind === 'text' && layer.text;
const retainedShape = layer.kind === 'shape' && layer.shape;
let placedOffset = null;
if (layer.kind === 'placed' && layer.placed) {
layer.placed = transformPlacedData(layer.placed, [1, 0, 0, 1, -x, -y]);
placedOffset = renderPlacedLayer(layer)?.offset || null;
} else if (!retainedText && !retainedShape) {
replaceCanvas(layer, renderWindow(layer.canvas, x - offset.x, y - offset.y, width, height));
}
for (const mask of layer.masks || []) {
const documentSpace = (mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'document';
if ((retainedText || retainedShape || layer.kind === 'placed') && !documentSpace) continue;
const maskOffset = mask.offset || { x: 0, y: 0 };
const sourceX = documentSpace ? x : x - offset.x - (Number(maskOffset.x) || 0);
const sourceY = documentSpace ? y : y - offset.y - (Number(maskOffset.y) || 0);
replaceCanvas(mask, renderWindow(mask.canvas, sourceX, sourceY, width, height));
mask.space = documentSpace ? 'document' : 'layer';
if (!documentSpace) mask.offset = { x: 0, y: 0 };
}
editorState.layerOffsets.set(layer.id, placedOffset || ((retainedText || retainedShape)
? { x: offset.x - x, y: offset.y - y }
: { x: 0, y: 0 }));
invalidateLayerCaches(layer);
}
for (const group of editorState.layerGroups || []) {
for (const mask of group.masks || []) replaceCanvas(mask, renderWindow(mask.canvas, x, y, width, height));
}
if (editorState.wandMask) {
const sourceLayerOffset = oldOffsets.get(editorState.wandLayerId) || { x: 0, y: 0 };
const documentSpace = editorState.wandMaskSpace === 'document' || (
!editorState.wandMaskSpace && editorState.wandMask.width === oldWidth && editorState.wandMask.height === oldHeight
);
const sourceX = documentSpace ? x : x - sourceLayerOffset.x;
const sourceY = documentSpace ? y : y - sourceLayerOffset.y;
replaceStandaloneCanvas(
editorState.wandMask,
renderWindow(editorState.wandMask, sourceX, sourceY, width, height),
);
}
forEachStoredSelection(editorState, canvas => {
replaceStandaloneCanvas(canvas, renderWindow(canvas, x, y, width, height));
});
if (editorState.wandLastSeed) {
const seed = { ...editorState.wandLastSeed, x: editorState.wandLastSeed.x - x, y: editorState.wandLastSeed.y - y };
editorState.wandLastSeed = seed.x >= 0 && seed.y >= 0 && seed.x < width && seed.y < height ? seed : null;
}
editorState.lassoPoints = clipPolygonToRect(
(editorState.lassoPoints || []).map(point => ({ x: point.x - x, y: point.y - y })),
width,
height,
);
const guides = editorState.guides || {};
transformGuides(
editorState,
{ values: guides.vertical, limit: width, map: value => value - x },
{ values: guides.horizontal, limit: height, map: value => value - y },
);
editorState.cropRect = null;
editorState.cropStart = null;
editorState.cropEnd = null;
syncDocumentCanvas(editorState, width, height);
return { x, y, width, height };
}
/** Resize the actual image content, including every layer-space/document-space mask. */
export function resizeImageDocument(editorState, width, height, options = {}) {
const newWidth = Math.max(1, Math.round(width));
const newHeight = Math.max(1, Math.round(height));
const oldWidth = editorState.imgWidth;
const oldHeight = editorState.imgHeight;
if (!oldWidth || !oldHeight) return null;
const scaleX = newWidth / oldWidth;
const scaleY = newHeight / oldHeight;
const quality = options.smoothingQuality || 'high';
for (const layer of editorState.layers) {
const targetLayerWidth = Math.max(1, Math.round(layer.canvas.width * scaleX));
const targetLayerHeight = Math.max(1, Math.round(layer.canvas.height * scaleY));
let placedOffset = null;
if (layer.kind === 'text' && layer.text) scaleTextLayer(layer, scaleX, scaleY);
else if (layer.kind === 'shape' && layer.shape) scaleShapeLayer(layer, scaleX, scaleY);
else if (layer.kind === 'placed' && layer.placed) {
layer.placed = transformPlacedData(layer.placed, [scaleX, 0, 0, scaleY, 0, 0]);
placedOffset = renderPlacedLayer(layer)?.offset || null;
} else replaceCanvas(layer, renderScaled(layer.canvas, targetLayerWidth, targetLayerHeight, quality));
const layerWidth = layer.canvas.width;
const layerHeight = layer.canvas.height;
for (const mask of layer.masks || []) {
const documentSpace = (mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'document';
const targetMaskWidth = documentSpace
? newWidth
: Math.max(1, Math.round(mask.canvas.width * scaleX));
const targetMaskHeight = documentSpace
? newHeight
: Math.max(1, Math.round(mask.canvas.height * scaleY));
replaceCanvas(
mask,
renderScaled(mask.canvas, targetMaskWidth, targetMaskHeight, quality),
);
mask.space = documentSpace ? 'document' : 'layer';
if (!documentSpace) {
mask.offset = {
x: Math.round((Number(mask.offset?.x) || 0) * scaleX),
y: Math.round((Number(mask.offset?.y) || 0) * scaleY),
};
}
}
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
editorState.layerOffsets.set(layer.id, placedOffset || {
x: Math.round(offset.x * scaleX),
y: Math.round(offset.y * scaleY),
});
invalidateLayerCaches(layer);
}
for (const group of editorState.layerGroups || []) {
for (const mask of group.masks || []) replaceCanvas(mask, renderScaled(mask.canvas, newWidth, newHeight, quality));
}
if (editorState.wandMask) {
replaceStandaloneCanvas(
editorState.wandMask,
renderScaled(
editorState.wandMask,
editorState.wandMaskSpace === 'document' ? newWidth : Math.max(1, Math.round(editorState.wandMask.width * scaleX)),
editorState.wandMaskSpace === 'document' ? newHeight : Math.max(1, Math.round(editorState.wandMask.height * scaleY)),
quality,
),
);
}
forEachStoredSelection(editorState, canvas => {
replaceStandaloneCanvas(canvas, renderScaled(canvas, newWidth, newHeight, quality));
});
if (editorState.wandLastSeed) {
editorState.wandLastSeed = {
...editorState.wandLastSeed,
x: editorState.wandLastSeed.x * scaleX,
y: editorState.wandLastSeed.y * scaleY,
};
}
editorState.lassoPoints = (editorState.lassoPoints || []).map(point => ({
x: point.x * scaleX,
y: point.y * scaleY,
}));
const guides = editorState.guides || {};
transformGuides(
editorState,
{ values: guides.vertical, limit: newWidth, map: value => value * scaleX },
{ values: guides.horizontal, limit: newHeight, map: value => value * scaleY },
);
syncDocumentCanvas(editorState, newWidth, newHeight);
return { width: newWidth, height: newHeight, scaleX, scaleY };
}
/** Change document bounds without resampling layer pixels. Origin stays top-left. */
export function resizeCanvasDocument(editorState, width, height, options = {}) {
const newWidth = Math.max(1, Math.round(width));
const newHeight = Math.max(1, Math.round(height));
const anchorX = Math.max(0, Math.min(1, Number(options.anchorX) || 0));
const anchorY = Math.max(0, Math.min(1, Number(options.anchorY) || 0));
const shiftX = Math.round((newWidth - editorState.imgWidth) * anchorX);
const shiftY = Math.round((newHeight - editorState.imgHeight) * anchorY);
for (const layer of editorState.layers) {
for (const mask of layer.masks || []) {
const documentSpace = (mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'document';
if (!documentSpace) continue;
replaceCanvas(mask, renderTranslated(mask.canvas, newWidth, newHeight, shiftX, shiftY));
mask.space = 'document';
}
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
editorState.layerOffsets.set(layer.id, {
x: (Number(offset.x) || 0) + shiftX,
y: (Number(offset.y) || 0) + shiftY,
});
}
for (const group of editorState.layerGroups || []) {
for (const mask of group.masks || []) replaceCanvas(mask, renderTranslated(mask.canvas, newWidth, newHeight, shiftX, shiftY));
}
if (editorState.wandMask && editorState.wandMaskSpace === 'document') {
replaceStandaloneCanvas(editorState.wandMask, renderTranslated(editorState.wandMask, newWidth, newHeight, shiftX, shiftY));
}
forEachStoredSelection(editorState, canvas => {
replaceStandaloneCanvas(canvas, renderTranslated(canvas, newWidth, newHeight, shiftX, shiftY));
});
editorState.lassoPoints = clipPolygonToRect(
(editorState.lassoPoints || []).map(point => ({ x: point.x + shiftX, y: point.y + shiftY })),
newWidth,
newHeight,
);
if (editorState.wandLastSeed) {
const seed = { ...editorState.wandLastSeed, x: editorState.wandLastSeed.x + shiftX, y: editorState.wandLastSeed.y + shiftY };
editorState.wandLastSeed = seed.x >= 0 && seed.y >= 0 && seed.x < newWidth && seed.y < newHeight ? seed : null;
}
const guides = editorState.guides || {};
transformGuides(
editorState,
{ values: guides.vertical, limit: newWidth, map: value => value + shiftX },
{ values: guides.horizontal, limit: newHeight, map: value => value + shiftY },
);
syncDocumentCanvas(editorState, newWidth, newHeight);
return { width: newWidth, height: newHeight };
}
function rotatedPoint(point, degrees, oldWidth, oldHeight) {
const normalized = ((degrees % 360) + 360) % 360;
if (normalized === 90) return { x: oldHeight - point.y, y: point.x };
if (normalized === 180) return { x: oldWidth - point.x, y: oldHeight - point.y };
if (normalized === 270) return { x: point.y, y: oldWidth - point.x };
return { x: point.x, y: point.y };
}
function rotatedOffset(offset, width, height, degrees, oldWidth, oldHeight) {
const normalized = ((degrees % 360) + 360) % 360;
if (normalized === 90) return { x: oldHeight - offset.y - height, y: offset.x };
if (normalized === 180) return { x: oldWidth - offset.x - width, y: oldHeight - offset.y - height };
if (normalized === 270) return { x: offset.y, y: oldWidth - offset.x - width };
return { x: offset.x, y: offset.y };
}
/** Rotate every layer, mask, offset, and active selection by a right angle. */
export function rotateDocument(editorState, degrees) {
const normalized = ((degrees % 360) + 360) % 360;
if (![90, 180, 270].includes(normalized)) return null;
const oldWidth = editorState.imgWidth;
const oldHeight = editorState.imgHeight;
const newWidth = normalized === 180 ? oldWidth : oldHeight;
const newHeight = normalized === 180 ? oldHeight : oldWidth;
for (const layer of editorState.layers) {
const oldLayerWidth = layer.canvas.width;
const oldLayerHeight = layer.canvas.height;
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
let placedOffset = null;
if (layer.kind === 'text' && layer.text) rotateTextLayer(layer, normalized);
else if (layer.kind === 'shape' && layer.shape) rotateShapeLayer(layer, normalized);
else if (layer.kind === 'placed' && layer.placed) {
const matrix = normalized === 90
? [0, 1, -1, 0, oldHeight, 0]
: normalized === 180
? [-1, 0, 0, -1, oldWidth, oldHeight]
: [0, -1, 1, 0, 0, oldWidth];
layer.placed = transformPlacedData(layer.placed, matrix);
placedOffset = renderPlacedLayer(layer)?.offset || null;
} else replaceCanvas(layer, renderRotated(layer.canvas, normalized));
for (const mask of layer.masks || []) {
const oldMaskWidth = mask.canvas.width;
const oldMaskHeight = mask.canvas.height;
const maskOffset = mask.offset || { x: 0, y: 0 };
replaceCanvas(mask, renderRotated(mask.canvas, normalized));
if ((mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'layer') {
const ox = Number(maskOffset.x) || 0;
const oy = Number(maskOffset.y) || 0;
if (normalized === 90) {
mask.offset = { x: oldLayerHeight - oy - oldMaskHeight, y: ox };
} else if (normalized === 180) {
mask.offset = { x: oldLayerWidth - ox - oldMaskWidth, y: oldLayerHeight - oy - oldMaskHeight };
} else {
mask.offset = { x: oy, y: oldLayerWidth - ox - oldMaskWidth };
}
}
}
editorState.layerOffsets.set(layer.id, placedOffset ||
rotatedOffset(offset, oldLayerWidth, oldLayerHeight, normalized, oldWidth, oldHeight));
invalidateLayerCaches(layer);
}
for (const group of editorState.layerGroups || []) {
for (const mask of group.masks || []) replaceCanvas(mask, renderRotated(mask.canvas, normalized));
}
if (editorState.wandMask) replaceStandaloneCanvas(editorState.wandMask, renderRotated(editorState.wandMask, normalized));
forEachStoredSelection(editorState, canvas => {
replaceStandaloneCanvas(canvas, renderRotated(canvas, normalized));
});
if (editorState.wandLastSeed) {
editorState.wandLastSeed = {
...editorState.wandLastSeed,
...rotatedPoint(editorState.wandLastSeed, normalized, oldWidth, oldHeight),
};
}
editorState.lassoPoints = (editorState.lassoPoints || []).map(point =>
rotatedPoint(point, normalized, oldWidth, oldHeight));
const guides = editorState.guides || {};
if (normalized === 90) {
transformGuides(
editorState,
{ values: guides.horizontal, limit: newWidth, map: value => oldHeight - value },
{ values: guides.vertical, limit: newHeight },
);
} else if (normalized === 180) {
transformGuides(
editorState,
{ values: guides.vertical, limit: newWidth, map: value => oldWidth - value },
{ values: guides.horizontal, limit: newHeight, map: value => oldHeight - value },
);
} else {
transformGuides(
editorState,
{ values: guides.horizontal, limit: newWidth },
{ values: guides.vertical, limit: newHeight, map: value => oldWidth - value },
);
}
syncDocumentCanvas(editorState, newWidth, newHeight);
return { width: newWidth, height: newHeight, degrees: normalized };
}
/** Flip every layer, mask, offset, and active selection across the document. */
export function flipDocument(editorState, axis) {
if (axis !== 'h' && axis !== 'v') return null;
const width = editorState.imgWidth;
const height = editorState.imgHeight;
for (const layer of editorState.layers) {
const layerWidth = layer.canvas.width;
const layerHeight = layer.canvas.height;
let placedOffset = null;
if (layer.kind === 'text' && layer.text) flipTextLayer(layer, axis);
else if (layer.kind === 'shape' && layer.shape) flipShapeLayer(layer, axis);
else if (layer.kind === 'placed' && layer.placed) {
const matrix = axis === 'h'
? [-1, 0, 0, 1, width, 0]
: [1, 0, 0, -1, 0, height];
layer.placed = transformPlacedData(layer.placed, matrix);
placedOffset = renderPlacedLayer(layer)?.offset || null;
} else replaceCanvas(layer, renderFlipped(layer.canvas, axis));
for (const mask of layer.masks || []) {
const maskWidth = mask.canvas.width;
const maskHeight = mask.canvas.height;
const maskOffset = mask.offset || { x: 0, y: 0 };
replaceCanvas(mask, renderFlipped(mask.canvas, axis));
if ((mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'layer') {
const ox = Number(maskOffset.x) || 0;
const oy = Number(maskOffset.y) || 0;
mask.offset = axis === 'h'
? { x: layerWidth - ox - maskWidth, y: oy }
: { x: ox, y: layerHeight - oy - maskHeight };
}
}
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
editorState.layerOffsets.set(layer.id, placedOffset || (axis === 'h'
? { x: width - offset.x - layerWidth, y: offset.y }
: { x: offset.x, y: height - offset.y - layerHeight }));
invalidateLayerCaches(layer);
}
for (const group of editorState.layerGroups || []) {
for (const mask of group.masks || []) replaceCanvas(mask, renderFlipped(mask.canvas, axis));
}
if (editorState.wandMask) replaceStandaloneCanvas(editorState.wandMask, renderFlipped(editorState.wandMask, axis));
forEachStoredSelection(editorState, canvas => {
replaceStandaloneCanvas(canvas, renderFlipped(canvas, axis));
});
if (editorState.wandLastSeed) {
editorState.wandLastSeed = {
...editorState.wandLastSeed,
x: axis === 'h' ? width - editorState.wandLastSeed.x : editorState.wandLastSeed.x,
y: axis === 'v' ? height - editorState.wandLastSeed.y : editorState.wandLastSeed.y,
};
}
editorState.lassoPoints = (editorState.lassoPoints || []).map(point => ({
x: axis === 'h' ? width - point.x : point.x,
y: axis === 'v' ? height - point.y : point.y,
}));
const guides = editorState.guides || {};
transformGuides(
editorState,
{
values: guides.vertical,
limit: width,
map: axis === 'h' ? value => width - value : value => value,
},
{
values: guides.horizontal,
limit: height,
map: axis === 'v' ? value => height - value : value => value,
},
);
syncDocumentCanvas(editorState, width, height);
return { width, height, axis };
}