File size: 8,976 Bytes
6f91ce5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 | import {
createShapeId,
react,
startEditingShapeWithRichText,
unsafe__withoutCapture,
type Editor,
type TLBinding,
type TLShape,
type TLShapeId,
} from 'tldraw'
import { NOTE_PIN_TYPE } from '../../shared/bindings.ts'
import { cardLabel } from '../../shared/display.ts'
import { isPaperCard } from '../../shared/paperCard.ts'
import { CARD_SIZE, type PaperCardShape } from '../../shared/shapes.ts'
import { boardOf, changes, recordEdit } from '../pinboard/changes.ts'
import { NOTE_SCALE, pinOffset, plainText } from '../widgets/notes.ts'
import { toast } from './toast.ts'
// Pinning notes on the canvas (T14): a note a person puts down with its centre on a paper's card
// is pinned to that paper with a `note-pin` binding (T01), beside the card as v6 pins them, and
// moves with it; one dragged well away from its paper comes loose. The geometry is pure, so it
// runs under `node --test`; registerPinNotes wires it to the editor.
interface Pt {
x: number
y: number
}
export interface Box {
x: number
y: number
w: number
h: number
}
/** A paper card in page space: its shape ID and the 172 × 70 card it draws (getShapePageBounds). */
export interface CardBox {
id: string
box: Box
}
const inside = (p: Pt, b: Box) => p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h
const centre = (b: Box): Pt => ({ x: b.x + b.w / 2, y: b.y + b.h / 2 })
/** The card whose drawn box holds the point; of overlapping cards, the one whose centre is nearest. */
export function cardUnder<C extends CardBox>(p: Pt, cards: readonly C[]): C | null {
let best: C | null = null
let bestD = Infinity
for (const card of cards) {
if (!inside(p, card.box)) continue
const c = centre(card.box)
const d = Math.hypot(c.x - p.x, c.y - p.y)
if (d < bestD) {
best = card
bestD = d
}
}
return best
}
/** The gap between two boxes; 0 when they touch or overlap. */
export function gap(a: Box, b: Box): number {
const dx = Math.max(0, a.x - (b.x + b.w), b.x - (a.x + a.w))
const dy = Math.max(0, a.y - (b.y + b.h), b.y - (a.y + a.h))
return Math.hypot(dx, dy)
}
/** A pinned note comes loose once it's more than a card's width from its paper. */
export const UNPIN_GAP = CARD_SIZE.w
export type NotePlace = { pin: string } | { unpin: true } | null
/**
* What putting a note down does: pin it to the card under its centre (or re-place it beside its
* own card), unpin it when it's dragged too far from its paper, or nothing.
*/
export function placeNote(note: Box, pinnedTo: string | null, cards: readonly CardBox[]): NotePlace {
const target = cardUnder(centre(note), cards)
if (target) return { pin: target.id }
const own = pinnedTo ? cards.find((c) => c.id === pinnedTo) : undefined
if (own && gap(note, own.box) > UNPIN_GAP) return { unpin: true }
return null
}
// ---- the editor
/** How the note came to be put down: made with the Note tool, dropped from the toolbar, made some other way, or dragged. */
type Why = 'tool' | 'dropped' | 'created' | 'moved'
const queues = new WeakMap<Editor, Map<TLShapeId, Why>>()
/** Notes a person creates or drags are settled once they're put down. Returns the cleanup. */
export function registerPinNotes(editor: Editor): () => void {
const queue = new Map<TLShapeId, Why>()
let busy = false
// a note is put down once the Note tool's press and any drag are over
const moving = () => editor.isIn('note.pointing') || editor.isIn('select.translating')
const flush = () => {
if (busy || !queue.size || moving()) return
const notes = [...queue]
queue.clear()
busy = true
try {
for (const [id, why] of notes) settle(editor, id, why)
} finally {
busy = false
}
}
queues.set(editor, queue)
// the toolbar's Note, dragged onto the board: caught before tldraw's own drop handling
const container = editor.getContainer()
const isNote = (e: DragEvent) => !!e.dataTransfer?.types.includes(NOTE_DRAG_TYPE)
const over = (e: DragEvent) => {
if (!isNote(e)) return
e.preventDefault()
e.dataTransfer!.dropEffect = 'copy'
}
const drop = (e: DragEvent) => {
if (!isNote(e)) return
e.preventDefault()
e.stopPropagation()
editor.markEventAsHandled(e)
dropNoteAt(editor, editor.screenToPage({ x: e.clientX, y: e.clientY }))
}
container.addEventListener('dragover', over, true)
container.addEventListener('drop', drop, true)
const cleanups = [
() => {
container.removeEventListener('dragover', over, true)
container.removeEventListener('drop', drop, true)
},
editor.sideEffects.registerAfterCreateHandler('shape', (shape, source) => {
if (busy || source !== 'user' || shape.type !== 'note' || queue.has(shape.id)) return
queue.set(shape.id, editor.isIn('note') ? 'tool' : 'created')
}),
editor.sideEffects.registerAfterChangeHandler('shape', (prev, next, source) => {
if (busy || source !== 'user' || next.type !== 'note') return
if (prev.x === next.x && prev.y === next.y && prev.parentId === next.parentId) return
// notes that move along with their paper aren't put down by anyone
if (!editor.getSelectedShapeIds().includes(next.id)) return
if (!queue.has(next.id)) queue.set(next.id, 'moved')
}),
editor.sideEffects.registerOperationCompleteHandler(flush),
react('pin notes once they are put down', () => {
if (!moving()) unsafe__withoutCapture(flush)
}),
]
return () => {
cleanups.forEach((cleanup) => cleanup())
if (queues.get(editor) === queue) queues.delete(editor)
}
}
function settle(editor: Editor, id: TLShapeId, why: Why) {
const note = editor.getShape(id)
const bounds = note ? editor.getShapePageBounds(note) : undefined
if (note?.type !== 'note' || !bounds) return
const pin = editor.getBindingsFromShape(note, NOTE_PIN_TYPE)[0]
const cards = new Map<string, PaperCardShape>()
const boxes: CardBox[] = []
for (const s of editor.getCurrentPageShapes()) {
const box = isPaperCard(s) && !editor.isShapeHidden(s) ? editor.getShapePageBounds(s) : undefined
if (!isPaperCard(s) || !box) continue
cards.set(s.id, s)
boxes.push({ id: s.id, box })
}
const place = placeNote(bounds, pin?.toId ?? null, boxes)
let pinnedTo = pin ? editor.getShape(pin.toId) : undefined
if (place && 'pin' in place) {
const card = cards.get(place.pin)!
pinTo(editor, note, card, pin)
if (pin?.toId !== card.id) toast(`Note pinned to ${cardLabel(card)}. It moves with the paper.`)
pinnedTo = card
} else if (place && pin) {
editor.deleteBinding(pin.id)
toast('Note unpinned.')
pinnedTo = undefined
} else if (!pin && (why === 'tool' || why === 'dropped')) {
toast('Note added. Drop it on a paper to pin it there.')
}
// for Update suggestions (T13): a new note, or an old one pinned or unpinned
const text = plainText(note.props.richText)
const paperId = isPaperCard(pinnedTo) ? pinnedTo.props.paperId : null
if (why !== 'moved') recordEdit(editor, { type: 'note_added', noteId: note.id, text, ...(paperId ? { paperId } : {}) })
else if (paperId && pinnedTo?.id !== pin?.toId) recordEdit(editor, { type: 'note_pinned', noteId: note.id, text, paperId })
else if (!paperId && pin) {
const boardId = boardOf(editor)
const was = editor.getShape(pin.toId)
if (boardId && isPaperCard(was)) changes.noteUnpinned(boardId, note.id, text, was.props.paperId)
}
}
/** Moves the note beside the card and pins it there; a pin to another paper goes. */
function pinTo(editor: Editor, note: TLShape, card: PaperCardShape, pin: TLBinding | undefined) {
const at = editor.getShapePageTransform(card).point()
const others = editor.getBindingsToShape(card.id, NOTE_PIN_TYPE).filter((b) => b.fromId !== note.id).length
const { dx, dy } = pinOffset(card.props, others)
editor.run(() => {
if (pin && pin.toId !== card.id) editor.deleteBinding(pin.id)
const p = editor.getPointInParentSpace(note, { x: at.x + dx, y: at.y + dy })
editor.updateShape({ id: note.id, type: note.type, x: p.x, y: p.y })
if (pin?.toId === card.id) editor.updateBinding({ id: pin.id, type: NOTE_PIN_TYPE, props: { dx, dy } })
else editor.createBinding({ type: NOTE_PIN_TYPE, fromId: note.id, toId: card.id, props: { dx, dy } })
})
}
/** What the toolbar's Note carries when it's dragged (an HTML drag). */
export const NOTE_DRAG_TYPE = 'application/x-pinboard-note'
/** The board's notes are 200 px at scale 1; the toolbar's are the widgets' size. */
const DROPPED_NOTE = 200 * NOTE_SCALE
/** The toolbar's Note dropped on the board at a page point: a note there, pinned if it's on a paper, ready to type in. */
export function dropNoteAt(editor: Editor, point: Pt) {
const id = createShapeId()
editor.markHistoryStoppingPoint('drop a note')
editor.setCurrentTool('select')
queues.get(editor)?.set(id, 'dropped')
editor.createShape({
id,
type: 'note',
x: point.x - DROPPED_NOTE / 2,
y: point.y - DROPPED_NOTE / 2,
props: { scale: NOTE_SCALE, size: 's', font: 'sans', align: 'start', verticalAlign: 'start' },
})
editor.select(id)
startEditingShapeWithRichText(editor, id)
}
|