diff --git a/packages/core/src/components/canvas/canvas.tsx b/packages/core/src/components/canvas/canvas.tsx index 421babf0c..30e04efac 100644 --- a/packages/core/src/components/canvas/canvas.tsx +++ b/packages/core/src/components/canvas/canvas.tsx @@ -30,6 +30,7 @@ interface CanvasProps> { hideBounds?: boolean hideHandles?: boolean hideIndicators?: boolean + externalContainerRef?: React.RefObject meta?: M id?: string } @@ -41,6 +42,7 @@ export function Canvas>({ users, userId, meta, + externalContainerRef, hideHandles = false, hideBounds = false, hideIndicators = false, @@ -53,7 +55,7 @@ export function Canvas>({ useResizeObserver(rCanvas) - useZoomEvents(pageState.camera.zoom, rCanvas) + useZoomEvents(pageState.camera.zoom, externalContainerRef || rCanvas) useSafariFocusOutFix() diff --git a/packages/core/src/components/page/page.tsx b/packages/core/src/components/page/page.tsx index 720fbcce7..9838c83e2 100644 --- a/packages/core/src/components/page/page.tsx +++ b/packages/core/src/components/page/page.tsx @@ -55,16 +55,6 @@ export const Page = React.memo(function Page ( ))} - {bounds && ( - - )} {!hideIndicators && selectedIds .filter(Boolean) @@ -79,6 +69,16 @@ export const Page = React.memo(function Page )} + {bounds && ( + + )} {!hideHandles && shapeWithHandles && } ) diff --git a/packages/core/src/components/renderer/renderer.tsx b/packages/core/src/components/renderer/renderer.tsx index bda8d320c..cf010fcce 100644 --- a/packages/core/src/components/renderer/renderer.tsx +++ b/packages/core/src/components/renderer/renderer.tsx @@ -20,6 +20,10 @@ export interface RendererProps /** * An object containing instances of your shape classes. */ @@ -93,6 +97,7 @@ export function Renderer diff --git a/packages/core/src/hooks/useZoomEvents.ts b/packages/core/src/hooks/useZoomEvents.ts index 4b15f5b6d..70d322c8f 100644 --- a/packages/core/src/hooks/useZoomEvents.ts +++ b/packages/core/src/hooks/useZoomEvents.ts @@ -6,7 +6,7 @@ import { useGesture } from '@use-gesture/react' import { Vec } from '@tldraw/vec' // Capture zoom gestures (pinches, wheels and pans) -export function useZoomEvents(zoom: number, ref: React.RefObject) { +export function useZoomEvents(zoom: number, ref: React.RefObject) { const rOriginPoint = React.useRef(undefined) const rPinchPoint = React.useRef(undefined) const rDelta = React.useRef([0, 0]) @@ -35,7 +35,9 @@ export function useZoomEvents(zoom: number, ref: React.RefObj { onWheel: ({ event: e, delta }) => { const elm = ref.current - if (!(e.target === elm || elm?.contains(e.target as Node))) return + + if (!elm || !(e.target === elm || elm.contains(e.target as Node))) return + e.preventDefault() if (inputs.isPinching) return @@ -47,7 +49,8 @@ export function useZoomEvents(zoom: number, ref: React.RefObj }, onPinchStart: ({ origin, event }) => { const elm = ref.current - if (!(event.target === elm || elm?.contains(event.target as Node))) return + + if (!elm || !(event.target === elm || elm.contains(event.target as Node))) return const info = inputs.pinch(origin, origin) inputs.isPinching = true @@ -93,7 +96,7 @@ export function useZoomEvents(zoom: number, ref: React.RefObj }, }, { - target: window, + target: ref, eventOptions: { passive: false }, pinch: { from: zoom, diff --git a/packages/core/src/shapes/createShape.tsx b/packages/core/src/shapes/createShape.tsx index 7565cf320..64a155afc 100644 --- a/packages/core/src/shapes/createShape.tsx +++ b/packages/core/src/shapes/createShape.tsx @@ -22,6 +22,8 @@ export const ShapeUtil = function , shape: T): TLBounds hitTest(this: TLShapeUtil, shape: T, point: number[]): boolean diff --git a/packages/core/src/utils/utils.ts b/packages/core/src/utils/utils.ts index 0a43aa327..b2c229c19 100644 --- a/packages/core/src/utils/utils.ts +++ b/packages/core/src/utils/utils.ts @@ -79,12 +79,11 @@ export class Utils { *``` */ - static lerpColor(color1: string, color2: string, factor = 0.5): string | undefined { + static lerpColor(color1: string, color2: string, factor = 0.5): string { function h2r(hex: string) { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) - return result - ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] - : null + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)! + return [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] } function r2h(rgb: number[]) { diff --git a/packages/dev/src/components/editor.tsx b/packages/dev/src/components/editor.tsx index fbfc83a01..00cc8ef4a 100644 --- a/packages/dev/src/components/editor.tsx +++ b/packages/dev/src/components/editor.tsx @@ -5,11 +5,11 @@ export default function Editor(props: TLDrawProps): JSX.Element { const rTLDrawState = React.useRef() const handleMount = React.useCallback((state: TLDrawState) => { + rTLDrawState.current = state + props.onMount?.(state) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore window.tlstate = state - rTLDrawState.current = state - props.onMount?.(state) }, []) return ( diff --git a/packages/tldraw/src/components/tldraw/tldraw.tsx b/packages/tldraw/src/components/tldraw/tldraw.tsx index 1082c7749..ba8db3d1b 100644 --- a/packages/tldraw/src/components/tldraw/tldraw.tsx +++ b/packages/tldraw/src/components/tldraw/tldraw.tsx @@ -19,14 +19,18 @@ import { PagePanel } from '~components/page-panel' import { Menu } from '~components/menu' import { breakpoints, iconButton } from '~components' import { DotFilledIcon } from '@radix-ui/react-icons' +import { TLDR } from '~state/tldr' // Selectors const isInSelectSelector = (s: Data) => s.appState.activeTool === 'select' -const isSelectedShapeWithHandlesSelector = (s: Data) => { +const isHideBoundsShapeSelector = (s: Data) => { const { shapes } = s.document.pages[s.appState.currentPageId] const { selectedIds } = s.document.pageStates[s.appState.currentPageId] - return selectedIds.length === 1 && selectedIds.every((id) => shapes[id].handles !== undefined) + return ( + selectedIds.length === 1 && + selectedIds.every((id) => !TLDR.getShapeUtils(shapes[id].type).showBounds) + ) } const pageSelector = (s: Data) => s.document.pages[s.appState.currentPageId] @@ -106,7 +110,6 @@ export function TLDraw({ }, [sId, id]) // Use the `key` to ensure that new selector hooks are made when the id changes - return ( @@ -157,7 +160,7 @@ function InnerTldraw({ const isSelecting = useSelector(isInSelectSelector) - const isSelectedHandlesShape = useSelector(isSelectedShapeWithHandlesSelector) + const isHideBoundsShape = useSelector(isHideBoundsShapeSelector) const isInSession = tlstate.session !== undefined @@ -165,7 +168,7 @@ function InnerTldraw({ const hideBounds = (isInSession && tlstate.session?.constructor.name !== 'BrushSession') || !isSelecting || - isSelectedHandlesShape || + isHideBoundsShape || !!pageState.editingId // Hide bounds when not using the select tool, or when in session @@ -215,6 +218,7 @@ function InnerTldraw({ s.appState.activeTool + +export const PrimaryTools = React.memo((): JSX.Element => { + const { tlstate, useSelector } = useTLDrawContext() + + const activeTool = useSelector(activeToolSelector) + + const selectDrawTool = React.useCallback(() => { + tlstate.selectTool(TLDrawShapeType.Draw) + }, [tlstate]) + + const selectRectangleTool = React.useCallback(() => { + tlstate.selectTool(TLDrawShapeType.Rectangle) + }, [tlstate]) + + const selectEllipseTool = React.useCallback(() => { + tlstate.selectTool(TLDrawShapeType.Ellipse) + }, [tlstate]) + + const selectArrowTool = React.useCallback(() => { + tlstate.selectTool(TLDrawShapeType.Arrow) + }, [tlstate]) + + const selectTextTool = React.useCallback(() => { + tlstate.selectTool(TLDrawShapeType.Text) + }, [tlstate]) + + const selectStickyTool = React.useCallback(() => { + tlstate.selectTool(TLDrawShapeType.Sticky) + }, [tlstate]) + + return ( +
+ + + + + + + + + + + + + + + + + + +
+ ) +}) diff --git a/packages/tldraw/src/components/tools-panel/status-bar/index.ts b/packages/tldraw/src/components/tools-panel/status-bar/index.ts new file mode 100644 index 000000000..f9d6aa7b1 --- /dev/null +++ b/packages/tldraw/src/components/tools-panel/status-bar/index.ts @@ -0,0 +1 @@ +export * from './status-bar' diff --git a/packages/tldraw/src/components/tools-panel/status-bar.tsx b/packages/tldraw/src/components/tools-panel/status-bar/status-bar.tsx similarity index 100% rename from packages/tldraw/src/components/tools-panel/status-bar.tsx rename to packages/tldraw/src/components/tools-panel/status-bar/status-bar.tsx diff --git a/packages/tldraw/src/components/tools-panel/tools-panel.tsx b/packages/tldraw/src/components/tools-panel/tools-panel.tsx index e17f3bd3e..a4ac0ab84 100644 --- a/packages/tldraw/src/components/tools-panel/tools-panel.tsx +++ b/packages/tldraw/src/components/tools-panel/tools-panel.tsx @@ -1,23 +1,15 @@ import * as React from 'react' -import { - ArrowTopRightIcon, - CircleIcon, - CursorArrowIcon, - LockClosedIcon, - LockOpen1Icon, - Pencil1Icon, - SquareIcon, - TextIcon, -} from '@radix-ui/react-icons' +import { CursorArrowIcon, LockClosedIcon, LockOpen1Icon } from '@radix-ui/react-icons' import css from '~styles' -import { Data, TLDrawShapeType } from '~types' +import type { Data } from '~types' import { useTLDrawContext } from '~hooks' -import { StatusBar } from './status-bar' -import { floatingContainer } from '../shared' -import { PrimaryButton, SecondaryButton } from './styled' -import { UndoRedo } from './undo-redo' -import { Zoom } from './zoom' -import { BackToContent } from './back-to-content' +import { floatingContainer } from '~components/shared' +import { StatusBar } from '~components/tools-panel/status-bar' +import { SecondaryButton } from '~components/tools-panel/styled' +import { UndoRedo } from '~components/tools-panel/undo-redo' +import { Zoom } from '~components/tools-panel/zoom' +import { BackToContent } from '~components/tools-panel/back-to-content' +import { PrimaryTools } from '~components/tools-panel/primary-tools' const activeToolSelector = (s: Data) => s.appState.activeTool const isToolLockedSelector = (s: Data) => s.appState.isToolLocked @@ -36,26 +28,6 @@ export const ToolsPanel = React.memo((): JSX.Element => { tlstate.selectTool('select') }, [tlstate]) - const selectDrawTool = React.useCallback(() => { - tlstate.selectTool(TLDrawShapeType.Draw) - }, [tlstate]) - - const selectRectangleTool = React.useCallback(() => { - tlstate.selectTool(TLDrawShapeType.Rectangle) - }, [tlstate]) - - const selectEllipseTool = React.useCallback(() => { - tlstate.selectTool(TLDrawShapeType.Ellipse) - }, [tlstate]) - - const selectArrowTool = React.useCallback(() => { - tlstate.selectTool(TLDrawShapeType.Arrow) - }, [tlstate]) - - const selectTextTool = React.useCallback(() => { - tlstate.selectTool(TLDrawShapeType.Text) - }, [tlstate]) - return (
@@ -73,48 +45,7 @@ export const ToolsPanel = React.memo((): JSX.Element => {
-
- - - - - - - - - - - - - - - -
+
{ const { tlstate } = useTLDrawContext() diff --git a/packages/tldraw/src/components/tools-panel/zoom/index.ts b/packages/tldraw/src/components/tools-panel/zoom/index.ts new file mode 100644 index 000000000..7d9ae51b7 --- /dev/null +++ b/packages/tldraw/src/components/tools-panel/zoom/index.ts @@ -0,0 +1 @@ +export * from './zoom' diff --git a/packages/tldraw/src/components/tools-panel/zoom.tsx b/packages/tldraw/src/components/tools-panel/zoom/zoom.tsx similarity index 92% rename from packages/tldraw/src/components/tools-panel/zoom.tsx rename to packages/tldraw/src/components/tools-panel/zoom/zoom.tsx index d64e84577..656edf3fb 100644 --- a/packages/tldraw/src/components/tools-panel/zoom.tsx +++ b/packages/tldraw/src/components/tools-panel/zoom/zoom.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { ZoomInIcon, ZoomOutIcon } from '@radix-ui/react-icons' -import { TertiaryButton, tertiaryButtonsContainer } from './styled' +import { TertiaryButton, tertiaryButtonsContainer } from '~components/tools-panel/styled' import { useTLDrawContext } from '~hooks' import type { Data } from '~types' diff --git a/packages/tldraw/src/hooks/useKeyboardShortcuts.tsx b/packages/tldraw/src/hooks/useKeyboardShortcuts.tsx index 45b4e0527..ed4eb437e 100644 --- a/packages/tldraw/src/hooks/useKeyboardShortcuts.tsx +++ b/packages/tldraw/src/hooks/useKeyboardShortcuts.tsx @@ -66,6 +66,15 @@ export function useKeyboardShortcuts(ref: React.RefObject) { [tlstate] ) + useHotkeys( + 'n,7', + () => { + if (canHandleEvent()) tlstate.selectTool(TLDrawShapeType.Sticky) + }, + undefined, + [tlstate] + ) + /* ---------------------- Misc ---------------------- */ // Dark Mode diff --git a/packages/tldraw/src/shape/shape-styles.ts b/packages/tldraw/src/shape/shape-styles.ts index f861a26fe..fd693cab9 100644 --- a/packages/tldraw/src/shape/shape-styles.ts +++ b/packages/tldraw/src/shape/shape-styles.ts @@ -20,6 +20,26 @@ const colors = { [ColorStyle.Yellow]: '#ffc936', } +export const stickyFills: Record> = { + light: { + ...(Object.fromEntries( + Object.entries(colors).map(([k, v]) => [k, Utils.lerpColor(v, canvasLight, 0.45)]) + ) as Record), + [ColorStyle.White]: '#ffffff', + [ColorStyle.Black]: '#3d3d3d', + }, + dark: { + ...(Object.fromEntries( + Object.entries(colors).map(([k, v]) => [ + k, + Utils.lerpColor(Utils.lerpColor(v, '#999999', 0.3), canvasDark, 0.4), + ]) + ) as Record), + [ColorStyle.White]: '#bbbbbb', + [ColorStyle.Black]: '#1d1d1d', + }, +} + export const strokes: Record> = { light: colors, dark: { @@ -57,6 +77,13 @@ const fontSizes = { auto: 'auto', } +const stickyFontSizes = { + [SizeStyle.Small]: 24, + [SizeStyle.Medium]: 36, + [SizeStyle.Large]: 48, + auto: 'auto', +} + export function getStrokeWidth(size: SizeStyle): number { return strokeWidths[size] } @@ -65,6 +92,10 @@ export function getFontSize(size: SizeStyle): number { return fontSizes[size] } +export function getStickyFontSize(size: SizeStyle): number { + return stickyFontSizes[size] +} + export function getFontStyle(style: ShapeStyles): string { const fontSize = getFontSize(style.size) const { scale = 1 } = style @@ -72,6 +103,26 @@ export function getFontStyle(style: ShapeStyles): string { return `${fontSize * scale}px/1.3 "Caveat Brush"` } +export function getStickyFontStyle(style: ShapeStyles): string { + const fontSize = getStickyFontSize(style.size) + const { scale = 1 } = style + + return `${fontSize * scale}px/1.3 "Caveat Brush"` +} + +export function getStickyShapeStyle(style: ShapeStyles, isDarkMode = false) { + const { color } = style + + const theme: Theme = isDarkMode ? 'dark' : 'light' + const adjustedColor = color === ColorStyle.Black ? ColorStyle.Yellow : color + + return { + fill: stickyFills[theme][adjustedColor], + stroke: strokes[theme][adjustedColor], + color: isDarkMode ? '#1d1d1d' : '#0d0d0d', + } +} + export function getShapeStyle( style: ShapeStyles, isDarkMode = false diff --git a/packages/tldraw/src/shape/shape-utils.tsx b/packages/tldraw/src/shape/shape-utils.tsx index e3a3f78f9..c5396dd7b 100644 --- a/packages/tldraw/src/shape/shape-utils.tsx +++ b/packages/tldraw/src/shape/shape-utils.tsx @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { Rectangle, Ellipse, Arrow, Draw, Text, Group, PostIt } from './shapes' +import { Rectangle, Ellipse, Arrow, Draw, Text, Group, Sticky } from './shapes' import { TLDrawShapeType, TLDrawShape, TLDrawShapeUtil } from '~types' // This is a bad "any", but the "this" context stuff we're doing doesn't allow us to union the types @@ -10,7 +10,7 @@ export const tldrawShapeUtils: Record = { [TLDrawShapeType.Arrow]: Arrow, [TLDrawShapeType.Text]: Text, [TLDrawShapeType.Group]: Group, - [TLDrawShapeType.PostIt]: PostIt, + [TLDrawShapeType.Sticky]: Sticky, } export function getShapeUtils(type: T['type']) { diff --git a/packages/tldraw/src/shape/shapes/arrow/arrow.tsx b/packages/tldraw/src/shape/shapes/arrow/arrow.tsx index e549bd3ad..3fb2502ad 100644 --- a/packages/tldraw/src/shape/shapes/arrow/arrow.tsx +++ b/packages/tldraw/src/shape/shapes/arrow/arrow.tsx @@ -27,6 +27,8 @@ export const Arrow = new ShapeUtil(() => canStyleFill: false, + showBounds: false, + pathCache: new WeakMap(), defaultProps: { diff --git a/packages/tldraw/src/shape/shapes/ellipse/ellipse.tsx b/packages/tldraw/src/shape/shapes/ellipse/ellipse.tsx index 020da01bd..d028b13c6 100644 --- a/packages/tldraw/src/shape/shapes/ellipse/ellipse.tsx +++ b/packages/tldraw/src/shape/shapes/ellipse/ellipse.tsx @@ -3,7 +3,7 @@ import { SVGContainer, Utils, ShapeUtil, TLTransformInfo, TLBounds } from '@tldr import { Vec } from '@tldraw/vec' import { DashStyle, EllipseShape, TLDrawShapeType, TLDrawMeta } from '~types' import { defaultStyle, getPerfectDashProps, getShapeStyle } from '~shape/shape-styles' -import getStroke, { getStrokeOutlinePoints, getStrokePoints } from 'perfect-freehand' +import { getStrokeOutlinePoints, getStrokePoints } from 'perfect-freehand' import { intersectBoundsEllipse, intersectLineSegmentEllipse, diff --git a/packages/tldraw/src/shape/shapes/index.ts b/packages/tldraw/src/shape/shapes/index.ts index 7430dd01e..a2c034bdd 100644 --- a/packages/tldraw/src/shape/shapes/index.ts +++ b/packages/tldraw/src/shape/shapes/index.ts @@ -4,4 +4,4 @@ export * from './rectangle' export * from './ellipse' export * from './text' export * from './group' -export * from './post-it' +export * from './sticky' diff --git a/packages/tldraw/src/shape/shapes/post-it/index.ts b/packages/tldraw/src/shape/shapes/post-it/index.ts deleted file mode 100644 index bcc5c46a8..000000000 --- a/packages/tldraw/src/shape/shapes/post-it/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './post-it' diff --git a/packages/tldraw/src/shape/shapes/post-it/post-it.spec.tsx b/packages/tldraw/src/shape/shapes/post-it/post-it.spec.tsx deleted file mode 100644 index 3017ef947..000000000 --- a/packages/tldraw/src/shape/shapes/post-it/post-it.spec.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { PostIt } from './post-it' - -describe('Post-It shape', () => { - it('Creates a shape', () => { - expect(PostIt.create).toBeDefined() - // expect(PostIt.create({ id: 'postit' })).toMatchSnapshot('postit') - }) -}) diff --git a/packages/tldraw/src/shape/shapes/post-it/post-it.tsx b/packages/tldraw/src/shape/shapes/post-it/post-it.tsx deleted file mode 100644 index 6a1be86ca..000000000 --- a/packages/tldraw/src/shape/shapes/post-it/post-it.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import * as React from 'react' -import { HTMLContainer, ShapeUtil } from '@tldraw/core' -import { defaultStyle, getShapeStyle } from '~shape/shape-styles' -import { PostItShape, TLDrawMeta, TLDrawShapeType } from '~types' -import { getBoundsRectangle, transformRectangle, transformSingleRectangle } from '../shared' - -export const PostIt = new ShapeUtil(() => ({ - type: TLDrawShapeType.PostIt, - - canBind: true, - - pathCache: new WeakMap([]), - - defaultProps: { - id: 'id', - type: TLDrawShapeType.PostIt, - name: 'PostIt', - parentId: 'page', - childIndex: 1, - point: [0, 0], - size: [1, 1], - text: '', - rotation: 0, - style: defaultStyle, - }, - - shouldRender(prev, next) { - return next.size !== prev.size || next.style !== prev.style - }, - - Component({ events }, ref) { - const [count, setCount] = React.useState(0) - - return ( - -
-
e.preventDefault()}> - e.stopPropagation()} - /> - -
-
-
- ) - }, - - Indicator({ shape }) { - const { - style, - size: [width, height], - } = shape - - const styles = getShapeStyle(style, false) - const strokeWidth = +styles.strokeWidth - - const sw = strokeWidth - - return ( - - ) - }, - - getBounds(shape) { - return getBoundsRectangle(shape, this.boundsCache) - }, - - transform: transformRectangle, - - transformSingle: transformSingleRectangle, -})) diff --git a/packages/tldraw/src/shape/shapes/shared.tsx b/packages/tldraw/src/shape/shapes/shared.tsx index 6d362d015..ad4fda5d9 100644 --- a/packages/tldraw/src/shape/shapes/shared.tsx +++ b/packages/tldraw/src/shape/shapes/shared.tsx @@ -81,3 +81,175 @@ export function getBoundsRectangle( return Utils.translateBounds(bounds, shape.point) } + +// Adapted (mostly copied) the work of https://github.com/fregante +// Copyright (c) Federico Brigante (bfred.it) + +type ReplacerCallback = (substring: string, ...args: any[]) => string + +const INDENT = ' ' + +export class TextAreaUtils { + static insertTextFirefox(field: HTMLTextAreaElement | HTMLInputElement, text: string): void { + // Found on https://www.everythingfrontend.com/posts/insert-text-into-textarea-at-cursor-position.html 🎈 + field.setRangeText( + text, + field.selectionStart || 0, + field.selectionEnd || 0, + 'end' // Without this, the cursor is either at the beginning or `text` remains selected + ) + + field.dispatchEvent( + new InputEvent('input', { + data: text, + inputType: 'insertText', + isComposing: false, // TODO: fix @types/jsdom, this shouldn't be required + }) + ) + } + + /** Inserts `text` at the cursor’s position, replacing any selection, with **undo** support and by firing the `input` event. */ + static insert(field: HTMLTextAreaElement | HTMLInputElement, text: string): void { + const document = field.ownerDocument + const initialFocus = document.activeElement + if (initialFocus !== field) { + field.focus() + } + + if (!document.execCommand('insertText', false, text)) { + TextAreaUtils.insertTextFirefox(field, text) + } + + if (initialFocus === document.body) { + field.blur() + } else if (initialFocus instanceof HTMLElement && initialFocus !== field) { + initialFocus.focus() + } + } + + /** Replaces the entire content, equivalent to `field.value = text` but with **undo** support and by firing the `input` event. */ + static set(field: HTMLTextAreaElement | HTMLInputElement, text: string): void { + field.select() + TextAreaUtils.insert(field, text) + } + + /** Get the selected text in a field or an empty string if nothing is selected. */ + static getSelection(field: HTMLTextAreaElement | HTMLInputElement): string { + const { selectionStart, selectionEnd } = field + return field.value.slice( + selectionStart ? selectionStart : undefined, + selectionEnd ? selectionEnd : undefined + ) + } + + /** Adds the `wrappingText` before and after field’s selection (or cursor). If `endWrappingText` is provided, it will be used instead of `wrappingText` at on the right. */ + static wrapSelection( + field: HTMLTextAreaElement | HTMLInputElement, + wrap: string, + wrapEnd?: string + ): void { + const { selectionStart, selectionEnd } = field + const selection = TextAreaUtils.getSelection(field) + TextAreaUtils.insert(field, wrap + selection + (wrapEnd ?? wrap)) + + // Restore the selection around the previously-selected text + field.selectionStart = (selectionStart || 0) + wrap.length + field.selectionEnd = (selectionEnd || 0) + wrap.length + } + + /** Finds and replaces strings and regex in the field’s value, like `field.value = field.value.replace()` but better */ + static replace( + field: HTMLTextAreaElement | HTMLInputElement, + searchValue: string | RegExp, + replacer: string | ReplacerCallback + ): void { + /** Remembers how much each match offset should be adjusted */ + let drift = 0 + + field.value.replace(searchValue, (...args): string => { + // Select current match to replace it later + const matchStart = drift + (args[args.length - 2] as number) + const matchLength = args[0].length + field.selectionStart = matchStart + field.selectionEnd = matchStart + matchLength + + const replacement = typeof replacer === 'string' ? replacer : replacer(...args) + TextAreaUtils.insert(field, replacement) + + // Select replacement. Without this, the cursor would be after the replacement + field.selectionStart = matchStart + drift += replacement.length - matchLength + return replacement + }) + } + + static findLineEnd(value: string, currentEnd: number): number { + // Go to the beginning of the last line + const lastLineStart = value.lastIndexOf('\n', currentEnd - 1) + 1 + + // There's nothing to unindent after the last cursor, so leave it as is + if (value.charAt(lastLineStart) !== '\t') { + return currentEnd + } + + return lastLineStart + 1 // Include the first character, which will be a tab + } + + static indent(element: HTMLTextAreaElement): void { + const { selectionStart, selectionEnd, value } = element + const selectedText = value.slice(selectionStart, selectionEnd) + // The first line should be indented, even if it starts with `\n` + // The last line should only be indented if includes any character after `\n` + const lineBreakCount = /\n/g.exec(selectedText)?.length + + if (lineBreakCount && lineBreakCount > 0) { + // Select full first line to replace everything at once + const firstLineStart = value.lastIndexOf('\n', selectionStart - 1) + 1 + + const newSelection = element.value.slice(firstLineStart, selectionEnd - 1) + const indentedText = newSelection.replace( + /^|\n/g, // Match all line starts + `$&${INDENT}` + ) + const replacementsCount = indentedText.length - newSelection.length + + // Replace newSelection with indentedText + element.setSelectionRange(firstLineStart, selectionEnd - 1) + TextAreaUtils.insert(element, indentedText) + + // Restore selection position, including the indentation + element.setSelectionRange(selectionStart + 1, selectionEnd + replacementsCount) + } else { + TextAreaUtils.insert(element, INDENT) + } + } + + // The first line should always be unindented + // The last line should only be unindented if the selection includes any characters after `\n` + static unindent(element: HTMLTextAreaElement): void { + const { selectionStart, selectionEnd, value } = element + + // Select the whole first line because it might contain \t + const firstLineStart = value.lastIndexOf('\n', selectionStart - 1) + 1 + const minimumSelectionEnd = TextAreaUtils.findLineEnd(value, selectionEnd) + + const newSelection = element.value.slice(firstLineStart, minimumSelectionEnd) + const indentedText = newSelection.replace(/(^|\n)(\t| {1,2})/g, '$1') + const replacementsCount = newSelection.length - indentedText.length + + // Replace newSelection with indentedText + element.setSelectionRange(firstLineStart, minimumSelectionEnd) + TextAreaUtils.insert(element, indentedText) + + // Restore selection position, including the indentation + const firstLineIndentation = /\t| {1,2}/.exec(value.slice(firstLineStart, selectionStart)) + + const difference = firstLineIndentation ? firstLineIndentation[0].length : 0 + + const newSelectionStart = selectionStart - difference + element.setSelectionRange( + selectionStart - difference, + Math.max(newSelectionStart, selectionEnd - replacementsCount) + ) + } +} diff --git a/packages/tldraw/src/shape/shapes/sticky/index.ts b/packages/tldraw/src/shape/shapes/sticky/index.ts new file mode 100644 index 000000000..6f9a20e35 --- /dev/null +++ b/packages/tldraw/src/shape/shapes/sticky/index.ts @@ -0,0 +1 @@ +export * from './sticky' diff --git a/packages/tldraw/src/shape/shapes/sticky/sticky.spec.tsx b/packages/tldraw/src/shape/shapes/sticky/sticky.spec.tsx new file mode 100644 index 000000000..faa491e58 --- /dev/null +++ b/packages/tldraw/src/shape/shapes/sticky/sticky.spec.tsx @@ -0,0 +1,8 @@ +import { Sticky } from './sticky' + +describe('Post-It shape', () => { + it('Creates a shape', () => { + expect(Sticky.create).toBeDefined() + // expect(Sticky.create({ id: 'sticky' })).toMatchSnapshot('sticky') + }) +}) diff --git a/packages/tldraw/src/shape/shapes/sticky/sticky.tsx b/packages/tldraw/src/shape/shapes/sticky/sticky.tsx new file mode 100644 index 000000000..83b8023b5 --- /dev/null +++ b/packages/tldraw/src/shape/shapes/sticky/sticky.tsx @@ -0,0 +1,293 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import * as React from 'react' +import { css } from '@stitches/core' +import { HTMLContainer, ShapeUtil } from '@tldraw/core' +import { defaultStyle } from '~shape/shape-styles' +import { StickyShape, TLDrawMeta, TLDrawShapeType } from '~types' +import { getBoundsRectangle } from '../shared' +import { getStickyFontStyle, getStickyShapeStyle } from '~shape' +import { TextAreaUtils } from '../shared' +import Vec from '@tldraw/vec' + +const PADDING = 16 +const MIN_CONTAINER_HEIGHT = 200 + +function normalizeText(text: string) { + return text.replace(/\r?\n|\r/g, '\n') +} + +export const Sticky = new ShapeUtil(() => ({ + type: TLDrawShapeType.Sticky, + + showBounds: false, + + isStateful: true, + + canBind: true, + + canEdit: true, + + pathCache: new WeakMap([]), + + defaultProps: { + id: 'id', + type: TLDrawShapeType.Sticky, + name: 'Sticky', + parentId: 'page', + childIndex: 1, + point: [0, 0], + size: [200, 200], + text: '', + rotation: 0, + style: defaultStyle, + }, + + shouldRender(prev, next) { + return next.size !== prev.size || next.style !== prev.style || next.text !== prev.text + }, + + Component({ events, shape, isEditing, onShapeBlur, onShapeChange, meta }, ref) { + const font = getStickyFontStyle(shape.style) + + const { color, fill } = getStickyShapeStyle(shape.style, meta.isDarkMode) + + const rContainer = React.useRef(null) + + const rTextArea = React.useRef(null) + + const rText = React.useRef(null) + + const rIsMounted = React.useRef(false) + + const handlePointerDown = React.useCallback((e: React.PointerEvent) => { + e.stopPropagation() + }, []) + + const handleTextChange = React.useCallback( + (e: React.ChangeEvent) => { + onShapeChange?.({ + id: shape.id, + type: shape.type, + text: normalizeText(e.currentTarget.value), + }) + }, + [onShapeChange] + ) + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') return + + e.stopPropagation() + + if (e.key === 'Tab') { + e.preventDefault() + if (e.shiftKey) { + TextAreaUtils.unindent(e.currentTarget) + } else { + TextAreaUtils.indent(e.currentTarget) + } + + onShapeChange?.({ ...shape, text: normalizeText(e.currentTarget.value) }) + } + }, + [shape, onShapeChange] + ) + + const handleBlur = React.useCallback( + (e: React.FocusEvent) => { + if (!isEditing) return + if (rIsMounted.current) { + e.currentTarget.setSelectionRange(0, 0) + onShapeBlur?.() + } + }, + [isEditing] + ) + + const handleFocus = React.useCallback( + (e: React.FocusEvent) => { + if (!isEditing) return + if (!rIsMounted.current) return + + if (document.activeElement === e.currentTarget) { + e.currentTarget.select() + } + }, + [isEditing] + ) + + // Focus when editing changes to true + React.useEffect(() => { + if (isEditing) { + if (document.activeElement !== rText.current) { + requestAnimationFrame(() => { + rIsMounted.current = true + const elm = rTextArea.current! + elm.focus() + elm.select() + }) + } + } + }, [isEditing]) + + // Resize to fit text + React.useEffect(() => { + const text = rText.current! + + const { size } = shape + const { offsetHeight: currTextHeight } = text + const minTextHeight = MIN_CONTAINER_HEIGHT - PADDING * 2 + const prevTextHeight = size[1] - PADDING * 2 + + // Same size? We can quit here + if (currTextHeight === prevTextHeight) return + + if (currTextHeight > minTextHeight) { + // Snap the size to the text content if the text only when the + // text is larger than the minimum text height. + onShapeChange?.({ id: shape.id, size: [size[0], currTextHeight + PADDING * 2] }) + return + } + + if (currTextHeight < minTextHeight && size[1] > MIN_CONTAINER_HEIGHT) { + // If we're smaller than the minimum height and the container + // is too tall, snap it down to the minimum container height + onShapeChange?.({ id: shape.id, size: [size[0], MIN_CONTAINER_HEIGHT] }) + return + } + }, [shape.text, shape.size[1]]) + + const style = { + font, + color, + textShadow: meta.isDarkMode + ? `0.5px 0.5px 2px rgba(255, 255, 255,.25)` + : `0.5px 0.5px 2px rgba(255, 255, 255,.5)`, + } + + return ( + +
+
+ {shape.text}​ +
+ {isEditing && ( +