Fix text rendering, text layout due to mismatched vertical alignments.

This commit is contained in:
Steve Ruiz 2021-07-02 11:48:49 +01:00
parent c994a935ee
commit c4d9116426
10 changed files with 154 additions and 77 deletions

View file

@ -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": "/**",

View file

@ -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>
)

View file

@ -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>
)
}

View file

@ -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)
}
}, [])

View file

@ -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)

View file

@ -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

View file

@ -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])
})

View file

@ -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',

View file

@ -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))))

View file

@ -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