wip: double click to create logseq page/block

pull/5775/head
Peng Xiao 2022-06-17 20:22:51 +08:00
parent e8f7248b01
commit 55c0c5681e
13 changed files with 227 additions and 197 deletions

View File

@ -130,9 +130,6 @@ const _ContextBar: TLContextBarComponent<Shape> = ({ shapes, offsets }) => {
) : null}
</>
)}
<a className="shape-link" onClick={() => app.pubEvent('whiteboard-link', shapes)}>
Link
</a>
</div>
</HTMLContainer>
)

View File

@ -1,8 +1,7 @@
// TODO: provide "frontend.components.page/page" component?
/* eslint-disable @typescript-eslint/no-explicit-any */
import { TLBoxShape, TLBoxShapeProps } from '@tldraw/core'
import { HTMLContainer, TLComponentProps, useApp } from '@tldraw/react'
import { makeObservable, transaction } from 'mobx'
import { observer } from 'mobx-react-lite'
import * as React from 'react'
import { TextInput } from '~components/inputs/TextInput'
@ -16,17 +15,84 @@ export interface LogseqPortalShapeProps extends TLBoxShapeProps, CustomStyleProp
pageId: string // page name or UUID
}
interface LogseqQuickSearchProps {
onChange: (id: string) => void
}
const LogseqQuickSearch = observer(({ onChange }: LogseqQuickSearchProps) => {
const [q, setQ] = React.useState('')
const rInput = React.useRef<HTMLInputElement>(null)
const { search } = React.useContext(LogseqContext)
const secretPrefix = 'œ::'
const commitChange = React.useCallback((id: string) => {
setQ(id)
onChange(id)
rInput.current?.blur()
}, [])
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const _q = e.currentTarget.value
if (_q.startsWith(secretPrefix)) {
const id = _q.substring(secretPrefix.length)
commitChange(id)
} else {
setQ(_q)
}
}, [])
const options = React.useMemo(() => {
if (search && q) {
return search(q)
}
return null
}, [search, q])
React.useEffect(() => {
setTimeout(() => {
rInput.current?.focus()
})
}, [])
return (
<>
<TextInput
ref={rInput}
label="Page name or block UUID"
type="text"
value={q}
onChange={handleChange}
onKeyDown={e => {
if (e.key === 'Enter') {
commitChange(q)
}
}}
list="logseq-portal-search-results"
/>
<datalist id="logseq-portal-search-results">
{options?.map(option => (
<option key={option} value={secretPrefix + option}>
{option}
</option>
))}
</datalist>
</>
)
})
export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
static id = 'logseq-portal'
static smart = true
static defaultProps: LogseqPortalShapeProps = {
id: 'logseq-portal',
type: 'logseq-portal',
parentId: 'page',
point: [0, 0],
size: [600, 320],
stroke: '#000000',
fill: '#ffffff',
size: [180, 75],
stroke: 'transparent',
fill: 'var(--ls-secondary-background-color)',
strokeWidth: 2,
opacity: 1,
pageId: '',
@ -38,174 +104,88 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
canActivate = true
canEdit = true
ReactContextBar = observer(() => {
const { pageId } = this.props
const [q, setQ] = React.useState(pageId)
const rInput = React.useRef<HTMLInputElement>(null)
const { search } = React.useContext(LogseqContext)
const app = useApp()
constructor(props = {} as Partial<LogseqPortalShapeProps>) {
super(props)
makeObservable(this)
this.draft = true
}
const secretPrefix = 'œ::'
ReactComponent = observer(({ events, isErasing, isActivated }: TLComponentProps) => {
const {
props: { opacity, pageId, strokeWidth, stroke },
} = this
const app = useApp<Shape>()
const isMoving = useCameraMovingRef()
const { Page } = React.useContext(LogseqContext)
const isSelected = app.selectedIds.has(this.id)
const tlEventsEnabled = isMoving || isSelected || app.selectedTool.id !== 'select'
const stop = React.useCallback(
e => {
if (!tlEventsEnabled) {
e.stopPropagation()
}
},
[tlEventsEnabled]
)
const commitChange = React.useCallback((id: string) => {
setQ(id)
this.update({ pageId: id, size: LogseqPortalShape.defaultProps.size })
app.persist()
rInput.current?.blur()
transaction(() => {
this.update({
pageId: id,
size: [600, 320],
})
this.setDraft(false)
app.setActivatedShapes([])
app.persist()
})
}, [])
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const _q = e.currentTarget.value
if (_q.startsWith(secretPrefix)) {
const id = _q.substring(secretPrefix.length)
commitChange(id)
} else {
setQ(_q)
}
}, [])
const options = React.useMemo(() => {
if (search && q) {
return search(q)
}
return null
}, [search, q])
return (
<>
<TextInput
ref={rInput}
label="Page name or block UUID"
type="text"
value={q}
onChange={handleChange}
onKeyDown={e => {
if (e.key === 'Enter') {
commitChange(q)
}
}}
list="logseq-portal-search-results"
/>
<datalist id="logseq-portal-search-results">
{options?.map(option => (
<option key={option} value={secretPrefix + option}>
{option}
</option>
))}
</datalist>
</>
)
})
ReactComponent = observer(
({ events, isEditing, isErasing, isBinding, isActivated }: TLComponentProps) => {
const {
props: { opacity, pageId, strokeWidth },
} = this
const app = useApp<Shape>()
const isMoving = useCameraMovingRef()
const { Page } = React.useContext(LogseqContext)
const isSelected = app.selectedIds.has(this.id)
const enableTlEvents = () => {
return isMoving || isEditing || isSelected || app.selectedTool.id !== 'select'
}
const stop = React.useCallback(
e => {
if (!enableTlEvents()) {
e.stopPropagation()
}
},
[enableTlEvents]
)
if (!Page) {
return null
}
let linkButton = null
const logseqLink = this.props.logseqLink
if (logseqLink) {
const f = () => app.pubEvent('whiteboard-go-to-link', logseqLink)
linkButton = (
<a className="ml-2" onMouseDown={f}>
🔗 {logseqLink}
</a>
)
}
return (
<HTMLContainer
<HTMLContainer
style={{
overflow: 'hidden',
pointerEvents: 'all',
opacity: isErasing ? 0.2 : opacity,
backgroundColor: 'var(--ls-primary-background-color)',
}}
{...events}
>
<div
onWheelCapture={stop}
onPointerDown={stop}
onPointerUp={stop}
style={{
overflow: 'hidden',
pointerEvents: 'all',
opacity: isErasing ? 0.2 : opacity,
border: `${strokeWidth}px solid`,
borderColor: isActivated ? 'var(--tl-selectStroke)' : 'rgb(52, 52, 52)',
backgroundColor: '#ffffff',
boxShadow: isBinding ? '0px 0px 0 var(--tl-binding-distance) var(--tl-binding)' : '',
pointerEvents: isActivated ? 'all' : 'none',
}}
{...events}
>
{pageId && (
{this.draft ? (
<LogseqQuickSearch onChange={commitChange} />
) : (
<div
className="ls-whiteboard-card-header"
style={{
height: '32px',
width: '100%',
background: isActivated ? 'var(--tl-selectStroke)' : '#bbb',
display: 'flex',
alignItems: 'center',
color: isActivated ? '#fff' : '#000',
justifyContent: 'center',
overflow: 'auto',
overscrollBehavior: 'none',
height: pageId ? 'calc(100% - 33px)' : '100%',
userSelect: 'none',
boxShadow: isActivated
? '0px 0px 0 var(--tl-binding-distance) var(--tl-binding)'
: '',
opacity: isSelected ? 0.5 : 1,
}}
>
{pageId}
{linkButton}
{pageId && Page ? (
<div style={{ padding: '12px', height: '100%', cursor: 'default' }}>
<Page pageId={pageId} />
</div>
) : null}
</div>
)}
<div
style={{
width: '100%',
overflow: 'auto',
overscrollBehavior: 'none',
height: pageId ? 'calc(100% - 33px)' : '100%',
pointerEvents: isActivated ? 'all' : 'none',
userSelect: 'none',
opacity: isSelected ? 0.5 : 1,
}}
>
{pageId ? (
<div
onWheelCapture={stop}
onPointerDown={stop}
onPointerUp={stop}
style={{ padding: '12px', height: '100%', cursor: 'default' }}
>
<Page pageId={pageId} />
</div>
) : (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
overflow: 'hidden',
justifyContent: 'center',
padding: 16,
fontSize: '24px'
}}
>
LOGSEQ PORTAL PLACEHOLDER
</div>
)}
</div>
</HTMLContainer>
)
}
)
</div>
</HTMLContainer>
)
})
ReactIndicator = observer(() => {
const {

View File

@ -1,8 +1,8 @@
import { TLBoxTool } from '@tldraw/core'
import { TLBoxTool, TLDotTool } from '@tldraw/core'
import type { TLReactEventMap } from '@tldraw/react'
import { Shape, LogseqPortalShape } from '~lib/shapes'
export class LogseqPortalTool extends TLBoxTool<LogseqPortalShape, Shape, TLReactEventMap> {
export class LogseqPortalTool extends TLDotTool<LogseqPortalShape, Shape, TLReactEventMap> {
static id = 'logseq-portal'
static shortcut = ['i']
Shape = LogseqPortalShape

View File

@ -1,6 +1,7 @@
import React from 'react'
import ReactDOM from 'react-dom'
import 'tldraw-logseq/styles.css'
import '../../../public/static/css/common.css'
import App from './App'

View File

@ -664,6 +664,15 @@ export class TLApp<
Shapes = new Map<string, TLShapeConstructor<S>>()
get SmartShape() {
for (const S of this.Shapes.values()) {
if (S.smart) {
return S
}
}
return null
}
registerShapes = (Shapes: TLShapeConstructor<S>[]) => {
Shapes.forEach(Shape => this.Shapes.set(Shape.id, Shape))
}

View File

@ -50,6 +50,7 @@ export class TLPage<S extends TLShape = TLShape, E extends TLEventMap = TLEventM
shapes: this.shapes.map(shape => toJS(shape.props)),
bindings: toJS(this.bindings),
nonce: this.nonce,
activatedShape: toJS(this.app.activatedIds),
}),
(curr, prev) => {
this.cleanup(curr, prev)
@ -71,7 +72,8 @@ export class TLPage<S extends TLShape = TLShape, E extends TLEventMap = TLEventM
return {
id: this.id,
name: this.name,
shapes: this.shapes.map(shape => shape.serialized),
// @ts-expect-error maybe later
shapes: this.shapes.map(shape => shape.serialized).filter(s => !!s),
bindings: deepCopy(this.bindings),
nonce: this.nonce,
}
@ -178,20 +180,22 @@ export class TLPage<S extends TLShape = TLShape, E extends TLEventMap = TLEventM
direction === 'horizontal',
direction === 'vertical'
)
shape.onResize(shape.serialized, {
bounds: relativeBounds,
center: BoundsUtils.getBoundsCenter(relativeBounds),
rotation: shape.props.rotation ?? 0 * -1,
type: TLResizeCorner.TopLeft,
scale:
shape.canFlip && shape.props.scale
? direction === 'horizontal'
? [-shape.props.scale[0], 1]
: [1, -shape.props.scale[1]]
: [1, 1],
clip: false,
transformOrigin: [0.5, 0.5],
})
if (shape.serialized) {
shape.onResize(shape.serialized, {
bounds: relativeBounds,
center: BoundsUtils.getBoundsCenter(relativeBounds),
rotation: shape.props.rotation ?? 0 * -1,
type: TLResizeCorner.TopLeft,
scale:
shape.canFlip && shape.props.scale
? direction === 'horizontal'
? [-shape.props.scale[0], 1]
: [1, -shape.props.scale[1]]
: [1, 1],
clip: false,
transformOrigin: [0.5, 0.5],
})
}
})
return this
}
@ -271,12 +275,17 @@ export class TLPage<S extends TLShape = TLShape, E extends TLEventMap = TLEventM
}
})
if (!deepEqual(updated, curr)) {
// Cleanup inactive drafts
const shapesToDelete = this.shapes.filter(s => s.draft && !this.app.activatedShapes.includes(s))
if (!deepEqual(updated, curr) || shapesToDelete.length) {
transaction(() => {
this.update({
bindings: updated.bindings,
})
this.removeShapes(...shapesToDelete)
updated.shapes.forEach(shape => {
this.getShapeById(shape.id)?.update(shape)
})

View File

@ -18,6 +18,7 @@ export type TLShapeModel<P extends TLShapeProps = TLShapeProps> = {
export interface TLShapeConstructor<S extends TLShape = TLShape> {
new (props: any): S
id: string
smart: boolean
}
export type TLFlag = boolean
@ -79,6 +80,8 @@ export abstract class TLShape<P extends TLShapeProps = TLShapeProps, M = any> {
makeObservable(this)
}
// there should be only one Shape that is smart (created by double click canvas)
static smart: boolean
static type: string
@observable props: P
@ -104,6 +107,8 @@ export abstract class TLShape<P extends TLShapeProps = TLShapeProps, M = any> {
bindingDistance = BINDING_DISTANCE
// For smart shape
@observable draft = false
@observable private isDirty = false
@observable private lastSerialized: TLShapeModel<P> | undefined
@ -113,6 +118,10 @@ export abstract class TLShape<P extends TLShapeProps = TLShapeProps, M = any> {
return this.props.id
}
@action setDraft(draft: boolean) {
this.draft = draft
}
@action setIsDirty(isDirty: boolean) {
this.isDirty = isDirty
}
@ -278,8 +287,8 @@ export abstract class TLShape<P extends TLShapeProps = TLShapeProps, M = any> {
}
@computed
get serialized(): TLShapeModel<P> {
return this.getCachedSerialized()
get serialized(): TLShapeModel<P> | null {
return this.draft ? null : this.getCachedSerialized()
}
validateProps = (

View File

@ -1,9 +1,9 @@
import { IdleState, CreatingState } from './states'
import { TLTool, TLApp, TLShape, TLDotShape, TLDotShapeProps } from '~lib'
import { TLApp, TLBoxShape, TLShape, TLTool } from '~lib'
import { TLCursor, TLEventMap } from '~types'
import { CreatingState, IdleState } from './states'
export abstract class TLDotTool<
T extends TLDotShape = TLDotShape,
T extends TLBoxShape = TLBoxShape,
S extends TLShape = TLShape,
K extends TLEventMap = TLEventMap,
R extends TLApp<S, K> = TLApp<S, K>
@ -17,8 +17,9 @@ export abstract class TLDotTool<
cursor = TLCursor.Cross
abstract Shape: {
new (props: Partial<TLDotShapeProps>): T
new (props: Partial<T['props']>): T
id: string
defaultProps: TLDotShapeProps
smart: boolean
defaultProps: T['props']
}
}

View File

@ -1,12 +1,13 @@
import Vec from '@tldraw/vec'
import { TLApp, TLShape, TLToolState, TLDotShape } from '~lib'
import { TLApp, TLShape, TLToolState, TLBoxShape } from '~lib'
import { uniqueId } from '~utils'
import type { TLEventMap, TLStateEvents } from '~types'
import type { TLDotTool } from '../TLDotTool'
import { transaction } from 'mobx'
export class CreatingState<
S extends TLShape,
T extends S & TLDotShape,
S extends TLBoxShape,
T extends S & TLShape,
K extends TLEventMap,
R extends TLApp<S, K>,
P extends TLDotTool<T, S, K, R>
@ -19,15 +20,14 @@ export class CreatingState<
onEnter = () => {
const { Shape } = this.tool
this.offset = [Shape.defaultProps.radius, Shape.defaultProps.radius]
this.offset = [Shape.defaultProps.size[0] / 2, Shape.defaultProps.size[1] / 2]
const shape = new Shape({
id: uniqueId(),
parentId: this.app.currentPage.id,
point: Vec.sub(this.app.inputs.originPoint, this.offset),
})
size: Shape.defaultProps.size,
} as any)
this.creatingShape = shape
this.app.currentPage.addShapes(shape)
this.app.setSelectedShapes([shape])
}
onPointerMove: TLStateEvents<S, K>['onPointerMove'] = () => {
@ -41,7 +41,15 @@ export class CreatingState<
onPointerUp: TLStateEvents<S, K>['onPointerUp'] = () => {
this.tool.transition('idle')
if (this.creatingShape) {
this.app.setSelectedShapes([this.creatingShape])
const shape = this.creatingShape
transaction(() => {
this.app.currentPage.addShapes(shape)
if (this.tool.Shape.smart && shape.draft) {
this.app.setActivatedShapes([shape])
} else {
this.app.setSelectedShapes([shape])
}
})
}
if (!this.app.settings.isToolLocked) {
this.app.transition('select')

View File

@ -1,6 +1,8 @@
import { Vec } from '@tldraw/vec'
import { transaction } from 'mobx'
import { TLApp, TLSelectTool, TLShape, TLToolState } from '~lib'
import type { TLEventMap, TLEvents } from '~types'
import { uniqueId } from '~utils'
export class PointingCanvasState<
S extends TLShape,
@ -40,7 +42,19 @@ export class PointingCanvasState<
this.tool.transition('pinching', { info, event })
}
onDoubleClick: TLEvents<S>['pointer'] = info => {
console.log('TODO: bringing up Logseq autocomplete here', info)
onDoubleClick: TLEvents<S>['pointer'] = () => {
transaction(() => {
const Shape = this.app.SmartShape
if (Shape) {
const shape = new Shape({
id: uniqueId(),
type: Shape.id,
parentId: this.app.currentPage.id,
point: [...this.app.inputs.originPoint],
})
this.app.setActivatedShapes([shape.id])
this.app.currentPage.addShapes(shape)
}
})
}
}

View File

@ -64,8 +64,9 @@ export class ResizingState<
this.isSingle = selectedShapesArray.length === 1
this.selectionRotation = this.isSingle ? selectedShapesArray[0].props.rotation ?? 0 : 0
this.initialCommonBounds = { ...selectionBounds }
// @ts-expect-error maybe later
this.snapshots = Object.fromEntries(
selectedShapesArray.map(shape => {
selectedShapesArray.filter(s => !s.draft).map(shape => {
const bounds = { ...shape.bounds }
const [cx, cy] = BoundsUtils.getBoundsCenter(bounds)
return [

View File

@ -154,7 +154,7 @@ export const Canvas = observer(function Renderer<S extends TLReactShape>({
isSelected={true}
/>
))}
{hoveredShape && (
{hoveredShape && !hoveredShape.draft && (
<Indicator key={'hovered_indicator_' + hoveredShape.id} shape={hoveredShape} />
)}
{brush && components.Brush && <components.Brush bounds={brush} />}

View File

@ -30,6 +30,7 @@ export interface TLComponentProps<M = unknown> extends TLCommonShapeProps<M> {
export interface TLReactShapeConstructor<S extends TLReactShape = TLReactShape> {
new (props: S['props'] & { type: any }): S
id: string
smart: boolean
}
export abstract class TLReactShape<P extends TLShapeProps = TLShapeProps, M = any> extends TLShape<