From c4d9116426aedc8d1e77ed70437bd71896c2c184 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Fri, 2 Jul 2021 11:48:49 +0100 Subject: [PATCH] Fix text rendering, text layout due to mismatched vertical alignments. --- .vscode/snippets.code-snippets | 16 ----- components/canvas/page.tsx | 10 +-- components/canvas/shape.tsx | 35 +++++---- hooks/useCanvasEvents.ts | 10 ++- hooks/useKeyboardEvents.ts | 4 +- hooks/useLoadOnMount.ts | 2 +- state/sessions/edit-session.ts | 1 + state/shape-utils/text.tsx | 23 ++++-- state/state.ts | 125 ++++++++++++++++++++++++++------- utils/tld.ts | 5 ++ 10 files changed, 154 insertions(+), 77 deletions(-) diff --git a/.vscode/snippets.code-snippets b/.vscode/snippets.code-snippets index 7308da0ac..d73333f7d 100644 --- a/.vscode/snippets.code-snippets +++ b/.vscode/snippets.code-snippets @@ -1,20 +1,4 @@ { - // Place your tldraw workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and - // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope - // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is - // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: - // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. - // Placeholders with the same ids are connected. - // Example: - // "Print to console": { - // "scope": "javascript,typescript", - // "prefix": "log", - // "body": [ - // "console.log('$1');", - // "$2" - // ], - // "description": "Log output to console" - // } "createComment": { "scope": "typescript", "prefix": "/**", diff --git a/components/canvas/page.tsx b/components/canvas/page.tsx index 7278aed63..4aab83fab 100644 --- a/components/canvas/page.tsx +++ b/components/canvas/page.tsx @@ -10,7 +10,9 @@ here; and still cheaper than any other pattern I've found. */ export default function Page(): JSX.Element { - const isSelecting = useSelector((s) => s.isIn('selecting')) + const showHovers = useSelector((s) => + s.isInAny('selecting', 'text', 'editingShape') + ) const visiblePageShapeIds = usePageShapes() @@ -19,12 +21,12 @@ export default function Page(): JSX.Element { }) return ( - - {isSelecting && hoveredShapeId && ( + + {showHovers && hoveredShapeId && ( )} {visiblePageShapeIds.map((id) => ( - + ))} ) diff --git a/components/canvas/shape.tsx b/components/canvas/shape.tsx index ced961d16..e1b6f554b 100644 --- a/components/canvas/shape.tsx +++ b/components/canvas/shape.tsx @@ -11,10 +11,9 @@ import useShapeDef from 'hooks/useShape' interface ShapeProps { id: string - isSelecting: boolean } -function Shape({ id, isSelecting }: ShapeProps): JSX.Element { +function Shape({ id }: ShapeProps): JSX.Element { const rGroup = useRef(null) const isHidden = useSelector((s) => { @@ -27,7 +26,7 @@ function Shape({ id, isSelecting }: ShapeProps): JSX.Element { const shape = tld.getShape(s.data, id) if (shape === undefined) return [] return shape?.children - }, deepCompareArrays) + }) const strokeWidth = useSelector((s) => { const shape = tld.getShape(s.data, id) @@ -58,7 +57,10 @@ function Shape({ id, isSelecting }: ShapeProps): JSX.Element { const shape = tld.getShape(state.data, id) - if (!shape) return null + if (!shape) { + console.warn('Could not find that shape:', id) + return null + } // From here on, not reactive—if we're here, we can trust that the // shape in state is a shape with changes that we need to render. @@ -73,17 +75,16 @@ function Shape({ id, isSelecting }: ShapeProps): JSX.Element { isCurrentParent={isCurrentParent} {...events} > - {isSelecting && - (isForeignObject ? ( - - ) : ( - - ))} + {isForeignObject ? ( + + ) : ( + + )} {!isHidden && (isForeignObject ? ( @@ -93,9 +94,7 @@ function Shape({ id, isSelecting }: ShapeProps): JSX.Element { ))} {isParent && - children.map((shapeId) => ( - - ))} + children.map((shapeId) => )} ) } diff --git a/hooks/useCanvasEvents.ts b/hooks/useCanvasEvents.ts index afae37ab9..655fb31e1 100644 --- a/hooks/useCanvasEvents.ts +++ b/hooks/useCanvasEvents.ts @@ -18,10 +18,16 @@ export default function useCanvasEvents( rCanvas.current.setPointerCapture(e.pointerId) + const info = inputs.pointerDown(e, 'canvas') + if (e.button === 0) { - state.send('POINTED_CANVAS', inputs.pointerDown(e, 'canvas')) + if (inputs.isDoubleClick() && !(info.altKey || info.metaKey)) { + state.send('DOUBLE_POINTED_CANVAS', info) + } + + state.send('POINTED_CANVAS', info) } else if (e.button === 2) { - state.send('RIGHT_POINTED', inputs.pointerDown(e, 'canvas')) + state.send('RIGHT_POINTED', info) } }, []) diff --git a/hooks/useKeyboardEvents.ts b/hooks/useKeyboardEvents.ts index e294baf33..2871de921 100644 --- a/hooks/useKeyboardEvents.ts +++ b/hooks/useKeyboardEvents.ts @@ -264,7 +264,7 @@ export default function useKeyboardEvents() { break } default: { - state.send('PRESSED_KEY', info) + null } } } @@ -279,8 +279,6 @@ export default function useKeyboardEvents() { if (e.key === 'Alt') { state.send('RELEASED_ALT_KEY', info) } - - state.send('RELEASED_KEY', info) } document.body.addEventListener('keydown', handleKeyDown) diff --git a/hooks/useLoadOnMount.ts b/hooks/useLoadOnMount.ts index caab8ba17..628cbd705 100644 --- a/hooks/useLoadOnMount.ts +++ b/hooks/useLoadOnMount.ts @@ -2,7 +2,7 @@ import { useEffect } from 'react' import state from 'state' -export default function useLoadOnMount(roomId?: string) { +export default function useLoadOnMount(roomId: string = undefined) { useEffect(() => { const fonts = (document as any).fonts diff --git a/state/sessions/edit-session.ts b/state/sessions/edit-session.ts index 41b31b29a..cda3f0b2e 100644 --- a/state/sessions/edit-session.ts +++ b/state/sessions/edit-session.ts @@ -17,6 +17,7 @@ export default class EditSession extends BaseSession { const initialShape = this.snapshot.initialShape const shape = tld.getShape(data, initialShape.id) const utils = getShapeUtils(shape) + Object.entries(change).forEach(([key, value]) => { utils.setProperty(shape, key as keyof Shape, value as Shape[keyof Shape]) }) diff --git a/state/shape-utils/text.tsx b/state/shape-utils/text.tsx index 4686ff022..fd807cad7 100644 --- a/state/shape-utils/text.tsx +++ b/state/shape-utils/text.tsx @@ -33,6 +33,8 @@ Object.assign(mdiv.style, { left: '0px', zIndex: '9999', pointerEvents: 'none', + alignmentBaseline: 'mathematical', + dominantBaseline: 'mathematical', }) mdiv.tabIndex = -1 @@ -83,28 +85,32 @@ const text = registerShapeUtils({ function handleChange(e: React.ChangeEvent) { state.send('EDITED_SHAPE', { + id, change: { text: normalizeText(e.currentTarget.value) }, }) } - function handleKeyDown(e: React.KeyboardEvent) { + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Escape') return + e.stopPropagation() + if (e.key === 'Tab') { e.preventDefault() } } function handleBlur() { - state.send('BLURRED_EDITING_SHAPE') + setTimeout(() => state.send('BLURRED_EDITING_SHAPE', { id }), 0) } function handleFocus(e: React.FocusEvent) { e.currentTarget.select() - state.send('FOCUSED_EDITING_SHAPE') + state.send('FOCUSED_EDITING_SHAPE', { id }) } const fontSize = getFontSize(shape.style.size) * shape.scale - const gap = fontSize * 0.4 + const lineHeight = fontSize * 1.4 if (!isEditing) { return ( @@ -113,7 +119,7 @@ const text = registerShapeUtils({ ({ width={bounds.width} height={bounds.height} fill={styles.stroke} - dominantBaseline="hanging" + xmlSpace="preserve" + dominantBaseline="mathematical" + alignmentBaseline="mathematical" > {str} @@ -255,9 +263,12 @@ const StyledTextArea = styled('textarea', { border: 'none', padding: '4px', whiteSpace: 'pre', + alignmentBaseline: 'mathematical', + dominantBaseline: 'mathematical', resize: 'none', minHeight: 1, minWidth: 1, + lineHeight: 1.4, outline: 0, backgroundColor: '$boundsBg', overflow: 'hidden', diff --git a/state/state.ts b/state/state.ts index 55511cb65..d29d12b7a 100644 --- a/state/state.ts +++ b/state/state.ts @@ -37,6 +37,7 @@ import { SizeStyle, ColorStyle, } from 'types' +import { getFontSize } from './shape-styles' const initialData: Data = { isReadOnly: false, @@ -141,15 +142,12 @@ for (let i = 0; i < count; i++) { const state = createState({ data: initialData, - on: { - UNMOUNTED: { to: 'loading' }, - }, initial: 'loading', states: { loading: { on: { MOUNTED: { - do: 'restoreSavedData', + do: 'restoredPreviousDocument', to: 'ready', }, }, @@ -162,6 +160,10 @@ const state = createState({ else: ['zoomCameraToActual'], }, on: { + UNMOUNTED: { + do: ['saveAppState', 'saveDocumentState', 'resetDocumentState'], + to: 'loading', + }, // Network-Related RT_LOADED_ROOM: [ 'clearRoom', @@ -396,6 +398,18 @@ const state = createState({ if: ['hasSelection', 'selectionIncludesGroups'], do: 'ungroupSelection', }, + MOVED_OVER_SHAPE: { + if: 'pointHitsShape', + then: { + unless: 'shapeIsHovered', + do: 'setHoveredId', + }, + else: { + if: 'shapeIsHovered', + do: 'clearHoveredId', + }, + }, + UNHOVERED_SHAPE: 'clearHoveredId', NUDGED: 'nudgeSelection', }, initial: 'notPointing', @@ -412,6 +426,18 @@ const state = createState({ to: 'brushSelecting', do: 'setCurrentParentIdToPage', }, + DOUBLE_POINTED_CANVAS: [ + { + get: 'newText', + do: 'createShape', + }, + { + get: 'firstSelectedShape', + if: 'canEditSelectedShape', + do: 'setEditingId', + to: 'editingShape', + }, + ], POINTED_BOUNDS: [ { if: 'isPressingMetaKey', @@ -441,18 +467,6 @@ const state = createState({ unless: 'isReadOnly', to: 'translatingHandles', }, - MOVED_OVER_SHAPE: { - if: 'pointHitsShape', - then: { - unless: 'shapeIsHovered', - do: 'setHoveredId', - }, - else: { - if: 'shapeIsHovered', - do: 'clearHoveredId', - }, - }, - UNHOVERED_SHAPE: 'clearHoveredId', POINTED_SHAPE: [ { if: 'isPressingMetaKey', @@ -638,6 +652,7 @@ const state = createState({ on: { EDITED_SHAPE: { do: 'updateEditSession' }, BLURRED_EDITING_SHAPE: [ + { unless: 'isEditingShape' }, { get: 'editingShape', if: 'shouldDeleteShape', @@ -645,6 +660,19 @@ const state = createState({ }, { to: 'selecting' }, ], + POINTED_SHAPE: { + unless: 'isPointingEditingShape', + if: 'isPointingTextShape', + do: [ + 'completeSession', + 'clearEditingId', + 'setPointedId', + 'clearSelectedIds', + 'pushPointedIdToSelectedIds', + 'setEditingId', + 'startEditSession', + ], + }, CANCELLED: [ { get: 'editingShape', @@ -895,6 +923,16 @@ const state = createState({ on: { CANCELLED: { to: 'selecting' }, POINTED_SHAPE: [ + { + if: 'isPointingTextShape', + unless: 'isPressingShiftKey', + do: [ + 'clearSelectedIds', + 'pushPointedIdToSelectedIds', + 'setEditingId', + ], + to: 'editingShape', + }, { get: 'newText', do: 'createShape', @@ -1063,12 +1101,21 @@ const state = createState({ hasRoom(_, payload: { id?: string }) { return payload.id !== undefined }, + isEditingShape(data, payload: { id: string }) { + return payload.id === data.editingId + }, shouldDeleteShape(data, payload, shape: Shape) { return getShapeUtils(shape).shouldDelete(shape) }, isPointingCanvas(data, payload: PointerInfo) { return payload.target === 'canvas' }, + isPointingEditingShape(data, payload: { target: string }) { + return payload.target === data.editingId + }, + isPointingTextShape(data, payload: { target: string }) { + return tld.getShape(data, payload.target)?.type === ShapeType.Text + }, isPointingBounds(data, payload: PointerInfo) { return tld.getSelectedIds(data).size > 0 && payload.target === 'bounds' }, @@ -1184,6 +1231,8 @@ const state = createState({ resetDocumentState(data) { data.document.id = uniqueId() + session.cancel(data) + const newId = 'page1' data.currentPageId = newId @@ -1249,10 +1298,17 @@ const state = createState({ }, createShape(data, payload, type: ShapeType) { + const style = deepClone(data.currentStyle) + let point = vec.round(tld.screenToWorld(payload.point, data)) + + if (type === ShapeType.Text) { + point = vec.sub(point, vec.mul([0, 1], getFontSize(style.size) * 0.8)) + } + const shape = createShape(type, { id: uniqueId(), parentId: data.currentPageId, - point: vec.round(tld.screenToWorld(payload.point, data)), + point, style: deepClone(data.currentStyle), }) @@ -1525,14 +1581,13 @@ const state = createState({ tld.getSelectedIds(data).clear() }, selectAll(data) { - const selectedIds = tld.getSelectedIds(data) - const page = tld.getPage(data) - selectedIds.clear() - for (const id in page.shapes) { - if (page.shapes[id].parentId === data.currentPageId) { - selectedIds.add(id) - } - } + tld.setSelectedIds( + data, + tld + .getShapes(data) + .filter((shape) => shape.parentId === data.currentPageId) + .map((shape) => shape.id) + ) }, setHoveredId(data, payload: PointerInfo) { data.hoveredId = payload.target @@ -1572,6 +1627,9 @@ const state = createState({ clearSelectedIds(data) { tld.setSelectedIds(data, []) }, + selectId(data, payload: PointerInfo) { + tld.setSelectedIds(data, [payload.target]) + }, pullPointedIdFromSelectedIds(data) { const { pointedId } = data const selectedIds = tld.getSelectedIds(data) @@ -1938,7 +1996,7 @@ const state = createState({ /* ---------------------- Data ---------------------- */ - restoreSavedData(data) { + restoredPreviousDocument(data) { storage.firstLoad(data) }, @@ -1962,6 +2020,10 @@ const state = createState({ storage.saveAppStateToLocalStorage(data) }, + saveDocumentState(data) { + storage.saveDocumentToLocalStorage(data) + }, + forceSave(data) { storage.saveToFileSystem(data) }, @@ -2143,5 +2205,14 @@ function getSelectionBounds(data: Data) { return commonBounds } +// const skippedLogs = new Set([ +// 'MOVED_POINTER', +// 'MOVED_OVER_SHAPE', +// 'RESIZED_WINDOW', +// 'HOVERED_SHAPE', +// 'UNHOVERED_SHAPE', +// 'PANNED_CAMERA', +// ]) + // state.enableLog(true) -// state.onUpdate((s) => console.log(s.log.filter((l) => l !== 'MOVED_POINTER'))) +// state.onUpdate((s) => console.log(s.log.filter((l) => !skippedLogs.has(l)))) diff --git a/utils/tld.ts b/utils/tld.ts index 50e6df399..52285186f 100644 --- a/utils/tld.ts +++ b/utils/tld.ts @@ -318,6 +318,11 @@ export default class StateUtils { static getTopParentId(data: Data, id: string): string { const shape = this.getPage(data).shapes[id] + if (shape.parentId === shape.id) { + console.error('Shape has the same id as its parent!', deepClone(shape)) + return shape.parentId + } + return shape.parentId === data.currentPageId || shape.parentId === data.currentParentId ? id