Skip to content

Commit 3a28cdf

Browse files
authored
Merge pull request #198 from ReScienceLab/magic-pen
Magic pen: Option-drag over a board quotes that region in the chat
2 parents b76a786 + db27fa2 commit 3a28cdf

5 files changed

Lines changed: 381 additions & 28 deletions

File tree

‎canvas/src/ChatPanel.tsx‎

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ const OPEN_KEY = "sp-chat-open";
101101
* A board comes as `board`: its name and where the server draws it. Drawing takes seconds, so the
102102
* panel puts its tile and its number up at once and asks for the drawing itself — from here and
103103
* not from the canvas's frame, which a reload or a change of tab would take the answer away with.
104+
* A region the magic pen marked (magicPen.tsx) comes with `crop`, the part of that drawing it is,
105+
* as fractions of it: the server draws at the display's pixel ratio, which only the drawing knows.
104106
*
105107
* On `window`, because the panel is a sibling of `<Tldraw>` and the button renders inside it.
106108
* Here rather than beside the button, so the home page, which has the panel and no canvas, has
@@ -111,7 +113,13 @@ export const CANVAS_ATTACH = "sp:canvas-attach";
111113
export type CanvasAttachDetail =
112114
/** `reference` for anything but a picture, whose drawing is only the tile's: the agent is
113115
* pointed at its file instead. */
114-
| { kind: "board"; name: string; src: string; reference?: true }
116+
| {
117+
kind: "board";
118+
name: string;
119+
src: string;
120+
crop?: { x: number; y: number; w: number; h: number };
121+
reference?: true;
122+
}
115123
| { kind: "image"; file: File }
116124
| { kind: "error"; message: string }
117125
| { kind: "draft"; text: string }
@@ -829,7 +837,12 @@ export function ChatPanel(props: {
829837
* drawing asked of the server, to land in that tile. Asked for again it keeps the tile it has —
830838
* one already there or on its way is only named again, and one that failed is drawn again.
831839
*/
832-
const addBoard = (name: string, src: string, reference?: true) => {
840+
const addBoard = (
841+
name: string,
842+
src: string,
843+
crop?: { x: number; y: number; w: number; h: number },
844+
reference?: true,
845+
) => {
833846
let tile = tray.current.find((t) => t.name === name);
834847
if (!tile && tray.current.length >= MAX_IMAGES)
835848
return setSendError(
@@ -865,7 +878,21 @@ export function ChatPanel(props: {
865878
fetch(src)
866879
.then(async (shot) => {
867880
if (!shot.ok) throw new Error(await shot.text());
868-
const png = await shot.blob();
881+
let png = await shot.blob();
882+
if (crop) {
883+
const whole = await createImageBitmap(png);
884+
const [w, h] = [whole.width, whole.height];
885+
const part = await createImageBitmap(
886+
whole,
887+
Math.round(crop.x * w),
888+
Math.round(crop.y * h),
889+
Math.round(crop.w * w),
890+
Math.round(crop.h * h),
891+
);
892+
const canvas = new OffscreenCanvas(part.width, part.height);
893+
canvas.getContext("2d")!.drawImage(part, 0, 0);
894+
png = await canvas.convertToBlob({ type: "image/png" });
895+
}
869896
void addImages([new File([png], name, { type: png.type })], true);
870897
})
871898
.catch((error) => {
@@ -892,7 +919,7 @@ export function ChatPanel(props: {
892919
// that is still being read.
893920
if (detail.kind === "board") {
894921
adds.current = adds.current.then(() =>
895-
addBoard(detail.name, detail.src, detail.reference),
922+
addBoard(detail.name, detail.src, detail.crop, detail.reference),
896923
);
897924
return;
898925
}

‎canvas/src/canvasAttach.tsx‎

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,35 @@ import { CANVAS_ATTACH, type CanvasAttachDetail } from "./ChatPanel";
1616
import { personsShape, personsShapeName, saveNow } from "./canvasContent";
1717
import { canvasBoardRef } from "./canvasLibrary";
1818
import { Plus } from "./geistIcons";
19-
import {
20-
asCanvasTarget,
21-
shapeUnderPointer,
22-
} from "./canvasClicks";
19+
import { asCanvasTarget, shapeUnderPointer } from "./canvasClicks";
2320

2421
/** A board, a picture the library placed, or anything the person put on a project canvas: what
2522
* the **+** answers for. The canvas answers only for the first two (asCanvasTarget). */
2623
const addable = (editor: Editor, shape: TLShape | undefined): TLShape | null =>
2724
asCanvasTarget(shape) ?? (personsShape(editor, shape) ? shape! : null);
2825

2926
/** To the agent's panel, which is the window's, outside the canvas's frame (AppShell.tsx). */
30-
const dispatchAttach = (detail: CanvasAttachDetail) =>
27+
// oxlint-disable-next-line react/only-export-components
28+
export const dispatchAttach = (detail: CanvasAttachDetail) =>
3129
window.parent.dispatchEvent(new CustomEvent(CANVAS_ATTACH, { detail }));
3230

31+
/** A board by its `<slug>/<file>.html`, and where the server draws it. */
32+
// oxlint-disable-next-line react/only-export-components
33+
export function boardShot(board: CanvasFileShape) {
34+
const { w, h, path } = board.props;
35+
const ref = canvasBoardRef(path);
36+
if (!ref) throw new Error("that board has no file behind it");
37+
const name = `${ref.slug}/${ref.file}`;
38+
// At most 4000 a side, as CanvasFileShapeUtil's toSvg draws one.
39+
const scale = Math.min(1, 4000 / Math.max(w, h));
40+
const src = new URL(
41+
`${import.meta.env.BASE_URL}__sp/shoot?path=${encodeURIComponent(name)}` +
42+
`&w=${Math.max(1, Math.round(w * scale))}&h=${Math.max(1, Math.round(h * scale))}`,
43+
window.location.href,
44+
).href;
45+
return { name, src };
46+
}
47+
3348
/**
3449
* A board or a picture, handed to the chat. A board is a page in an `<iframe>`, so the server
3550
* draws it first (`/__sp/shoot`, server/sp.ts) and it goes over under its own
@@ -43,17 +58,7 @@ async function attach(editor: Editor, target: TLShape) {
4358
// A board, whoever placed it, before the check below that an agent-placed one also passes:
4459
// `toImage` of a board comes back blank, so the server shoots it.
4560
if (target.type === CANVAS_FILE_SHAPE_TYPE) {
46-
const { w, h, path } = (target as CanvasFileShape).props;
47-
const ref = canvasBoardRef(path);
48-
if (!ref) throw new Error("that board has no file behind it");
49-
const name = `${ref.slug}/${ref.file}`;
50-
// At most 4000 a side, as CanvasFileShapeUtil's toSvg draws one.
51-
const scale = Math.min(1, 4000 / Math.max(w, h));
52-
const src = new URL(
53-
`${import.meta.env.BASE_URL}__sp/shoot?path=${encodeURIComponent(name)}` +
54-
`&w=${Math.max(1, Math.round(w * scale))}&h=${Math.max(1, Math.round(h * scale))}`,
55-
window.location.href,
56-
).href;
61+
const { name, src } = boardShot(target as CanvasFileShape);
5762
return dispatchAttach({ kind: "board", name, src, reference: true });
5863
}
5964
// One of the person's own, drawn for the tile and named by where the agent reads the thing

‎canvas/src/canvasChrome.tsx‎

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import {
1010
atom,
1111
type Atom,
1212
ConversionsMenuGroup,
13+
DefaultColorStyle,
1314
DefaultContextMenu,
15+
getColorValue,
1416
DefaultShapeWrapper,
1517
SelectAllMenuItem,
1618
TldrawUiButton,
@@ -42,6 +44,7 @@ import {
4244
CanvasSelectionAttachButton,
4345
} from "./canvasAttach";
4446
import { CommentUserDialog } from "./CommentUserDialog";
47+
import { MagicPen } from "./magicPen";
4548
import { GROUNDS, groundEditable, groundOf, setGround } from "./canvasGround";
4649
import {
4750
linkedBoard,
@@ -235,7 +238,13 @@ function setFresh(fresh: ReadonlyMap<TLShapeId, Fresh>) {
235238

236239
/** Rings `ids` until the pointer passes over each, so the reader sees what just arrived or changed. */
237240
export function markFresh(ids: TLShapeId[], kind: Fresh) {
238-
if (ids.length) setFresh(new Map([...freshShapes().get(), ...ids.map((id) => [id, kind] as const)]));
241+
if (ids.length)
242+
setFresh(
243+
new Map([
244+
...freshShapes().get(),
245+
...ids.map((id) => [id, kind] as const),
246+
]),
247+
);
239248
}
240249

241250
export const canvasChromeComponents: TLComponents = {
@@ -258,7 +267,11 @@ export const canvasChromeComponents: TLComponents = {
258267
useEffect(() => {
259268
if (!fresh) return;
260269
const seen = (info: TLEventInfo) => {
261-
if (info.name !== "pointer_move" || shapeUnderPointer(editor)?.id !== id) return;
270+
if (
271+
info.name !== "pointer_move" ||
272+
shapeUnderPointer(editor)?.id !== id
273+
)
274+
return;
262275
const rest = new Map(freshShapes().get());
263276
rest.delete(id);
264277
setFresh(rest);
@@ -270,7 +283,9 @@ export const canvasChromeComponents: TLComponents = {
270283
<DefaultShapeWrapper
271284
ref={ref}
272285
{...props}
273-
className={fresh ? `${props.className ?? ""} sp-fresh` : props.className}
286+
className={
287+
fresh ? `${props.className ?? ""} sp-fresh` : props.className
288+
}
274289
data-fresh={fresh}
275290
/>
276291
);
@@ -351,15 +366,22 @@ export const canvasChromeComponents: TLComponents = {
351366
const cover =
352367
canvasIndex().served && canvasIndex().project
353368
? (board ?? (over && canvasImageRef(over.id)))
354-
: undefined;
369+
: undefined;
355370
const element =
356371
board && pointedElement.current?.path === (over as CanvasFileShape).props.path
357372
? pointedElement.current.box
358373
: undefined;
359374

360375
// The canvas's ground, the strip's swatch as presets. Custom opens that swatch's picker.
361-
const page = chrome.activeTab.kind === "canvas" ? pageOf(chrome.activeTab) : undefined;
376+
const page =
377+
chrome.activeTab.kind === "canvas" ? pageOf(chrome.activeTab) : undefined;
362378
const ground = page ? groundOf(page) : undefined;
379+
// The colour of what the right-click picked out that can take one: an arrow, a line, a note
380+
// the agent or the person drew. A board or anything else the layout placed is locked.
381+
const colourable = editor
382+
.getSelectedShapes()
383+
.filter((shape) => !shape.isLocked && "color" in shape.props);
384+
const colours = editor.getCurrentTheme().colors[editor.getColorMode()];
363385

364386
return (
365387
<DefaultContextMenu {...props}>
@@ -379,10 +401,14 @@ export const canvasChromeComponents: TLComponents = {
379401
{links.length > 0 && (
380402
<TldrawUiMenuItem
381403
id="copy-link"
382-
label={links.length > 1 ? `Copy ${links.length} links` : "Copy link"}
404+
label={
405+
links.length > 1 ? `Copy ${links.length} links` : "Copy link"
406+
}
383407
icon={<Copy />}
384408
kbd="cmd+c,ctrl+c"
385-
onSelect={() => void navigator.clipboard.writeText(links.join("\n"))}
409+
onSelect={() =>
410+
void navigator.clipboard.writeText(links.join("\n"))
411+
}
386412
/>
387413
)}
388414
{cover && !isExample(cover.slug) && (
@@ -422,13 +448,46 @@ export const canvasChromeComponents: TLComponents = {
422448
checked={!GROUNDS.some(([, color]) => color === ground)}
423449
onSelect={() =>
424450
document
425-
.querySelector<HTMLInputElement>(".sp-canvas-tabs-ground input")
451+
.querySelector<HTMLInputElement>(
452+
".sp-canvas-tabs-ground input",
453+
)
426454
?.showPicker()
427455
}
428456
/>
429457
</TldrawUiMenuSubmenu>
430458
</TldrawUiMenuGroup>
431459
)}
460+
{colourable.length > 0 && (
461+
<TldrawUiMenuGroup id="colour">
462+
<TldrawUiMenuSubmenu id="colour" label="Colour">
463+
{DefaultColorStyle.values.map((color) => (
464+
<TldrawUiMenuItem
465+
key={color}
466+
id={`colour-${color}`}
467+
label={
468+
color[0].toUpperCase() + color.slice(1).replace("-", " ")
469+
}
470+
iconLeft={
471+
<span
472+
className="sp-menu-swatch"
473+
style={{
474+
background: getColorValue(colours, color, "solid"),
475+
}}
476+
/>
477+
}
478+
isSelected={colourable.every(
479+
(shape) => (shape.props as { color: string }).color === color,
480+
)}
481+
// tldraw leaves a locked shape as it is, so a board in the selection too is
482+
// no matter.
483+
onSelect={() => {
484+
editor.setStyleForSelectedShapes(DefaultColorStyle, color);
485+
}}
486+
/>
487+
))}
488+
</TldrawUiMenuSubmenu>
489+
</TldrawUiMenuGroup>
490+
)}
432491
{/* tldraw's items one at a time, not its groups: this canvas is read, and every shape on
433492
it is locked and rebuilt from layout.json, so only what works on a locked shape is
434493
here. A group would bring Cut, Delete and Duplicate, greyed out on every shape here,
@@ -522,6 +581,7 @@ export const canvasChromeComponents: TLComponents = {
522581
whose boards its server draws as it draws the project's own (server/projects.ts). */}
523582
{local() && <CanvasAttachButtons />}
524583
{local() && <CanvasSelectionAttachButton />}
584+
{local() && <MagicPen />}
525585
{/* Out of the tool as well as the bubble. Escape closes only the bubble and leaves the
526586
next click placing another one, which is not what an accidental comment wants. The
527587
draft is kept either way, so a real comment interrupted here is there next time. */}

‎canvas/src/index.css‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1929,6 +1929,22 @@ a.sp-menu-row {
19291929
over it (canvasAttach.tsx). The layer this is drawn on takes no pointer events, so the buttons
19301930
ask for them back. Inside the corner, not over it: a button that crossed the shape's edge would
19311931
leave the shape as the pointer arrived, and take itself away with the hover. */
1932+
/* The magic pen (magicPen.tsx): over the whole canvas while Option is held, and the region it
1933+
marks on a board. */
1934+
.sp-pen {
1935+
position: absolute;
1936+
inset: 0;
1937+
z-index: 1;
1938+
pointer-events: auto;
1939+
}
1940+
1941+
.sp-pen-region {
1942+
position: absolute;
1943+
border: 1.5px dashed var(--ds-focus);
1944+
border-radius: 4px;
1945+
background: color-mix(in srgb, var(--ds-focus) 12%, transparent);
1946+
}
1947+
19321948
.sp-attach {
19331949
position: absolute;
19341950
display: flex;
@@ -2981,3 +2997,11 @@ a.sp-menu-row {
29812997
.sp-chat-made button[data-made="updated"] {
29822998
--ds-focus: light-dark(#1a9338, #3dbe5c);
29832999
}
3000+
3001+
/* A colour's dot in the canvas menu's Colour row (canvasChrome.tsx). */
3002+
.sp-menu-swatch {
3003+
width: 12px;
3004+
height: 12px;
3005+
border-radius: 50%;
3006+
box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 25%, transparent);
3007+
}

0 commit comments

Comments
 (0)