Fix text rendering, text layout due to mismatched vertical alignments.
This commit is contained in:
parent
c994a935ee
commit
c4d9116426
10 changed files with 154 additions and 77 deletions
16
.vscode/snippets.code-snippets
vendored
16
.vscode/snippets.code-snippets
vendored
|
@ -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": "/**",
|
||||
|
|
|
@ -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 (
|
||||
<g pointerEvents={isSelecting ? 'all' : 'none'}>
|
||||
{isSelecting && hoveredShapeId && (
|
||||
<g pointerEvents={showHovers ? 'all' : 'none'}>
|
||||
{showHovers && hoveredShapeId && (
|
||||
<HoveredShape key={hoveredShapeId} id={hoveredShapeId} />
|
||||
)}
|
||||
{visiblePageShapeIds.map((id) => (
|
||||
<Shape key={id} id={id} isSelecting={isSelecting} />
|
||||
<Shape key={id} id={id} />
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
|
|
|
@ -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<SVGGElement>(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 ? (
|
||||
<ForeignObjectHover id={id} />
|
||||
) : (
|
||||
<EventSoak
|
||||
as="use"
|
||||
href={'#' + id}
|
||||
strokeWidth={strokeWidth + 8}
|
||||
variant={canStyleFill ? 'filled' : 'hollow'}
|
||||
/>
|
||||
))}
|
||||
{isForeignObject ? (
|
||||
<ForeignObjectHover id={id} />
|
||||
) : (
|
||||
<EventSoak
|
||||
as="use"
|
||||
href={'#' + id}
|
||||
strokeWidth={strokeWidth + 8}
|
||||
variant={canStyleFill ? 'filled' : 'hollow'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isHidden &&
|
||||
(isForeignObject ? (
|
||||
|
@ -93,9 +94,7 @@ function Shape({ id, isSelecting }: ShapeProps): JSX.Element {
|
|||
))}
|
||||
|
||||
{isParent &&
|
||||
children.map((shapeId) => (
|
||||
<Shape key={shapeId} id={shapeId} isSelecting={isSelecting} />
|
||||
))}
|
||||
children.map((shapeId) => <Shape key={shapeId} id={shapeId} />)}
|
||||
</StyledGroup>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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])
|
||||
})
|
||||
|
|
|
@ -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<TextShape>({
|
|||
|
||||
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||
state.send('EDITED_SHAPE', {
|
||||
id,
|
||||
change: { text: normalizeText(e.currentTarget.value) },
|
||||
})
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
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<HTMLTextAreaElement>) {
|
||||
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<TextShape>({
|
|||
<text
|
||||
key={i}
|
||||
x={4}
|
||||
y={4 + gap / 2 + i * (fontSize + gap)}
|
||||
y={4 + fontSize / 2 + i * lineHeight}
|
||||
fontFamily="Verveine Regular"
|
||||
fontStyle="normal"
|
||||
fontWeight="regular"
|
||||
|
@ -121,7 +127,9 @@ const text = registerShapeUtils<TextShape>({
|
|||
width={bounds.width}
|
||||
height={bounds.height}
|
||||
fill={styles.stroke}
|
||||
dominantBaseline="hanging"
|
||||
xmlSpace="preserve"
|
||||
dominantBaseline="mathematical"
|
||||
alignmentBaseline="mathematical"
|
||||
>
|
||||
{str}
|
||||
</text>
|
||||
|
@ -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',
|
||||
|
|
125
state/state.ts
125
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<string>([
|
||||
// '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))))
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue