Repository navigation
Inspector behind a switch; double-click zooms to fill; Space presents a board #195
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
01b8cb3
64cebcf
7fd3b00
e95adee
2ad706a
0beacda
541a44c
06b5c6d
ffd038d
9494c8e
013fd4b
9e93f0b
af9a54b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| import { type ReactNode, useEffect, useState } from "react"; | ||
| import { createPortal } from "react-dom"; | ||
| import type { Editor } from "tldraw"; | ||
| import { asCanvasTarget, type CanvasTarget } from "./canvasClicks"; | ||
| import { CANVAS_FILE_SHAPE_TYPE } from "./CanvasFileShapeUtil"; | ||
| import { useCanvasFileHtml } from "./canvasLibrary"; | ||
|
|
||
| /** Screen px between a presented board and the window's edge. */ | ||
| const INSET = 32; | ||
|
|
||
| /** | ||
| * Space on a selected board or picture shows it alone over the whole window (`Present`), scaled | ||
| * to fit it. Taken from tldraw on the key going down, so with one of them selected Space is this | ||
| * and not the hand. | ||
| */ | ||
| export function CanvasPresent({ editor }: { editor: Editor | null }) { | ||
| const [shown, setShown] = useState<CanvasTarget>(); | ||
| const top = window.top!; | ||
| const [view, setView] = useState({ w: top.innerWidth, h: top.innerHeight }); | ||
|
|
||
| useEffect(() => { | ||
| if (!editor || shown) return; | ||
| const onDown = (e: KeyboardEvent) => { | ||
| if (e.code !== "Space" || e.repeat) return; | ||
| // Space on a focused control is that control's: typing, or pressing a button. | ||
| const control = | ||
| e.target instanceof HTMLElement && | ||
| (e.target.isContentEditable || | ||
| e.target.closest( | ||
| "input, textarea, select, button, a[href], [role=button], [role=menuitem], [role=tab]", | ||
| )); | ||
| if (control || editor.getEditingShapeId()) return; | ||
| const selected = editor.getSelectedShapes(); | ||
| const target = selected.length === 1 && asCanvasTarget(selected[0]); | ||
| if (!target) return; | ||
| e.preventDefault(); | ||
| e.stopPropagation(); | ||
| setShown(target); | ||
| }; | ||
| addEventListener("keydown", onDown, true); | ||
| return () => removeEventListener("keydown", onDown, true); | ||
| }, [editor, shown]); | ||
|
|
||
| useEffect(() => { | ||
| const resized = () => setView({ w: top.innerWidth, h: top.innerHeight }); | ||
| top.addEventListener("resize", resized); | ||
| return () => top.removeEventListener("resize", resized); | ||
| }, [top]); | ||
|
|
||
| if (!editor || !shown) return null; | ||
| return ( | ||
| <Present | ||
| close={() => { | ||
| setShown(undefined); | ||
| editor.focus(); | ||
| }} | ||
| > | ||
| <Presented editor={editor} shape={shown} view={view} /> | ||
| </Present> | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Something shown alone over the whole window, the window dimmed round it: a board or picture | ||
| * from the canvas, or a picture in the chat. Space, Esc or a click anywhere comes back to where | ||
| * it was. Inside the window rather than the browser's full screen, which on a Mac moves the window | ||
| * to a display of its own and back, slowly. | ||
| * | ||
| * In the window's document, not the canvas frame's, so it covers the tab bar and the chat as well | ||
| * (the window loads the same stylesheet, shell.tsx). The keys are heard in both documents: the | ||
| * focus can be in either. | ||
| */ | ||
| export function Present({ | ||
| close, | ||
| children, | ||
| }: { | ||
| close: () => void; | ||
| children: ReactNode; | ||
| }) { | ||
| const top = window.top!; | ||
| useEffect(() => { | ||
| const onDown = (e: KeyboardEvent) => { | ||
| if (e.code !== "Space" && e.key !== "Escape") return; | ||
| e.preventDefault(); | ||
| e.stopPropagation(); | ||
| if (!e.repeat) close(); | ||
| }; | ||
| const windows = top === window ? [window] : [window, top]; | ||
| for (const w of windows) w.addEventListener("keydown", onDown, true); | ||
| return () => { | ||
| for (const w of windows) w.removeEventListener("keydown", onDown, true); | ||
| }; | ||
| }, [close, top]); | ||
| return createPortal( | ||
| <div | ||
| className="sp-present" | ||
| onClick={close} | ||
| > | ||
| {children} | ||
| </div>, | ||
| top.document.body, | ||
| ); | ||
| } | ||
|
|
||
| function Presented({ | ||
| editor, | ||
| shape, | ||
| view, | ||
| }: { | ||
| editor: Editor; | ||
| shape: CanvasTarget; | ||
| view: { w: number; h: number }; | ||
| }) { | ||
| const board = shape.type === CANVAS_FILE_SHAPE_TYPE ? shape : undefined; | ||
| const html = useCanvasFileHtml(board?.props.path ?? ""); | ||
| const { w, h } = shape.props; | ||
| if (board) | ||
| return ( | ||
| <iframe | ||
| title={board.props.name} | ||
| srcDoc={html} | ||
| sandbox="" | ||
| style={{ | ||
| width: w, | ||
| height: h, | ||
| scale: String( | ||
| Math.min((view.w - 2 * INSET) / w, (view.h - 2 * INSET) / h), | ||
| ), | ||
| }} | ||
| /> | ||
| ); | ||
| const src = | ||
| shape.type === "image" && shape.props.assetId | ||
| ? editor.getAsset(shape.props.assetId)?.props.src | ||
| : undefined; | ||
| return src ? <img alt="" src={src} /> : null; | ||
|
Comment on lines
+132
to
+136
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Cropped pictures present their original image When a library picture is cropped, Learn moreThe canvas uses tldraw's image rendering and stores crop metadata in the selected shape's Example: Unlock a brand picture, crop it to its left half, and press Space with that picture selected. The presentation shows the entire source, including the hidden right half. Recommended fix: Render the shape's crop and mask in Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,7 +15,7 @@ import { | |
| TRASH, | ||
| } from "./contextMenu"; | ||
| import { DocModeSwitch } from "./DocTab"; | ||
| import { FileText, LogoFigma, Plus } from "./geistIcons"; | ||
| import { FileText, Inspect, LogoFigma, Plus } from "./geistIcons"; | ||
|
|
||
| /** | ||
| * The project's canvases, across the top of the project under the bar's tab for it, after its | ||
|
|
@@ -28,14 +28,16 @@ import { FileText, LogoFigma, Plus } from "./geistIcons"; | |
| * layout.json's, and the folder keeps its slug. An example is the app's, so it has no "+" and | ||
| * no renaming, and a build has no server to make a canvas. | ||
| * | ||
| * At the far end are the controls of the tab in front: a canvas's ground colour, then Export to | ||
| * Figma, the one place a canvas goes from here; a document's switch between reading and editing. | ||
| * At the far end are the controls of the tab in front: a canvas's ground colour, the inspector's | ||
| * switch, then Export to Figma, the one place a canvas goes from here; a document's switch | ||
| * between reading and editing. | ||
| */ | ||
| /** The canvas whose tab is up for renaming, kept across the reload that brings a new one in. */ | ||
| const RENAME_KEY = "sp:rename-canvas"; | ||
|
|
||
| export function CanvasStrip() { | ||
| const { activeTab, openTab, editor } = useContext(CanvasChromeContext); | ||
| const { activeTab, openTab, editor, inspectorOn, setInspectorOn } = | ||
| useContext(CanvasChromeContext); | ||
| const tab = tabFor(activeTab); | ||
| const canvases = tab.kind === "example" ? [tab.slug] : ownCanvases(); | ||
| const here = activeTab.kind === "canvas" ? activeTab.slug : undefined; | ||
|
|
@@ -213,6 +215,18 @@ export function CanvasStrip() { | |
| /> | ||
| </label> | ||
| )} | ||
| {page && ( | ||
| <button | ||
| type="button" | ||
| className="sp-canvas-tabs-inspector" | ||
| aria-pressed={inspectorOn} | ||
| aria-label="Inspector" | ||
| title={inspectorOn ? "Hide the inspector" : "Inspect the selected board"} | ||
| onClick={() => setInspectorOn(!inspectorOn)} | ||
| > | ||
| <Inspect /> | ||
| </button> | ||
|
Comment on lines
+218
to
+228
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Add an Unreleased entry for these controls The new inspector switch, zoom gesture, and presentation mode have no entry under Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| )} | ||
| {/* An anchor, not a button, because the sheet is a page of its own, and the page that | ||
| walks through the import, so ⌘-click and copy-link have to work on it. */} | ||
| {slug && ( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a presented board is clicked, focus moves into the nested
srcDociframe, but keyboard events do not cross iframe boundaries, so neither listener installed here receives the subsequent Space or Escape. Because the overlay has no close button or click-to-dismiss handler, an interactive board can leave the user unable to exit presentation using the documented keys until they manually refocus the outer document; prevent the preview iframe from taking focus or provide a dismissal mechanism inside/over it.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in ffd038d: the presented frame takes no pointer, so it never gets the focus, and a click anywhere closes it. Checked in the browser: click on the board closes; Space then Esc still close.