Skip to content

Commit 9494c8e

Browse files
Jing-yilinclaude
andcommitted
The selected board outlines the element under the pointer again; Space on a control is the control's
Dropping the inspector took its in-board agent, and with it the outline a board drew round each element as the pointer crossed it. The selected board now loads a scripted copy over itself that does only that, fed the canvas's pointer in board px, as the inspected board was. Space no longer presents a board while a button, link or other control has the focus. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ffd038d commit 9494c8e

4 files changed

Lines changed: 92 additions & 11 deletions

File tree

‎canvas/src/App.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,12 @@ import {
5858
CANVAS_FILE_SHAPE_TYPE,
5959
CanvasFileShapeUtil,
6060
} from "./CanvasFileShapeUtil";
61-
import { asCanvasTarget, installDoubleClickZoom, zoomToFill } from "./canvasClicks";
61+
import {
62+
asCanvasTarget,
63+
installBoardHover,
64+
installDoubleClickZoom,
65+
zoomToFill,
66+
} from "./canvasClicks";
6267
import { CanvasPresent } from "./CanvasPresent";
6368
import { attachToChat } from "./canvasAttach";
6469
import { CanvasStatusBannerShapeUtil } from "./CanvasStatusBannerShapeUtil";
@@ -1280,7 +1285,9 @@ export default function App() {
12801285
if (!sync.apply() && !reloaded)
12811286
requestAnimationFrame(() => editor.zoomToFit());
12821287
const disposeZoom = installDoubleClickZoom(editor);
1288+
const disposeHover = installBoardHover(editor);
12831289
return () => {
1290+
disposeHover();
12841291
disposeContent();
12851292
disposeComments();
12861293
disposeZoom();

‎canvas/src/CanvasFileShapeUtil.tsx‎

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import {
66
T,
77
type RecordProps,
88
type TLShape,
9+
useEditor,
910
useIsEditing,
11+
useValue,
1012
} from "tldraw";
1113
import { local } from "./canvasIndex";
1214
import {
@@ -18,6 +20,22 @@ import {
1820

1921
export const CANVAS_FILE_SHAPE_TYPE = "canvas-file" as const;
2022

23+
/**
24+
* Outlines the element under the canvas's pointer, which installBoardHover (canvasClicks.ts)
25+
* posts in board px as `sp:at`; a point off the board is (-1, -1) and clears it. A path is its
26+
* icon's, so an svg outlines whole. ES5, since it runs in whatever the board is.
27+
*/
28+
const HOVER =
29+
"<script>(function(){var d=document.createElement('div');" +
30+
"d.style.cssText='position:fixed;pointer-events:none;z-index:2147483647;display:none;box-sizing:border-box;box-shadow:0 0 0 1px #006EFE';" +
31+
"document.documentElement.appendChild(d);" +
32+
"addEventListener('message',function(e){var m=e.data;if(!m||m.type!=='sp:at')return;" +
33+
"var el=document.elementFromPoint(m.x,m.y);if(el&&el.closest&&el.closest('svg'))el=el.closest('svg');" +
34+
"if(!el||el===document.body||el===document.documentElement){d.style.display='none';return;}" +
35+
"var r=el.getBoundingClientRect(),s=d.style;s.display='block';s.left=r.left+'px';s.top=r.top+'px';s.width=r.width+'px';s.height=r.height+'px';});" +
36+
"})();</" +
37+
"script>";
38+
2139
declare module "tldraw" {
2240
export interface TLGlobalShapePropsMap {
2341
[CANVAS_FILE_SHAPE_TYPE]: {
@@ -35,6 +53,12 @@ export type CanvasFileShape = TLShape<typeof CANVAS_FILE_SHAPE_TYPE>;
3553
function CanvasFile({ shape }: { shape: CanvasFileShape }) {
3654
const isEditing = useIsEditing(shape.id);
3755
const html = useCanvasFileHtml(shape.props.path);
56+
const editor = useEditor();
57+
const selected = useValue(
58+
"board selected",
59+
() => editor.getOnlySelectedShapeId() === shape.id,
60+
[editor, shape.id],
61+
);
3862

3963
// Behind the container, which is transparent, so the frame shows through it. Safari routes a
4064
// wheel to an iframe's own scrolling area whatever pointer-events says, so a two-finger pan
@@ -69,12 +93,33 @@ function CanvasFile({ shape }: { shape: CanvasFileShape }) {
6993
}}
7094
>
7195
{html ? (
72-
<iframe
73-
title={shape.props.name}
74-
srcDoc={html}
75-
sandbox=""
76-
style={{ ...frame, zIndex: isEditing ? undefined : -1 }}
77-
/>
96+
<>
97+
<iframe
98+
title={shape.props.name}
99+
srcDoc={html}
100+
sandbox=""
101+
style={{ ...frame, zIndex: isEditing ? undefined : -2 }}
102+
/>
103+
{/* The selected board outlines the element under the pointer (HOVER). It loads over the
104+
board rather than into its frame: Chrome drops a second srcdoc navigation while the
105+
first is pending and leaves the frame blank, and a remount reloads the mockup under
106+
the click that selected it. Pixel for pixel the same board, so the swap is invisible.
107+
`allow-scripts` and deliberately not `allow-same-origin`, which together would let
108+
the board reach back out into the canvas. */}
109+
{selected ? (
110+
<iframe
111+
title={shape.props.name}
112+
srcDoc={
113+
/<\/body>/i.test(html)
114+
? html.replace(/<\/body>/i, (tag) => HOVER + tag)
115+
: html + HOVER
116+
}
117+
sandbox="allow-scripts"
118+
data-sp-hover={shape.id}
119+
style={{ ...frame, zIndex: isEditing ? undefined : -1 }}
120+
/>
121+
) : null}
122+
</>
78123
) : hasCanvasFile(shape.props.path) ? null : (
79124
// A board that exists but is not in yet renders nothing, so the frame fills in when its
80125
// chunk arrives rather than flashing an error first.

‎canvas/src/CanvasPresent.tsx‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,14 @@ export function CanvasPresent({ editor }: { editor: Editor | null }) {
2222
if (!editor || shown) return;
2323
const onDown = (e: KeyboardEvent) => {
2424
if (e.code !== "Space" || e.repeat) return;
25-
const typing =
25+
// Space on a focused control is that control's: typing, or pressing a button.
26+
const control =
2627
e.target instanceof HTMLElement &&
2728
(e.target.isContentEditable ||
28-
e.target.tagName === "INPUT" ||
29-
e.target.tagName === "TEXTAREA");
30-
if (typing || editor.getEditingShapeId()) return;
29+
e.target.closest(
30+
"input, textarea, select, button, a[href], [role=button], [role=menuitem], [role=tab]",
31+
));
32+
if (control || editor.getEditingShapeId()) return;
3133
const selected = editor.getSelectedShapes();
3234
const target = selected.length === 1 && asCanvasTarget(selected[0]);
3335
if (!target) return;

‎canvas/src/canvasClicks.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type {
22
Editor,
33
StateNode,
4+
TLEventInfo,
45
TLImageShape,
56
TLPageId,
67
TLShape,
@@ -96,3 +97,29 @@ export function installDoubleClickZoom(editor: Editor) {
9697
idle.onDoubleClick = own;
9798
};
9899
}
100+
101+
/**
102+
* The selected board's frame never takes the pointer, so that panning, zooming and the comment
103+
* tool keep working over it: the canvas's pointer goes in as a board coordinate instead, for its
104+
* outline of the element under it (CanvasFileShapeUtil.tsx, HOVER). Returns the uninstaller.
105+
*/
106+
export function installBoardHover(editor: Editor) {
107+
const onEvent = (info: TLEventInfo) => {
108+
if (info.type !== "pointer" || info.name !== "pointer_move") return;
109+
const frame = document.querySelector<HTMLIFrameElement>(
110+
"iframe[data-sp-hover]",
111+
);
112+
if (!frame) return;
113+
const hit = shapeUnderPointer(editor);
114+
const at =
115+
hit?.id === frame.dataset.spHover &&
116+
editor.getCurrentToolId() === "select"
117+
? editor.getPointInShapeSpace(hit, editor.inputs.getCurrentPagePoint())
118+
: { x: -1, y: -1 };
119+
frame.contentWindow?.postMessage({ type: "sp:at", x: at.x, y: at.y }, "*");
120+
};
121+
editor.on("event", onEvent);
122+
return () => {
123+
editor.off("event", onEvent);
124+
};
125+
}

0 commit comments

Comments
 (0)