UNPKG

tldraw

Version:

A tiny little drawing editor.

445 lines (383 loc) • 10.9 kB
import { atom, BaseBoxShapeUtil, Circle2d, createShapeId, Geometry2d, RecordProps, resizeBox, StateNode, T, TLEventHandlers, TLGeoShape, TLResizeInfo, TLShape, TLTextShape, toRichText, Vec, } from '@tldraw/editor' import { TestEditor } from './TestEditor' const CIRCLE_CLIP_TYPE = 'circle-clip' declare module '@tldraw/tlschema' { export interface TLGlobalShapePropsMap { [CIRCLE_CLIP_TYPE]: { w: number; h: number } } } // Custom Circle Clip Shape Definition export type CircleClipShape = TLShape<typeof CIRCLE_CLIP_TYPE> export const isClippingEnabled$ = atom('isClippingEnabled', true) // The stroke width used when rendering the circle const STROKE_WIDTH = 2 export class CircleClipShapeUtil extends BaseBoxShapeUtil<CircleClipShape> { static override type = CIRCLE_CLIP_TYPE static override props: RecordProps<CircleClipShape> = { w: T.number, h: T.number, } override canBind() { return false } override canReceiveNewChildrenOfType(shape: TLShape) { return !shape.isLocked } override getDefaultProps(): CircleClipShape['props'] { return { w: 200, h: 200, } } override getGeometry(shape: CircleClipShape): Geometry2d { const radius = Math.min(shape.props.w, shape.props.h) / 2 return new Circle2d({ radius, x: shape.props.w / 2 - radius, y: shape.props.h / 2 - radius, isFilled: true, }) } override getClipPath(shape: CircleClipShape): Vec[] | undefined { // Generate a polygon approximation of the circle. // We inset the clip path by half the stroke width so that children are // clipped to the inner edge of the stroke, not the center line. const centerX = shape.props.w / 2 const centerY = shape.props.h / 2 const outerRadius = Math.min(shape.props.w, shape.props.h) / 2 const clipRadius = outerRadius - STROKE_WIDTH / 2 const segments = 48 // More segments = smoother circle const points: Vec[] = [] for (let i = 0; i < segments; i++) { const angle = (i / segments) * Math.PI * 2 const x = centerX + Math.cos(angle) * clipRadius const y = centerY + Math.sin(angle) * clipRadius points.push(new Vec(x, y)) } return points } override shouldClipChild(_child: TLShape): boolean { // For now, clip all children - we removed the onlyClipText feature for simplicity return isClippingEnabled$.get() } override component(_shape: CircleClipShape) { // For testing purposes, we'll just return null // In a real implementation, this would return JSX return null as any } override indicator(_shape: CircleClipShape) { // For testing purposes, we'll just return null // In a real implementation, this would return JSX return null as any } override onResize(shape: CircleClipShape, info: TLResizeInfo<CircleClipShape>) { return resizeBox(shape, info) } } export class CircleClipShapeTool extends StateNode { static override id = 'circle-clip' override onEnter(): void { this.editor.setCursor({ type: 'cross', rotation: 0 }) } override onPointerDown(info: Parameters<TLEventHandlers['onPointerDown']>[0]) { if (info.target === 'canvas') { const originPagePoint = this.editor.inputs.getOriginPagePoint() this.editor.createShape({ type: CIRCLE_CLIP_TYPE, x: originPagePoint.x - 100, y: originPagePoint.y - 100, props: { w: 200, h: 200, }, }) } } } let editor: TestEditor afterEach(() => { editor?.dispose() }) const ids = { circleClip1: createShapeId('circleClip1'), circleClip2: createShapeId('circleClip2'), text1: createShapeId('text1'), geo1: createShapeId('geo1'), geo2: createShapeId('geo2'), } beforeEach(() => { editor = new TestEditor({ shapeUtils: [CircleClipShapeUtil], tools: [CircleClipShapeTool], }) // Reset clipping state isClippingEnabled$.set(true) }) describe('CircleClipShapeUtil', () => { describe('shape creation and properties', () => { it('should create a circle clip shape with default properties', () => { editor.createShape({ id: ids.circleClip1, type: CIRCLE_CLIP_TYPE, x: 100, y: 100, props: { w: 200, h: 200, }, }) const shape = editor.getShape<CircleClipShape>(ids.circleClip1) expect(shape).toBeDefined() expect(shape!.type).toBe('circle-clip') expect(shape!.props.w).toBe(200) expect(shape!.props.h).toBe(200) }) it('should use default props when not specified', () => { editor.createShape({ id: ids.circleClip1, type: CIRCLE_CLIP_TYPE, x: 100, y: 100, props: {}, }) const shape = editor.getShape<CircleClipShape>(ids.circleClip1) expect(shape!.props.w).toBe(200) // default from getDefaultProps expect(shape!.props.h).toBe(200) // default from getDefaultProps }) }) describe('geometry and clipping', () => { it('should generate correct circle geometry', () => { editor.createShape({ id: ids.circleClip1, type: CIRCLE_CLIP_TYPE, x: 100, y: 100, props: { w: 200, h: 200, }, }) const shape = editor.getShape<CircleClipShape>(ids.circleClip1) const util = editor.getShapeUtil<CircleClipShape>('circle-clip') const geometry = util.getGeometry(shape!) expect(geometry).toBeDefined() expect(geometry.bounds).toBeDefined() expect(geometry.bounds.width).toBe(200) expect(geometry.bounds.height).toBe(200) }) it('should generate clip path for circle', () => { editor.createShape({ id: ids.circleClip1, type: CIRCLE_CLIP_TYPE, x: 100, y: 100, props: { w: 200, h: 200, }, }) const shape = editor.getShape<CircleClipShape>(ids.circleClip1) const util = editor.getShapeUtil<CircleClipShape>('circle-clip') const clipPath = util.getClipPath?.(shape!) if (!clipPath) throw new Error('Clip path is undefined') expect(clipPath).toBeDefined() expect(Array.isArray(clipPath)).toBe(true) expect(clipPath.length).toBeGreaterThan(0) // Should be a polygon approximation of a circle // Check that points are roughly in a circle pattern // The clip path is inset by half the stroke width (STROKE_WIDTH / 2 = 1) const centerX = 100 // shape.props.w / 2 const centerY = 100 // shape.props.h / 2 const clipRadius = 99 // min(w, h) / 2 - STROKE_WIDTH / 2 = 100 - 1 clipPath.forEach((point) => { const distance = Math.sqrt(Math.pow(point.x - centerX, 2) + Math.pow(point.y - centerY, 2)) expect(distance).toBeCloseTo(clipRadius, 0) }) }) }) describe('child clipping behavior', () => { it('should clip children when clipping is enabled', () => { editor.createShape({ id: ids.circleClip1, type: CIRCLE_CLIP_TYPE, x: 100, y: 100, props: { w: 200, h: 200, }, }) editor.createShape({ id: ids.text1, type: 'text', x: 0, y: 0, parentId: ids.circleClip1, props: { richText: toRichText('Test text'), }, }) const util = editor.getShapeUtil<CircleClipShape>('circle-clip') const textShape = editor.getShape<TLTextShape>(ids.text1) // Clipping should be enabled by default expect(isClippingEnabled$.get()).toBe(true) expect(util.shouldClipChild?.(textShape!)).toBe(true) expect(editor.getShapeClipPath(ids.text1)).toBeDefined() }) it('should not clip children when clipping is disabled', () => { isClippingEnabled$.set(false) editor.createShape({ id: ids.circleClip1, type: CIRCLE_CLIP_TYPE, x: 100, y: 100, props: { w: 200, h: 200, }, }) editor.createShape({ id: ids.text1, type: 'text', x: 0, y: 0, parentId: ids.circleClip1, props: { richText: toRichText('Test text'), }, }) const util = editor.getShapeUtil<CircleClipShape>('circle-clip') const textShape = editor.getShape<TLTextShape>(ids.text1) expect(isClippingEnabled$.get()).toBe(false) expect(util.shouldClipChild?.(textShape!)).toBe(false) expect(editor.getShapeClipPath(ids.text1)).toBeUndefined() }) }) }) describe('Integration tests', () => { it('should create and manage circle clip shapes with children', () => { // Create circle clip shape editor.createShape({ id: ids.circleClip1, type: CIRCLE_CLIP_TYPE, x: 100, y: 100, props: { w: 200, h: 200, }, }) // Add text child editor.createShape({ id: ids.text1, type: 'text', x: 50, y: 50, parentId: ids.circleClip1, props: { richText: toRichText('Clipped text'), }, }) // Add geo child editor.createShape({ id: ids.geo1, type: 'geo', x: 150, y: 150, parentId: ids.circleClip1, props: { w: 50, h: 50, }, }) const circleClipShape = editor.getShape<CircleClipShape>(ids.circleClip1) const textShape = editor.getShape<TLTextShape>(ids.text1) const geoShape = editor.getShape<TLGeoShape>(ids.geo1) expect(circleClipShape).toBeDefined() expect(textShape!.parentId).toBe(ids.circleClip1) expect(geoShape!.parentId).toBe(ids.circleClip1) // Verify clipping behavior const util = editor.getShapeUtil<CircleClipShape>('circle-clip') expect(util.shouldClipChild?.(textShape!)).toBe(true) expect(util.shouldClipChild?.(geoShape!)).toBe(true) expect(editor.getShapeClipPath(ids.text1)).toBeDefined() expect(editor.getShapeClipPath(ids.geo1)).toBeDefined() // Test clipping toggle isClippingEnabled$.set(false) expect(util.shouldClipChild?.(textShape!)).toBe(false) expect(util.shouldClipChild?.(geoShape!)).toBe(false) expect(editor.getShapeClipPath(ids.text1)).toBeUndefined() expect(editor.getShapeClipPath(ids.geo1)).toBeUndefined() }) it('should handle multiple circle clip shapes independently', () => { // Create two circle clip shapes editor.createShape({ id: ids.circleClip1, type: CIRCLE_CLIP_TYPE, x: 100, y: 100, props: { w: 200, h: 200, }, }) editor.createShape({ id: ids.circleClip2, type: CIRCLE_CLIP_TYPE, x: 400, y: 100, props: { w: 150, h: 150, }, }) // Add children to both editor.createShape({ id: ids.text1, type: 'text', x: 0, y: 0, parentId: ids.circleClip1, props: { richText: toRichText('First clip'), }, }) editor.createShape({ id: ids.geo1, type: 'text', x: 0, y: 0, parentId: ids.circleClip2, props: { richText: toRichText('Second clip'), }, }) const util = editor.getShapeUtil<CircleClipShape>('circle-clip') const text1 = editor.getShape<TLTextShape>(ids.text1) const text2 = editor.getShape<TLTextShape>(ids.geo1) // Both should be clipped when enabled expect(util.shouldClipChild?.(text1!)).toBe(true) expect(util.shouldClipChild?.(text2!)).toBe(true) // Both should not be clipped when disabled isClippingEnabled$.set(false) expect(util.shouldClipChild?.(text1!)).toBe(false) expect(util.shouldClipChild?.(text2!)).toBe(false) }) })