|
| 1 | +import { createShapeId, Editor, TLShape, TLShapeId } from 'tldraw' |
| 2 | + |
| 3 | +const SIZE_TOLERANCE = 0.75 |
| 4 | +const RATIO_TOLERANCE = 0.01 |
| 5 | +const MAX_NORMALIZATION_PASSES = 3 |
| 6 | + |
| 7 | +export function arrangeGrid( |
| 8 | + editor: Editor, |
| 9 | + input: { |
| 10 | + shapeIds: string[] |
| 11 | + columns?: number |
| 12 | + gap?: number |
| 13 | + targetWidth?: number |
| 14 | + fitCamera?: boolean |
| 15 | + } |
| 16 | +) { |
| 17 | + if (editor.getIsReadonly()) throw new Error('The canvas is read-only.') |
| 18 | + |
| 19 | + const currentPageId = editor.getCurrentPageId() |
| 20 | + const shapes = input.shapeIds.map((simpleId) => { |
| 21 | + const shapeId = createShapeId(simpleId) |
| 22 | + const shape = editor.getShape(shapeId) |
| 23 | + if (!shape) throw new Error(`Shape ${simpleId} does not exist.`) |
| 24 | + if (shape.parentId !== currentPageId) { |
| 25 | + throw new Error(`Shape ${simpleId} must be a top-level shape on the current page.`) |
| 26 | + } |
| 27 | + |
| 28 | + const util = editor.getShapeUtil(shape) |
| 29 | + if (!util.canResize(shape)) throw new Error(`Shape ${simpleId} cannot be resized.`) |
| 30 | + const geometryBounds = editor.getShapeGeometry(shape).bounds |
| 31 | + return { |
| 32 | + simpleId, |
| 33 | + shapeId, |
| 34 | + shape, |
| 35 | + locked: util.isAspectRatioLocked(shape), |
| 36 | + ratio: geometryBounds.w / geometryBounds.h, |
| 37 | + } |
| 38 | + }) |
| 39 | + |
| 40 | + const initialBounds = shapes.map(({ shapeId, simpleId }) => ({ |
| 41 | + simpleId, |
| 42 | + shapeId, |
| 43 | + bounds: requireBounds(editor, shapeId, simpleId), |
| 44 | + })) |
| 45 | + const lockedRatios = shapes.filter(({ locked }) => locked).map(({ ratio }) => ratio) |
| 46 | + const lockedRatio = compatibleLockedRatio(lockedRatios) |
| 47 | + const ratios = shapes.map(({ ratio }) => ratio).sort((a, b) => a - b) |
| 48 | + const representativeRatio = lockedRatio ?? ratios[Math.floor(ratios.length / 2)] |
| 49 | + let targetWidth = input.targetWidth ?? 600 |
| 50 | + let targetHeight = targetWidth / representativeRatio |
| 51 | + let passes = 0 |
| 52 | + |
| 53 | + const readingOrder = initialBounds |
| 54 | + .slice() |
| 55 | + .sort((a, b) => a.bounds.y - b.bounds.y || a.bounds.x - b.bounds.x) |
| 56 | + const anchorX = Math.min(...initialBounds.map(({ bounds }) => bounds.x)) |
| 57 | + const anchorY = Math.min(...initialBounds.map(({ bounds }) => bounds.y)) |
| 58 | + const columns = Math.min(input.columns ?? Math.ceil(Math.sqrt(shapes.length)), shapes.length) |
| 59 | + const rows = Math.ceil(shapes.length / columns) |
| 60 | + const gap = input.gap ?? 50 |
| 61 | + |
| 62 | + editor.markHistoryStoppingPoint('arranging equal-size grid') |
| 63 | + editor.run(() => { |
| 64 | + for (const { shape } of shapes) { |
| 65 | + if (shape.rotation !== 0) { |
| 66 | + editor.updateShape({ id: shape.id, type: shape.type, rotation: 0 }) |
| 67 | + } |
| 68 | + } |
| 69 | + |
| 70 | + for (passes = 1; passes <= MAX_NORMALIZATION_PASSES; passes += 1) { |
| 71 | + resizeAll(editor, shapes, targetWidth, targetHeight) |
| 72 | + const actualBounds = shapes.map(({ shapeId, simpleId }) => |
| 73 | + requireBounds(editor, shapeId, simpleId) |
| 74 | + ) |
| 75 | + if (allBoundsMatch(actualBounds, targetWidth, targetHeight)) break |
| 76 | + |
| 77 | + if (lockedRatio) { |
| 78 | + targetWidth = Math.max( |
| 79 | + targetWidth, |
| 80 | + ...actualBounds.map((bounds) => bounds.w), |
| 81 | + ...actualBounds.map((bounds) => bounds.h * lockedRatio) |
| 82 | + ) |
| 83 | + targetHeight = targetWidth / lockedRatio |
| 84 | + } else { |
| 85 | + targetWidth = Math.max(targetWidth, ...actualBounds.map((bounds) => bounds.w)) |
| 86 | + targetHeight = Math.max(targetHeight, ...actualBounds.map((bounds) => bounds.h)) |
| 87 | + } |
| 88 | + } |
| 89 | + |
| 90 | + const normalizedBounds = shapes.map(({ shapeId, simpleId }) => |
| 91 | + requireBounds(editor, shapeId, simpleId) |
| 92 | + ) |
| 93 | + if (!allBoundsMatch(normalizedBounds, targetWidth, targetHeight)) { |
| 94 | + throw new Error('The selected shapes could not converge on one common size.') |
| 95 | + } |
| 96 | + |
| 97 | + for (const [index, item] of readingOrder.entries()) { |
| 98 | + const row = Math.floor(index / columns) |
| 99 | + const column = index % columns |
| 100 | + const bounds = requireBounds(editor, item.shapeId, item.simpleId) |
| 101 | + moveTopLevelShape( |
| 102 | + editor, |
| 103 | + editor.getShape(item.shapeId)!, |
| 104 | + anchorX + column * (targetWidth + gap) - bounds.x, |
| 105 | + anchorY + row * (targetHeight + gap) - bounds.y |
| 106 | + ) |
| 107 | + } |
| 108 | + }) |
| 109 | + |
| 110 | + if (input.fitCamera !== false) editor.zoomToFit() |
| 111 | + |
| 112 | + return { |
| 113 | + shapeIds: readingOrder.map(({ simpleId }) => simpleId), |
| 114 | + columns, |
| 115 | + rows, |
| 116 | + gap, |
| 117 | + width: targetWidth, |
| 118 | + height: targetHeight, |
| 119 | + normalizationPasses: passes, |
| 120 | + fitCamera: input.fitCamera !== false, |
| 121 | + rotationsNormalized: true, |
| 122 | + positions: readingOrder.map(({ simpleId, shapeId }) => { |
| 123 | + const bounds = requireBounds(editor, shapeId, simpleId) |
| 124 | + return { shapeId: simpleId, x: bounds.x, y: bounds.y, w: bounds.w, h: bounds.h } |
| 125 | + }), |
| 126 | + } |
| 127 | +} |
| 128 | + |
| 129 | +function resizeAll( |
| 130 | + editor: Editor, |
| 131 | + shapes: Array<{ shapeId: TLShapeId; simpleId: string }>, |
| 132 | + targetWidth: number, |
| 133 | + targetHeight: number |
| 134 | +) { |
| 135 | + for (const { shapeId, simpleId } of shapes) { |
| 136 | + const bounds = requireBounds(editor, shapeId, simpleId) |
| 137 | + editor.resizeShape( |
| 138 | + shapeId, |
| 139 | + { x: targetWidth / bounds.w, y: targetHeight / bounds.h }, |
| 140 | + { scaleOrigin: { x: bounds.x, y: bounds.y } } |
| 141 | + ) |
| 142 | + } |
| 143 | +} |
| 144 | + |
| 145 | +function moveTopLevelShape(editor: Editor, shape: TLShape, dx: number, dy: number) { |
| 146 | + editor.updateShape({ id: shape.id, type: shape.type, x: shape.x + dx, y: shape.y + dy }) |
| 147 | +} |
| 148 | + |
| 149 | +function requireBounds(editor: Editor, shapeId: TLShapeId, simpleId: string) { |
| 150 | + const bounds = editor.getShapePageBounds(shapeId) |
| 151 | + if (!bounds || bounds.w <= 0 || bounds.h <= 0) { |
| 152 | + throw new Error(`Shape ${simpleId} does not have measurable bounds.`) |
| 153 | + } |
| 154 | + return bounds |
| 155 | +} |
| 156 | + |
| 157 | +function compatibleLockedRatio(ratios: number[]) { |
| 158 | + if (ratios.length === 0) return null |
| 159 | + const reference = ratios[0] |
| 160 | + if (ratios.some((ratio) => Math.abs(ratio / reference - 1) > RATIO_TOLERANCE)) { |
| 161 | + throw new Error('Selected aspect-ratio-locked shapes have incompatible proportions.') |
| 162 | + } |
| 163 | + return ratios.reduce((total, ratio) => total + ratio, 0) / ratios.length |
| 164 | +} |
| 165 | + |
| 166 | +function allBoundsMatch( |
| 167 | + bounds: Array<{ w: number; h: number }>, |
| 168 | + targetWidth: number, |
| 169 | + targetHeight: number |
| 170 | +) { |
| 171 | + return bounds.every( |
| 172 | + (item) => |
| 173 | + Math.abs(item.w - targetWidth) <= SIZE_TOLERANCE && |
| 174 | + Math.abs(item.h - targetHeight) <= SIZE_TOLERANCE |
| 175 | + ) |
| 176 | +} |
0 commit comments