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