Adds text

This commit is contained in:
Steve Ruiz 2021-06-17 11:43:55 +01:00
parent 098afdd180
commit aabc7e8e0f
17 changed files with 250 additions and 133 deletions

View file

@ -1,6 +1,6 @@
import styled from 'styles'
import state, { useSelector } from 'state'
import React, { useRef } from 'react'
import React, { useEffect, useRef } from 'react'
import useZoomEvents from 'hooks/useZoomEvents'
import useCamera from 'hooks/useCamera'
import Defs from './defs'

View file

@ -1,4 +1,4 @@
import React, { useRef, memo } from 'react'
import React, { useRef, memo, useEffect } from 'react'
import state, { useSelector } from 'state'
import styled from 'styles'
import { getShapeUtils } from 'lib/shape-utils'
@ -16,14 +16,21 @@ interface ShapeProps {
}
function Shape({ id, isSelecting, parentPoint }: ShapeProps) {
const shape = useSelector((s) => getPage(s.data).shapes[id])
const rGroup = useRef<SVGGElement>(null)
const rFocusable = useRef<HTMLTextAreaElement>(null)
const isEditing = useSelector((s) => s.data.editingId === id)
const rGroup = useRef<SVGGElement>(null)
const shape = useSelector((s) => getPage(s.data).shapes[id])
const events = useShapeEvents(id, getShapeUtils(shape)?.isParent, rGroup)
useEffect(() => {
if (isEditing) {
setTimeout(() => rFocusable.current?.focus(), 0)
}
}, [isEditing])
// This is a problem with deleted shapes. The hooks in this component
// may sometimes run before the hook in the Page component, which means
// a deleted shape will still be pulled here before the page component
@ -32,6 +39,7 @@ function Shape({ id, isSelecting, parentPoint }: ShapeProps) {
const style = getShapeStyle(shape.style)
const shapeUtils = getShapeUtils(shape)
const { isShy, isParent, isForeignObject } = shapeUtils
const bounds = shapeUtils.getBounds(shape)
@ -45,11 +53,7 @@ function Shape({ id, isSelecting, parentPoint }: ShapeProps) {
`
return (
<StyledGroup
ref={rGroup}
transform={transform}
onBlur={() => state.send('BLURRED_SHAPE', { target: id })}
>
<StyledGroup ref={rGroup} transform={transform}>
{isSelecting &&
!isShy &&
(isForeignObject ? (
@ -73,7 +77,7 @@ function Shape({ id, isSelecting, parentPoint }: ShapeProps) {
{!shape.isHidden &&
(isForeignObject ? (
shapeUtils.render(shape, { isEditing })
shapeUtils.render(shape, { isEditing, ref: rFocusable })
) : (
<RealShape
isParent={isParent}
@ -107,7 +111,6 @@ const RealShape = memo(function RealShape({
id,
style,
isParent,
isEditing,
}: RealShapeProps) {
return <StyledShape as="use" data-shy={isParent} href={'#' + id} {...style} />
})

View file

@ -105,7 +105,7 @@ export default function ToolsPanel() {
</Tooltip>
<Tooltip label="Text">
<IconButton
name={ShapeType.Arrow}
name={ShapeType.Text}
bp={{ '@initial': 'mobile', '@sm': 'small' }}
size={{ '@initial': 'medium', '@sm': 'small', '@md': 'large' }}
onClick={selectTextTool}

31
decs.d.ts vendored Normal file
View file

@ -0,0 +1,31 @@
type CSSOMString = string
type FontFaceLoadStatus = 'unloaded' | 'loading' | 'loaded' | 'error'
type FontFaceSetStatus = 'loading' | 'loaded'
interface FontFace {
family: CSSOMString
style: CSSOMString
weight: CSSOMString
stretch: CSSOMString
unicodeRange: CSSOMString
variant: CSSOMString
featureSettings: CSSOMString
variationSettings: CSSOMString
display: CSSOMString
readonly status: FontFaceLoadStatus
readonly loaded: Promise<FontFace>
load(): Promise<FontFace>
}
interface FontFaceSet {
readonly status: FontFaceSetStatus
readonly ready: Promise<FontFaceSet>
check(font: string, text?: string): Boolean
load(font: string, text?: string): Promise<FontFace[]>
}
declare global {
interface Document {
fonts: FontFaceSet
}
}

View file

@ -180,11 +180,7 @@ export default function useKeyboardEvents() {
break
}
case 't': {
if (metaKey(e)) {
state.send('DUPLICATED', getKeyboardEventInfo(e))
} else {
state.send('SELECTED_DOT_TOOL', getKeyboardEventInfo(e))
}
state.send('SELECTED_TEXT_TOOL', getKeyboardEventInfo(e))
break
}
case 'c': {

View file

@ -1,11 +1,16 @@
import { useEffect } from "react"
import state from "state"
import { useEffect } from 'react'
import state from 'state'
export default function useLoadOnMount() {
useEffect(() => {
state.send("MOUNTED")
const fonts = (document as any).fonts
fonts
.load('12px Verveine Regular', 'Fonts are loaded!')
.then(() => state.send('MOUNTED'))
return () => {
state.send("UNMOUNTED")
state.send('UNMOUNTED')
}
}, [])
}

View file

@ -63,10 +63,14 @@ export function getFontSize(size: FontSize) {
return fontSizes[size]
}
export function getFontStyle(size: FontSize, style: ShapeStyles) {
export function getFontStyle(
size: FontSize,
scale: number,
style: ShapeStyles
) {
const fontSize = getFontSize(size)
return `${fontSize}px Verveine Regular`
return `${fontSize * scale}px Verveine Regular`
}
export function getShapeStyle(

View file

@ -32,6 +32,7 @@ import draw from './draw'
import arrow from './arrow'
import group from './group'
import text from './text'
import React from 'react'
/*
Shape Utiliies
@ -173,9 +174,14 @@ export interface ShapeUtility<K extends Shape> {
render(
this: ShapeUtility<K>,
shape: K,
info: { isEditing: boolean }
info: {
isEditing: boolean
ref: React.MutableRefObject<HTMLTextAreaElement>
}
): JSX.Element
invalidate(this: ShapeUtility<K>, shape: K): ShapeUtility<K>
// Get the bounds of the a shape.
getBounds(this: ShapeUtility<K>, shape: K): Bounds
@ -191,7 +197,7 @@ export interface ShapeUtility<K extends Shape> {
// Test whether bounds collide with or contain a shape.
hitTestBounds(this: ShapeUtility<K>, shape: K, bounds: Bounds): boolean
getShouldDelete(this: ShapeUtility<K>, shape: K): boolean
shouldDelete(this: ShapeUtility<K>, shape: K): boolean
}
// A mapping of shape types to shape utilities.
@ -350,9 +356,14 @@ function getDefaultShapeUtil<T extends Shape>(): ShapeUtility<T> {
return this
},
getShouldDelete(shape) {
shouldDelete(shape) {
return false
},
invalidate(shape) {
this.boundsCache.delete(shape)
return this
},
}
}

View file

@ -19,12 +19,14 @@ mdiv.style.whiteSpace = 'pre'
mdiv.style.width = 'auto'
mdiv.style.border = '1px solid red'
mdiv.style.padding = '4px'
mdiv.style.lineHeight = '1'
mdiv.style.margin = '0px'
mdiv.style.opacity = '0'
mdiv.style.position = 'absolute'
mdiv.style.top = '-500px'
mdiv.style.left = '0px'
mdiv.style.zIndex = '9999'
mdiv.setAttribute('readonly', 'true')
document.body.appendChild(mdiv)
const text = registerShapeUtils<TextShape>({
@ -50,23 +52,20 @@ const text = registerShapeUtils<TextShape>({
isHidden: false,
style: defaultStyle,
text: '',
scale: 1,
size: 'auto',
fontSize: FontSize.Medium,
...props,
}
},
render(shape, { isEditing }) {
render(shape, { isEditing, ref }) {
const { id, text, style } = shape
const styles = getShapeStyle(style)
const font = getFontStyle(shape.fontSize, shape.scale, shape.style)
const font = getFontStyle(shape.fontSize, shape.style)
const bounds = this.getBounds(shape)
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
state.send('EDITED_SHAPE', { change: { text: e.currentTarget.value } })
}
return (
<foreignObject
id={id}
@ -76,44 +75,63 @@ const text = registerShapeUtils<TextShape>({
height={bounds.height}
pointerEvents="none"
>
<StyledText
key={id}
{isEditing ? (
<StyledTextArea
ref={ref}
style={{
font,
color: styles.fill,
color: styles.stroke,
}}
value={text}
onChange={handleChange}
isEditing={isEditing}
onFocus={(e) => e.currentTarget.select()}
autoComplete="false"
autoCapitalize="false"
autoCorrect="false"
onFocus={(e) => {
e.currentTarget.select()
state.send('FOCUSED_EDITING_SHAPE')
}}
onBlur={() => {
state.send('BLURRED_EDITING_SHAPE')
}}
onChange={(e) => {
state.send('EDITED_SHAPE', {
change: { text: e.currentTarget.value },
})
}}
/>
) : (
<StyledText
style={{
font,
color: styles.stroke,
}}
>
{text}
</StyledText>
)}
</foreignObject>
)
},
getBounds(shape) {
if (!this.boundsCache.has(shape)) {
mdiv.innerHTML = shape.text || ' ' // + '&nbsp;'
mdiv.style.font = getFontStyle(shape.fontSize, shape.scale, shape.style)
const [minX, minY] = shape.point
let width: number
let height: number
const [width, height] = [mdiv.offsetWidth, mdiv.offsetHeight]
if (shape.size === 'auto') {
// Calculate a size by rendering text into a div
mdiv.innerHTML = shape.text + '&nbsp;'
mdiv.style.font = getFontStyle(shape.fontSize, shape.style)
;[width, height] = [mdiv.offsetWidth, mdiv.offsetHeight]
} else {
// Use the shape's explicit size for width and height.
;[width, height] = shape.size
}
return {
this.boundsCache.set(shape, {
minX,
maxX: minX + width,
minY,
maxY: minY + height,
width,
height,
})
}
return this.boundsCache.get(shape)
},
hitTest(shape, test) {
@ -122,37 +140,25 @@ const text = registerShapeUtils<TextShape>({
transform(shape, bounds, { initialShape, transformOrigin, scaleX, scaleY }) {
if (shape.rotation === 0 && !shape.isAspectRatioLocked) {
shape.size = [bounds.width, bounds.height]
shape.point = [bounds.minX, bounds.minY]
shape.scale = initialShape.scale * Math.abs(scaleX)
} else {
if (initialShape.size === 'auto') return
shape.size = vec.mul(
initialShape.size,
Math.min(Math.abs(scaleX), Math.abs(scaleY))
)
shape.point = [
bounds.minX +
(bounds.width - shape.size[0]) *
(scaleX < 0 ? 1 - transformOrigin[0] : transformOrigin[0]),
bounds.minY +
(bounds.height - shape.size[1]) *
(scaleY < 0 ? 1 - transformOrigin[1] : transformOrigin[1]),
]
shape.point = [bounds.minX, bounds.minY]
shape.rotation =
(scaleX < 0 && scaleY >= 0) || (scaleY < 0 && scaleX >= 0)
? -initialShape.rotation
: initialShape.rotation
shape.scale = initialShape.scale * Math.abs(Math.min(scaleX, scaleY))
}
return this
},
transformSingle(shape, bounds) {
shape.size = [bounds.width, bounds.height]
transformSingle(shape, bounds, { initialShape, scaleX }) {
shape.point = [bounds.minX, bounds.minY]
shape.scale = initialShape.scale * Math.abs(scaleX)
return this
},
@ -161,14 +167,14 @@ const text = registerShapeUtils<TextShape>({
return this
},
getShouldDelete(shape) {
shouldDelete(shape) {
return shape.text.length === 0
},
})
export default text
const StyledText = styled('textarea', {
const StyledText = styled('div', {
width: '100%',
height: '100%',
border: 'none',
@ -180,13 +186,21 @@ const StyledText = styled('textarea', {
outline: 'none',
backgroundColor: 'transparent',
overflow: 'hidden',
pointerEvents: 'none',
userSelect: 'none',
})
variants: {
isEditing: {
true: {
const StyledTextArea = styled('textarea', {
width: '100%',
height: '100%',
border: 'none',
padding: '4px',
whiteSpace: 'pre',
resize: 'none',
minHeight: 1,
minWidth: 1,
outline: 'none',
overflow: 'hidden',
backgroundColor: '$boundsBg',
pointerEvents: 'all',
},
},
},
})

View file

@ -24,7 +24,7 @@ export default function handleCommand(
const shape = page.shapes[initialShape.id]
if (getShapeUtils(shape).getShouldDelete(shape)) {
if (getShapeUtils(shape).shouldDelete(shape)) {
delete page.shapes[initialShape.id]
}
},

View file

@ -15,21 +15,20 @@ export default function transformCommand(
history.execute(
data,
new Command({
name: 'translate_shapes',
name: 'transform_shapes',
category: 'canvas',
do(data, isInitial) {
do(data) {
const { type, shapeBounds } = after
const { shapes } = getPage(data)
for (let id in shapeBounds) {
const { initialShape, initialShapeBounds, transformOrigin } =
shapeBounds[id]
const { initialShapeBounds: bounds } = after.shapeBounds[id]
const { initialShape, transformOrigin } = before.shapeBounds[id]
const shape = shapes[id]
getShapeUtils(shape)
.transform(shape, initialShapeBounds, {
.transform(shape, bounds, {
type,
initialShape,
transformOrigin,
@ -42,24 +41,11 @@ export default function transformCommand(
updateParents(data, Object.keys(shapeBounds))
},
undo(data) {
const { type, shapeBounds } = before
const { shapeBounds } = before
const { shapes } = getPage(data)
for (let id in shapeBounds) {
const { initialShape, initialShapeBounds, transformOrigin } =
shapeBounds[id]
const shape = shapes[id]
getShapeUtils(shape)
.transform(shape, initialShapeBounds, {
type,
initialShape,
transformOrigin,
scaleX: scaleX < 0 ? scaleX * -1 : scaleX,
scaleY: scaleX < 0 ? scaleX * -1 : scaleX,
})
.onSessionComplete(shape)
shapes[id] = shapeBounds[id].initialShape
}
updateParents(data, Object.keys(shapeBounds))

View file

@ -5,6 +5,7 @@ import commands from 'state/commands'
import { current } from 'immer'
import {
getPage,
getPageState,
getSelectedIds,
getSelectedShapes,
getShape,

View file

@ -127,7 +127,8 @@ export function getTransformSnapshot(data: Data, transformType: Edge | Corner) {
const hasUnlockedShapes = initialShapes.length > 0
const isAllAspectRatioLocked = initialShapes.every(
(shape) => shape.isAspectRatioLocked
(shape) =>
shape.isAspectRatioLocked || !getShapeUtils(shape).canChangeAspectRatio
)
const shapesBounds = Object.fromEntries(

View file

@ -25,6 +25,7 @@ import {
getSelectedIds,
setSelectedIds,
getPageState,
getShapes,
} from 'utils/utils'
import {
Data,
@ -117,12 +118,10 @@ const state = createState({
states: {
loading: {
on: {
MOUNTED: [
'restoreSavedData',
{
MOUNTED: {
do: 'restoreSavedData',
to: 'ready',
},
],
},
},
ready: {
@ -133,6 +132,7 @@ const state = createState({
else: ['zoomCameraToFit', 'zoomCameraToActual'],
},
on: {
LOADED_FONTS: 'resetShapes',
TOGGLED_SHAPE_LOCK: { if: 'hasSelection', do: 'lockSelection' },
TOGGLED_SHAPE_HIDE: { if: 'hasSelection', do: 'hideSelection' },
TOGGLED_SHAPE_ASPECT_LOCK: {
@ -206,6 +206,7 @@ const state = createState({
SELECTED_LINE_TOOL: { unless: 'isReadOnly', to: 'line' },
SELECTED_POLYLINE_TOOL: { unless: 'isReadOnly', to: 'polyline' },
SELECTED_RECTANGLE_TOOL: { unless: 'isReadOnly', to: 'rectangle' },
SELECTED_TEXT_TOOL: { unless: 'isReadOnly', to: 'text' },
ZOOMED_CAMERA: {
do: 'zoomCamera',
},
@ -430,8 +431,17 @@ const state = createState({
onExit: 'clearEditingId',
on: {
EDITED_SHAPE: { do: 'updateEditSession' },
BLURRED_SHAPE: { do: 'completeSession', to: 'selecting' },
CANCELLED: { do: 'cancelSession', to: 'selecting' },
BLURRED_EDITING_SHAPE: { do: 'completeSession', to: 'selecting' },
CANCELLED: [
{
get: 'editingShape',
if: 'shouldDeleteShape',
do: 'breakSession',
else: 'cancelSession',
},
{ to: 'selecting' },
],
},
},
pinching: {
@ -730,6 +740,36 @@ const state = createState({
},
},
},
text: {
onEnter: 'setActiveToolText',
initial: 'creating',
states: {
creating: {
on: {
CANCELLED: { to: 'selecting' },
POINTED_SHAPE: [
{
get: 'newText',
do: 'createShape',
},
{
get: 'firstSelectedShape',
if: 'canEditSelectedShape',
do: 'setEditingId',
to: 'editingShape',
},
],
POINTED_CANVAS: [
{
get: 'newText',
do: 'createShape',
to: 'editingShape',
},
],
},
},
},
},
ray: {
onEnter: 'setActiveToolRay',
initial: 'creating',
@ -834,12 +874,6 @@ const state = createState({
},
},
results: {
newArrow() {
return ShapeType.Arrow
},
newDraw() {
return ShapeType.Draw
},
newDot() {
return ShapeType.Dot
},
@ -849,6 +883,15 @@ const state = createState({
newLine() {
return ShapeType.Line
},
newText() {
return ShapeType.Text
},
newDraw() {
return ShapeType.Draw
},
newArrow() {
return ShapeType.Arrow
},
newCircle() {
return ShapeType.Circle
},
@ -861,8 +904,14 @@ const state = createState({
firstSelectedShape(data) {
return getSelectedShapes(data)[0]
},
editingShape(data) {
return getShape(data, data.editingId)
},
},
conditions: {
shouldDeleteShape(data, payload, shape: Shape) {
return getShapeUtils(shape).shouldDelete(shape)
},
isPointingCanvas(data, payload: PointerInfo) {
return payload.target === 'canvas'
},
@ -959,6 +1008,13 @@ const state = createState({
},
/* --------------------- Shapes --------------------- */
resetShapes(data) {
const page = getPage(data)
Object.values(page.shapes).forEach((shape) => {
page.shapes[shape.id] = { ...shape }
})
},
createShape(data, payload, type: ShapeType) {
const shape = createShape(type, {
parentId: data.currentPageId,
@ -971,6 +1027,8 @@ const state = createState({
? siblings[siblings.length - 1].childIndex + 1
: 1
data.editingId = shape.id
getShapeUtils(shape).setProperty(shape, 'childIndex', childIndex)
getPage(data).shapes[shape.id] = shape
@ -1344,6 +1402,9 @@ const state = createState({
setActiveToolLine(data) {
data.activeTool = ShapeType.Line
},
setActiveToolText(data) {
data.activeTool = ShapeType.Text
},
/* --------------------- Camera --------------------- */
@ -1777,3 +1838,6 @@ function getSelectionBounds(data: Data) {
return commonBounds
}
// state.enableLog(true)
// state.onUpdate((s) => console.log(s.log.filter((l) => l !== 'MOVED_POINTER')))

View file

@ -5,7 +5,7 @@ import state from './state'
import { uniqueId } from 'utils/utils'
import * as idb from 'idb-keyval'
const CURRENT_VERSION = 'code_slate_0.0.7'
const CURRENT_VERSION = 'code_slate_0.0.8'
function storageId(fileId: string, label: string, id?: string) {
return [CURRENT_VERSION, fileId, label, id].filter(Boolean).join('_')

View file

@ -16,6 +16,6 @@
"rootDir": ".",
"baseUrl": "."
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "decs.d.ts"],
"exclude": ["node_modules"]
}

View file

@ -194,6 +194,7 @@ export interface TextShape extends BaseShape {
type: ShapeType.Text
text: string
size: number[] | 'auto'
scale: number
fontSize: FontSize
}