Add fit to content for frames. (#2275)
Adds Fit to content option for frames. This resizes the frames so that the whole content fits. It also adds 50px padding on all sides so that the content does not touch the frame's borders. https://github.com/tldraw/tldraw/assets/2523721/b2f86e31-7dfb-495f-ac31-f1e0125e0af1 https://github.com/tldraw/tldraw/assets/2523721/e0a73d25-ac9f-4a35-a1fd-4aed7a5b151c Fixes #1407 ### Change Type - [ ] `patch` — Bug fix - [x] `minor` — New feature - [ ] `major` — Breaking change - [ ] `dependencies` — Changes to package dependencies[^1] - [ ] `documentation` — Changes to the documentation only[^2] - [ ] `tests` — Changes to any test code only[^2] - [ ] `internal` — Any other changes that don't affect the published package[^2] - [ ] I don't know [^1]: publishes a `patch` release, for devDependencies use `internal` [^2]: will not publish a new version ### Test Plan 1. Add some shapes. 2. Add a frame that encloses those shapes. 3. Right click on the frame and choose `Fit to content` 4. The frame should resize to fit all the children with some padding on all sides of the frame. - [x] Unit Tests - [ ] End to end tests ### Release Notes - Add Fit to content option to the context menu for frames. This resizes the frames to correctly fit all their content. --------- Co-authored-by: David Sheldrick <d.j.sheldrick@gmail.com> Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
parent
0cf6a1e464
commit
300466f52a
14 changed files with 384 additions and 89 deletions
|
@ -36,6 +36,7 @@
|
|||
"action.export-as-png": "Export as PNG",
|
||||
"action.export-as-svg.short": "SVG",
|
||||
"action.export-as-svg": "Export as SVG",
|
||||
"action.fit-frame-to-content": "Fit to content",
|
||||
"action.flip-horizontal": "Flip horizontally",
|
||||
"action.flip-vertical": "Flip vertically",
|
||||
"action.flip-horizontal.short": "Flip H",
|
||||
|
|
|
@ -887,7 +887,6 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
registerExternalContentHandler<T extends TLExternalContent['type']>(type: T, handler: ((info: T extends TLExternalContent['type'] ? TLExternalContent & {
|
||||
type: T;
|
||||
} : TLExternalContent) => void) | null): this;
|
||||
removeFrame(ids: TLShapeId[]): this;
|
||||
renamePage(page: TLPage | TLPageId, name: string, historyOptions?: TLCommandHistoryOptions): this;
|
||||
// @deprecated (undocumented)
|
||||
get renderingBounds(): Box2d;
|
||||
|
|
|
@ -16390,59 +16390,6 @@
|
|||
"isAbstract": false,
|
||||
"name": "registerExternalContentHandler"
|
||||
},
|
||||
{
|
||||
"kind": "Method",
|
||||
"canonicalReference": "@tldraw/editor!Editor#removeFrame:member(1)",
|
||||
"docComment": "/**\n * Remove a frame.\n *\n * @param ids - Ids of the frames you wish to remove.\n *\n * @public\n */\n",
|
||||
"excerptTokens": [
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "removeFrame(ids: "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "TLShapeId",
|
||||
"canonicalReference": "@tldraw/tlschema!TLShapeId:type"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "[]"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "): "
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "this"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": ";"
|
||||
}
|
||||
],
|
||||
"isStatic": false,
|
||||
"returnTypeTokenRange": {
|
||||
"startIndex": 4,
|
||||
"endIndex": 5
|
||||
},
|
||||
"releaseTag": "Public",
|
||||
"isProtected": false,
|
||||
"overloadIndex": 1,
|
||||
"parameters": [
|
||||
{
|
||||
"parameterName": "ids",
|
||||
"parameterTypeTokenRange": {
|
||||
"startIndex": 1,
|
||||
"endIndex": 3
|
||||
},
|
||||
"isOptional": false
|
||||
}
|
||||
],
|
||||
"isOptional": false,
|
||||
"isAbstract": false,
|
||||
"name": "removeFrame"
|
||||
},
|
||||
{
|
||||
"kind": "Method",
|
||||
"canonicalReference": "@tldraw/editor!Editor#renamePage:member(1)",
|
||||
|
|
|
@ -7317,36 +7317,6 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a frame.
|
||||
*
|
||||
* @param ids - Ids of the frames you wish to remove.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
removeFrame(ids: TLShapeId[]): this {
|
||||
const frames = compact(
|
||||
ids
|
||||
.map((id) => this.getShape<TLFrameShape>(id))
|
||||
.filter((f) => f && this.isShapeOfType<TLFrameShape>(f, 'frame'))
|
||||
)
|
||||
if (!frames.length) return this
|
||||
|
||||
const allChildren: TLShapeId[] = []
|
||||
this.batch(() => {
|
||||
frames.map((frame) => {
|
||||
const children = this.getSortedChildIdsForParent(frame.id)
|
||||
if (children.length) {
|
||||
this.reparentShapes(children, frame.parentId, frame.index)
|
||||
allChildren.push(...children)
|
||||
}
|
||||
})
|
||||
this.setSelectedShapes(allChildren)
|
||||
this.deleteShapes(ids)
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a shape using a partial of the shape.
|
||||
*
|
||||
|
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -154,6 +154,7 @@ export { getEmbedInfo } from './lib/utils/embeds/embeds'
|
|||
export { copyAs } from './lib/utils/export/copyAs'
|
||||
export { getSvgAsImage } from './lib/utils/export/export'
|
||||
export { exportAs } from './lib/utils/export/exportAs'
|
||||
export { fitFrameToContent, removeFrame } from './lib/utils/frames/frames'
|
||||
export { setDefaultEditorAssetUrls } from './lib/utils/static-assets/assetUrls'
|
||||
export { truncateStringWithEllipsis } from './lib/utils/text/text'
|
||||
export {
|
||||
|
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from '@tldraw/editor'
|
||||
import * as React from 'react'
|
||||
import { getEmbedInfo } from '../../utils/embeds/embeds'
|
||||
import { fitFrameToContent, removeFrame } from '../../utils/frames/frames'
|
||||
import { EditLinkDialog } from '../components/EditLinkDialog'
|
||||
import { EmbedDialog } from '../components/EmbedDialog'
|
||||
import { TLUiIconType } from '../icon-types'
|
||||
|
@ -471,7 +472,25 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
|||
selectedShapes.every((shape) => editor.isShapeOfType<TLFrameShape>(shape, 'frame'))
|
||||
) {
|
||||
editor.mark('remove-frame')
|
||||
editor.removeFrame(selectedShapes.map((shape) => shape.id))
|
||||
removeFrame(
|
||||
editor,
|
||||
selectedShapes.map((shape) => shape.id)
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'fit-frame-to-content',
|
||||
label: 'action.fit-frame-to-content',
|
||||
readonlyOk: false,
|
||||
onSelect(source) {
|
||||
if (!hasSelectedShapes()) return
|
||||
|
||||
trackEvent('fit-frame-to-content', { source })
|
||||
const onlySelectedShape = editor.getOnlySelectedShape()
|
||||
if (onlySelectedShape && editor.isShapeOfType<TLFrameShape>(onlySelectedShape, 'frame')) {
|
||||
editor.mark('fit-frame-to-content')
|
||||
fitFrameToContent(editor, onlySelectedShape.id)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
@ -81,6 +81,10 @@ export const TLUiContextMenuSchemaProvider = track(function TLUiContextMenuSchem
|
|||
const allowRemoveFrame =
|
||||
oneSelected &&
|
||||
selectedShapes.every((shape) => editor.isShapeOfType<TLFrameShape>(shape, 'frame'))
|
||||
const allowFitFrameToContent =
|
||||
onlySelectedShape &&
|
||||
editor.isShapeOfType<TLFrameShape>(onlySelectedShape, 'frame') &&
|
||||
editor.getSortedChildIdsForParent(onlySelectedShape).length > 0
|
||||
const isShapeLocked = onlySelectedShape && editor.isShapeOrAncestorLocked(onlySelectedShape)
|
||||
|
||||
const contextTLUiMenuSchema = useMemo<TLUiMenuSchema>(() => {
|
||||
|
@ -93,6 +97,7 @@ export const TLUiContextMenuSchemaProvider = track(function TLUiContextMenuSchem
|
|||
allowGroup && !isShapeLocked && menuItem(actions['group']),
|
||||
allowUngroup && !isShapeLocked && menuItem(actions['ungroup']),
|
||||
allowRemoveFrame && !isShapeLocked && menuItem(actions['remove-frame']),
|
||||
allowFitFrameToContent && !isShapeLocked && menuItem(actions['fit-frame-to-content']),
|
||||
oneSelected && menuItem(actions['toggle-lock'])
|
||||
),
|
||||
menuGroup(
|
||||
|
@ -227,6 +232,7 @@ export const TLUiContextMenuSchemaProvider = track(function TLUiContextMenuSchem
|
|||
allowGroup,
|
||||
allowUngroup,
|
||||
allowRemoveFrame,
|
||||
allowFitFrameToContent,
|
||||
hasClipboardWrite,
|
||||
showEditLink,
|
||||
// oneEmbedSelected,
|
||||
|
|
|
@ -28,6 +28,7 @@ export interface TLUiEventMap {
|
|||
'group-shapes': null
|
||||
'ungroup-shapes': null
|
||||
'remove-frame': null
|
||||
'fit-frame-to-content': null
|
||||
'convert-to-embed': null
|
||||
'convert-to-bookmark': null
|
||||
'open-embed-link': null
|
||||
|
|
|
@ -40,6 +40,7 @@ export type TLUiTranslationKey =
|
|||
| 'action.export-as-png'
|
||||
| 'action.export-as-svg.short'
|
||||
| 'action.export-as-svg'
|
||||
| 'action.fit-frame-to-content'
|
||||
| 'action.flip-horizontal'
|
||||
| 'action.flip-vertical'
|
||||
| 'action.flip-horizontal.short'
|
||||
|
|
|
@ -40,6 +40,7 @@ export const DEFAULT_TRANSLATION = {
|
|||
'action.export-as-png': 'Export as PNG',
|
||||
'action.export-as-svg.short': 'SVG',
|
||||
'action.export-as-svg': 'Export as SVG',
|
||||
'action.fit-frame-to-content': 'Fit to content',
|
||||
'action.flip-horizontal': 'Flip horizontally',
|
||||
'action.flip-vertical': 'Flip vertically',
|
||||
'action.flip-horizontal.short': 'Flip H',
|
||||
|
|
101
packages/tldraw/src/lib/utils/frames/frames.ts
Normal file
101
packages/tldraw/src/lib/utils/frames/frames.ts
Normal file
|
@ -0,0 +1,101 @@
|
|||
import {
|
||||
Box2d,
|
||||
Editor,
|
||||
TLFrameShape,
|
||||
TLShapeId,
|
||||
TLShapePartial,
|
||||
Vec2d,
|
||||
compact,
|
||||
} from '@tldraw/editor'
|
||||
|
||||
/**
|
||||
* Remove a frame.
|
||||
*
|
||||
* @param editor - tlraw editor instance.
|
||||
* @param ids - Ids of the frames you wish to remove.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function removeFrame(editor: Editor, ids: TLShapeId[]) {
|
||||
const frames = compact(
|
||||
ids
|
||||
.map((id) => editor.getShape<TLFrameShape>(id))
|
||||
.filter((f) => f && editor.isShapeOfType<TLFrameShape>(f, 'frame'))
|
||||
)
|
||||
if (!frames.length) return
|
||||
|
||||
const allChildren: TLShapeId[] = []
|
||||
editor.batch(() => {
|
||||
frames.map((frame) => {
|
||||
const children = editor.getSortedChildIdsForParent(frame.id)
|
||||
if (children.length) {
|
||||
editor.reparentShapes(children, frame.parentId, frame.index)
|
||||
allChildren.push(...children)
|
||||
}
|
||||
})
|
||||
editor.setSelectedShapes(allChildren)
|
||||
editor.deleteShapes(ids)
|
||||
})
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export const DEFAULT_FRAME_PADDING = 50
|
||||
|
||||
/**
|
||||
* Fit a frame to its content.
|
||||
*
|
||||
* @param id - Id of the frame you wish to fit to content.
|
||||
* @param editor - tlraw editor instance.
|
||||
* @param opts - Options for fitting the frame.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function fitFrameToContent(editor: Editor, id: TLShapeId, opts = {} as { padding: number }) {
|
||||
const frame = editor.getShape<TLFrameShape>(id)
|
||||
if (!frame) return
|
||||
|
||||
const childIds = editor.getSortedChildIdsForParent(frame.id)
|
||||
const children = compact(childIds.map((id) => editor.getShape(id)))
|
||||
if (!children.length) return
|
||||
|
||||
const bounds = Box2d.FromPoints(
|
||||
children.flatMap((shape) => {
|
||||
const geometry = editor.getShapeGeometry(shape.id)
|
||||
return editor.getShapeLocalTransform(shape)!.applyToPoints(geometry.vertices)
|
||||
})
|
||||
)
|
||||
|
||||
const { padding = DEFAULT_FRAME_PADDING } = opts
|
||||
const w = bounds.w + 2 * padding
|
||||
const h = bounds.h + 2 * padding
|
||||
const dx = padding - bounds.minX
|
||||
const dy = padding - bounds.minY
|
||||
// The shapes already perfectly fit the frame.
|
||||
if (dx === 0 && dy === 0 && frame.props.w === w && frame.props.h === h) return
|
||||
|
||||
const diff = new Vec2d(dx, dy).rot(frame.rotation)
|
||||
editor.batch(() => {
|
||||
const changes: TLShapePartial[] = childIds.map((child) => {
|
||||
const shape = editor.getShape(child)!
|
||||
return {
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
x: shape.x + dx,
|
||||
y: shape.y + dy,
|
||||
}
|
||||
})
|
||||
|
||||
changes.push({
|
||||
id: frame.id,
|
||||
type: frame.type,
|
||||
x: frame.x - diff.x,
|
||||
y: frame.y - diff.y,
|
||||
props: {
|
||||
w,
|
||||
h,
|
||||
},
|
||||
})
|
||||
|
||||
editor.updateShapes(changes)
|
||||
})
|
||||
}
|
|
@ -5,6 +5,7 @@ import {
|
|||
TLShapeId,
|
||||
createShapeId,
|
||||
} from '@tldraw/editor'
|
||||
import { DEFAULT_FRAME_PADDING, fitFrameToContent, removeFrame } from '../lib/utils/frames/frames'
|
||||
import { TestEditor } from './TestEditor'
|
||||
|
||||
let editor: TestEditor
|
||||
|
@ -713,6 +714,70 @@ describe('frame shapes', () => {
|
|||
arrow = editor.getOnlySelectedShape()! as TLArrowShape
|
||||
expect(arrow.props.end).toMatchObject({ boundShapeId: innerBoxId })
|
||||
})
|
||||
|
||||
it('correctly fits to its content', () => {
|
||||
// Create two rects, their bounds are from [100, 100] to [400, 400],
|
||||
// so the frame that fits them (with 50px offset) should be from [50, 50] to [450, 450].
|
||||
const rectAId = createRect({ pos: [100, 100], size: [100, 100] })
|
||||
const rectBId = createRect({ pos: [300, 300], size: [100, 100] })
|
||||
|
||||
// Create the frame that encloses both rects
|
||||
const frameId = dragCreateFrame({ down: [0, 0], move: [700, 700], up: [700, 700] })
|
||||
const frame = editor.getShape(frameId)! as TLFrameShape
|
||||
|
||||
const rectA = editor.getShape(rectAId)!
|
||||
const rectB = editor.getShape(rectBId)!
|
||||
expect(rectA.parentId).toBe(frameId)
|
||||
expect(rectB.parentId).toBe(frameId)
|
||||
|
||||
fitFrameToContent(editor, frame.id)
|
||||
const newFrame = editor.getShape(frameId)! as TLFrameShape
|
||||
expect(newFrame.x).toBe(50)
|
||||
expect(newFrame.y).toBe(50)
|
||||
expect(newFrame.props.w).toBe(400)
|
||||
expect(newFrame.props.h).toBe(400)
|
||||
|
||||
const newRectA = editor.getShape(rectAId)!
|
||||
const newRectB = editor.getShape(rectBId)!
|
||||
// Rect positions should change by 50px since the frame moved
|
||||
// This keeps them in the same relative position
|
||||
expect(newRectA.x).toBe(DEFAULT_FRAME_PADDING)
|
||||
expect(newRectA.y).toBe(DEFAULT_FRAME_PADDING)
|
||||
expect(newRectB.x).toBe(250)
|
||||
expect(newRectB.y).toBe(250)
|
||||
})
|
||||
|
||||
it('uses padding option', () => {
|
||||
// Create two rects, their bounds are from [100, 100] to [400, 400],
|
||||
// so the frame that fits them (with 50px offset) should be from [50, 50] to [450, 450].
|
||||
const rectAId = createRect({ pos: [100, 100], size: [100, 100] })
|
||||
const rectBId = createRect({ pos: [300, 300], size: [100, 100] })
|
||||
|
||||
// Create the frame that encloses both rects
|
||||
const frameId = dragCreateFrame({ down: [0, 0], move: [700, 700], up: [700, 700] })
|
||||
const frame = editor.getShape(frameId)! as TLFrameShape
|
||||
|
||||
const rectA = editor.getShape(rectAId)!
|
||||
const rectB = editor.getShape(rectBId)!
|
||||
expect(rectA.parentId).toBe(frameId)
|
||||
expect(rectB.parentId).toBe(frameId)
|
||||
|
||||
fitFrameToContent(editor, frame.id, { padding: 100 })
|
||||
const newFrame = editor.getShape(frameId)! as TLFrameShape
|
||||
expect(newFrame.x).toBe(0)
|
||||
expect(newFrame.y).toBe(0)
|
||||
expect(newFrame.props.w).toBe(500)
|
||||
expect(newFrame.props.h).toBe(500)
|
||||
|
||||
const newRectA = editor.getShape(rectAId)!
|
||||
const newRectB = editor.getShape(rectBId)!
|
||||
|
||||
// frame is at 0,0 so positions should be the same for this test
|
||||
expect(newRectA.x).toBe(100)
|
||||
expect(newRectA.y).toBe(100)
|
||||
expect(newRectB.x).toBe(300)
|
||||
expect(newRectB.y).toBe(300)
|
||||
})
|
||||
})
|
||||
|
||||
test('arrows bound to a shape within a group within a frame are reparented if the group is moved outside of the frame', () => {
|
||||
|
@ -863,14 +928,14 @@ describe('When deleting/removing a frame', () => {
|
|||
it('removes a frame but not its children', () => {
|
||||
const rectId: TLShapeId = createRect({ size: [20, 20], pos: [10, 10] })
|
||||
const frameId = dragCreateFrame({ down: [10, 10], move: [100, 100], up: [100, 100] })
|
||||
editor.removeFrame([frameId])
|
||||
removeFrame(editor, [frameId])
|
||||
expect(editor.getShape(rectId)).toBeDefined()
|
||||
})
|
||||
it('reparents the children of a frame when removing it', () => {
|
||||
const rectId: TLShapeId = createRect({ size: [20, 20], pos: [10, 10] })
|
||||
const frame1Id = dragCreateFrame({ down: [10, 10], move: [100, 100], up: [100, 100] })
|
||||
const frame2Id = dragCreateFrame({ down: [0, 0], move: [110, 110], up: [110, 110] })
|
||||
editor.removeFrame([frame1Id])
|
||||
removeFrame(editor, [frame1Id])
|
||||
expect(editor.getShape(rectId)?.parentId).toBe(frame2Id)
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue