diff --git a/apps/web/src/features/board/BoardAccessIndicator.tsx b/apps/web/src/features/board/BoardAccessIndicator.tsx deleted file mode 100644 index b071ee5..0000000 --- a/apps/web/src/features/board/BoardAccessIndicator.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useState } from "react"; -import type { SupabaseClient } from "@supabase/supabase-js"; -import { setBoardVisibility } from "./boardOwnership"; - -interface Props { - client: SupabaseClient | null; - boardId: string; - owned: boolean; - isPublic: boolean; -} - -// Compact ownership/sharing chip shown on a board. Only the owner can flip a -// board between private (annotations visible only to them) and shared (anyone -// with the link can collaborate in realtime). Hidden entirely in local-only or -// signed-out contexts, where there is no ownership to express. -export function BoardAccessIndicator({ client, boardId, owned, isPublic }: Props) { - const [pub, setPub] = useState(isPublic); - const [busy, setBusy] = useState(false); - - if (!client || !owned) return null; - - async function toggle() { - setBusy(true); - const next = !pub; - const res = await setBoardVisibility(client, boardId, next); - if (res.ok) setPub(next); - setBusy(false); - } - - return ( -
- - {pub ? "đź”— Shared" : "đź”’ Private"} - - -
- ); -} diff --git a/apps/web/src/features/canvas/AppMenu.tsx b/apps/web/src/features/canvas/AppMenu.tsx index e18d8bc..d1e233e 100644 --- a/apps/web/src/features/canvas/AppMenu.tsx +++ b/apps/web/src/features/canvas/AppMenu.tsx @@ -3,13 +3,18 @@ import { useNavigate } from "react-router-dom"; import type { SupabaseClient } from "@supabase/supabase-js"; import { GlassPanel, Icon, Popover, useTheme, type IconName } from "@notux/ui"; import { + BACKGROUND_PRESETS, exportBoardToPdf, useAssetStore, useCommandStore, usePageStore, + usePrefsStore, + useSettingsStore, useShapeStore, useToolStore, } from "@notux/canvas"; +import type { BackgroundPresetId, GridStyle } from "@notux/sync"; +import { EmbedDialog } from "./EmbedDialog"; import { SnapshotsPanel } from "./SnapshotsPanel"; interface AppMenuProps { @@ -23,20 +28,32 @@ interface MenuItemProps { label: string; shortcut?: string; disabled?: boolean; + /** Renders a trailing checkmark (menu toggle rows). */ + checked?: boolean; onClick(): void; } -function MenuItem({ icon, label, shortcut, disabled, onClick }: MenuItemProps) { +const GRID_OPTIONS: Array<{ id: GridStyle; icon: IconName; label: string }> = [ + { id: "dots", icon: "grid-dots", label: "Dotted" }, + { id: "lines", icon: "grid-lines", label: "Squared" }, + { id: "ruled", icon: "grid-ruled", label: "Ruled" }, + { id: "plain", icon: "grid-plain", label: "Plain" }, +]; + +function MenuItem({ icon, label, shortcut, disabled, checked, onClick }: MenuItemProps) { return ( ); } @@ -74,9 +91,15 @@ export function AppMenu({ boardId, client, owned }: AppMenuProps) { const [snapshotsOpen, setSnapshotsOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false); const [embedOpen, setEmbedOpen] = useState(false); - const [embedUrl, setEmbedUrl] = useState(""); - const [embedError, setEmbedError] = useState(null); const [exporting, setExporting] = useState(false); + const [linkCopied, setLinkCopied] = useState(false); + + const background = useSettingsStore((s) => s.background); + const grid = useSettingsStore((s) => s.grid); + const setBackground = useSettingsStore((s) => s.setBackground); + const setGrid = useSettingsStore((s) => s.setGrid); + const showRemoteCursors = usePrefsStore((s) => s.showRemoteCursors); + const setShowRemoteCursors = usePrefsStore((s) => s.setShowRemoteCursors); const [dragIdx, setDragIdx] = useState(null); const [overIdx, setOverIdx] = useState(null); @@ -144,15 +167,11 @@ export function AppMenu({ boardId, client, owned }: AppMenuProps) { } } - function submitEmbed() { - const err = useAssetStore.getState().insertEmbed(embedUrl); - if (err) { - setEmbedError(err); - return; - } - setEmbedUrl(""); - setEmbedError(null); - setEmbedOpen(false); + function copyShareLink() { + void navigator.clipboard?.writeText(window.location.href).then(() => { + setLinkCopied(true); + window.setTimeout(() => setLinkCopied(false), 1500); + }); } function commitDrop() { @@ -236,7 +255,6 @@ export function AppMenu({ boardId, client, owned }: AppMenuProps) { disabled={!canImport} onClick={() => { setMenuOpen(false); - setEmbedError(null); setEmbedOpen(true); }} /> @@ -249,6 +267,11 @@ export function AppMenu({ boardId, client, owned }: AppMenuProps) { setExportOpen(true); }} /> + run(toggleTheme)} /> +
+ Background + + {(Object.keys(BACKGROUND_PRESETS) as BackgroundPresetId[]).map( + (id) => ( +
+
+ Grid + + {GRID_OPTIONS.map((g) => ( + + ))} + +
+ setShowRemoteCursors(!showRemoteCursors)} + /> - + run(() => zOrder("front"))} /> run(() => zOrder("forward"))} /> run(() => zOrder("backward"))} /> run(() => zOrder("back"))} /> run(toggleLock)} /> - run(deleteSelection)} /> @@ -407,57 +478,12 @@ export function AppMenu({ boardId, client, owned }: AppMenuProps) { {/* Embed-by-URL (YouTube / Google Drive) */} - setEmbedOpen(false)} anchorRef={menuBtnRef} placement="bottom" - className="menu-popover" - > -
-
Embed a link
- { - setEmbedUrl(e.target.value); - if (embedError) setEmbedError(null); - }} - onKeyDown={(e) => { - if (e.key === "Enter") submitEmbed(); - }} - style={{ - width: "100%", - boxSizing: "border-box", - padding: "8px 10px", - borderRadius: 8, - border: "1px solid rgba(255,255,255,0.18)", - background: "rgba(0,0,0,0.25)", - color: "inherit", - outline: "none", - marginTop: 6, - }} - /> - {embedError && ( -
- {embedError} -
- )} -
- Google Drive files must be shared “anyone with the link”. -
- -
-
+ /> p[0]?.toUpperCase() ?? "").join("") || "?"; +} + +function Avatar({ + name, + color, + avatarUrl, + spotlight, + onClick, + title, + self, +}: { + name: string; + color: string; + avatarUrl: string | null; + spotlight?: boolean; + onClick?: () => void; + title: string; + self?: boolean; +}) { + const cls = + "collab-avatar" + + (spotlight ? " collab-avatar--spotlight" : "") + + (self ? " collab-avatar--self" : ""); + const body = avatarUrl ? ( + + ) : ( + initials(name) + ); + if (!onClick) { + return ( + + {body} + + ); + } + return ( + + ); +} + +// Top-right collaboration bar (FigJam-inspired): connected peers, spotlight +// presenting, and sharing. In local-only mode it reduces to the share button. +export function CollabBar({ boardId, client, owned, isPublic, identity }: Props) { + const awareness = useAwareness(); + const peers = useRemoteCursors(awareness); + const spotlighting = useFollowStore((s) => s.spotlighting); + const setSpotlighting = useFollowStore((s) => s.setSpotlighting); + const follow = useFollowStore((s) => s.follow); + + const [shareOpen, setShareOpen] = useState(false); + const [linkCopied, setLinkCopied] = useState(false); + const [pub, setPub] = useState(isPublic); + const [busy, setBusy] = useState(false); + const shareBtnRef = useRef(null); + + const shown = peers.slice(0, MAX_AVATARS); + const overflow = peers.length - shown.length; + + function copyLink() { + void navigator.clipboard?.writeText(window.location.href).then(() => { + setLinkCopied(true); + window.setTimeout(() => setLinkCopied(false), 1500); + }); + } + + async function toggleVisibility() { + if (!client) return; + setBusy(true); + const next = !pub; + const res = await setBoardVisibility(client, boardId, next); + if (res.ok) setPub(next); + setBusy(false); + } + + function peerTitle(p: RemoteCursor): string { + if (p.spotlight) return `${p.name} is presenting — click to follow`; + return p.hasView ? `Follow ${p.name}` : p.name; + } + + return ( + <> + + {awareness && ( + <> + + {shown.map((p) => ( + follow(p.clientID, "manual") : undefined + } + title={peerTitle(p)} + /> + ))} + {overflow > 0 && ( + + +{overflow} + + )} + + + + + + )} + + + + setShareOpen(false)} + anchorRef={shareBtnRef} + placement="bottom" + className="menu-popover" + > +
+
Share
+ + {client && owned && ( + + )} +
+
+ + ); +} diff --git a/apps/web/src/features/canvas/Dock.tsx b/apps/web/src/features/canvas/Dock.tsx index fb8fe2f..3223e8f 100644 --- a/apps/web/src/features/canvas/Dock.tsx +++ b/apps/web/src/features/canvas/Dock.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, type CSSProperties, type Ref } from "react"; +import { useEffect, useRef, useState, type CSSProperties, type Ref } from "react"; import { GlassPanel, Icon, @@ -12,11 +12,13 @@ import type { ToolKind } from "@notux/types"; import { PEN_STYLES, WIDTH_PRESETS, + useAssetStore, useDockStore, useToolStore, type InstrumentId, } from "@notux/canvas"; import { ColorPicker } from "./ColorPicker"; +import { EmbedDialog } from "./EmbedDialog"; // Tools that count as "a shape" for the Shapes button's active state. const SHAPE_TOOLS: ReadonlySet = new Set([ @@ -119,6 +121,7 @@ export function Dock() { const shapesFlyoutOpen = useDockStore((s) => s.shapesFlyoutOpen); const stickyPopoverOpen = useDockStore((s) => s.stickyPopoverOpen); const colorPickerOpen = useDockStore((s) => s.colorPickerOpen); + const importPopoverOpen = useDockStore((s) => s.importPopoverOpen); const selectInstrument = useDockStore((s) => s.selectInstrument); const setPenStyle = useDockStore((s) => s.setPenStyle); @@ -131,8 +134,12 @@ export function Dock() { const setShapesFlyoutOpen = useDockStore((s) => s.setShapesFlyoutOpen); const setStickyPopoverOpen = useDockStore((s) => s.setStickyPopoverOpen); const setColorPickerOpen = useDockStore((s) => s.setColorPickerOpen); + const setImportPopoverOpen = useDockStore((s) => s.setImportPopoverOpen); const closeAllPopovers = useDockStore((s) => s.closeAllPopovers); + const canImport = useAssetStore((s) => s.canImport); + const [embedOpen, setEmbedOpen] = useState(false); + const tool = useToolStore((s) => s.tool); const eraserMode = useToolStore((s) => s.options.eraserMode); const stickyColor = useToolStore((s) => s.options.stickyColor); @@ -146,7 +153,11 @@ export function Dock() { const shapesBtnRef = useRef(null); const stickyBtnRef = useRef(null); const colorBtnRef = useRef(null); + const importBtnRef = useRef(null); const collapsedRef = useRef(null); + const imageInputRef = useRef(null); + const audioInputRef = useRef(null); + const pdfInputRef = useRef(null); // The brush popover (width + opacity, plus pen styles) anchors to whichever // brush tool is active. @@ -194,6 +205,24 @@ export function Dock() { ? { left: position.x, top: position.y, bottom: "auto", transform: "none" } : undefined; + // Whether the dock currently sits in the top half of the viewport (the + // default). Drives collapse-chevron direction; popovers flip on their own. + const inTopHalf = + position === null || + (typeof window !== "undefined" && position.y < window.innerHeight / 2); + + function onImportFiles(files: FileList | null, input: HTMLInputElement) { + if (files && files.length > 0) { + void useAssetStore.getState().importAtCenter(files); + } + input.value = ""; + } + + function importRow(input: HTMLInputElement | null) { + setImportPopoverOpen(false); + input?.click(); + } + if (collapsed) { return ( ); } @@ -230,20 +259,20 @@ export function Dock() { { - useToolStore.getState().setTool("select"); + useToolStore.getState().setTool("hand"); closeAllPopovers(); }} /> { - useToolStore.getState().setTool("hand"); + useToolStore.getState().setTool("select"); closeAllPopovers(); }} /> @@ -260,16 +289,6 @@ export function Dock() { else selectInstrument(useDockStore.getState().penStyle); }} /> - { - if (tool === "eraser") setEraserPopoverOpen(!eraserPopoverOpen); - else selectInstrument("eraser"); - }} - /> + { + if (tool === "eraser") setEraserPopoverOpen(!eraserPopoverOpen); + else selectInstrument("eraser"); + }} + /> + + + - setShapesFlyoutOpen(!shapesFlyoutOpen)} - /> + setShapesFlyoutOpen(!shapesFlyoutOpen)} + /> @@ -325,6 +357,15 @@ export function Dock() { /> + setImportPopoverOpen(!importPopoverOpen)} + /> + @@ -343,7 +384,7 @@ export function Dock() { open={penPopoverOpen && (tool === "pen" || tool === "highlighter")} onClose={() => setPenPopoverOpen(false)} anchorRef={brushAnchor} - placement="top" + placement="auto" tail > {isPenFamily && ( @@ -394,7 +435,7 @@ export function Dock() { open={eraserPopoverOpen && tool === "eraser"} onClose={() => setEraserPopoverOpen(false)} anchorRef={eraserBtnRef} - placement="top" + placement="auto" tail >
@@ -431,7 +472,7 @@ export function Dock() { open={shapesFlyoutOpen} onClose={() => setShapesFlyoutOpen(false)} anchorRef={shapesBtnRef} - placement="top" + placement="auto" tail >
@@ -455,7 +496,7 @@ export function Dock() { open={stickyPopoverOpen && tool === "sticky"} onClose={() => setStickyPopoverOpen(false)} anchorRef={stickyBtnRef} - placement="top" + placement="auto" tail >
@@ -477,6 +518,115 @@ export function Dock() { onClose={() => setColorPickerOpen(false)} anchorRef={colorBtnRef} /> + + {/* Import / embed menu. */} + setImportPopoverOpen(false)} + anchorRef={importBtnRef} + placement="auto" + tail + className="menu-popover" + > +
+
Import
+ importRow(imageInputRef.current)} + /> + importRow(pdfInputRef.current)} + /> + importRow(audioInputRef.current)} + /> +
+ Embed +
+ { + setImportPopoverOpen(false); + setEmbedOpen(true); + }} + /> + { + setImportPopoverOpen(false); + setEmbedOpen(true); + }} + /> +
+
+ + setEmbedOpen(false)} + anchorRef={importBtnRef} + placement="auto" + /> + + onImportFiles(e.target.files, e.target)} + /> + onImportFiles(e.target.files, e.target)} + /> + onImportFiles(e.target.files, e.target)} + /> ); } + +interface MediaMenuItemProps { + icon: IconName; + label: string; + disabled?: boolean; + onClick(): void; +} + +function MediaMenuItem({ icon, label, disabled, onClick }: MediaMenuItemProps) { + return ( + + ); +} diff --git a/apps/web/src/features/canvas/EmbedDialog.tsx b/apps/web/src/features/canvas/EmbedDialog.tsx new file mode 100644 index 0000000..6d09735 --- /dev/null +++ b/apps/web/src/features/canvas/EmbedDialog.tsx @@ -0,0 +1,67 @@ +import { useState, type RefObject } from "react"; +import { Popover } from "@notux/ui"; +import { useAssetStore } from "@notux/canvas"; + +interface Props { + open: boolean; + onClose(): void; + anchorRef: RefObject; + placement?: "top" | "bottom" | "auto"; +} + +/** Embed-by-URL dialog (YouTube / Google Drive), shared by the app menu and + * the dock's import button. */ +export function EmbedDialog({ open, onClose, anchorRef, placement = "bottom" }: Props) { + const [url, setUrl] = useState(""); + const [error, setError] = useState(null); + + function submit() { + const err = useAssetStore.getState().insertEmbed(url); + if (err) { + setError(err); + return; + } + setUrl(""); + setError(null); + onClose(); + } + + return ( + +
+
Embed a link
+ { + setUrl(e.target.value); + if (error) setError(null); + }} + onKeyDown={(e) => { + if (e.key === "Enter") submit(); + }} + /> + {error &&
{error}
} +
+ Google Drive files must be shared “anyone with the link”. +
+ +
+
+ ); +} diff --git a/apps/web/src/features/canvas/FollowPill.tsx b/apps/web/src/features/canvas/FollowPill.tsx new file mode 100644 index 0000000..41c007b --- /dev/null +++ b/apps/web/src/features/canvas/FollowPill.tsx @@ -0,0 +1,31 @@ +import { useAwareness, useFollowStore, useRemoteCursors } from "@notux/canvas"; + +// "Following — tap to stop" pill shown while follow mode is active. +export function FollowPill() { + const awareness = useAwareness(); + const peers = useRemoteCursors(awareness); + const followingClientID = useFollowStore((s) => s.followingClientID); + const stopFollowing = useFollowStore((s) => s.stopFollowing); + + if (followingClientID === null) return null; + const peer = peers.find((p) => p.clientID === followingClientID); + if (!peer) return null; + + return ( + + ); +} diff --git a/apps/web/src/features/canvas/SelectionInspector.tsx b/apps/web/src/features/canvas/SelectionInspector.tsx index 3e97b49..3aeb3c5 100644 --- a/apps/web/src/features/canvas/SelectionInspector.tsx +++ b/apps/web/src/features/canvas/SelectionInspector.tsx @@ -1,8 +1,15 @@ import { useMemo } from "react"; -import type { YShape } from "@notux/types"; +import type { TextAlign, YShape } from "@notux/types"; +import { Icon, type IconName } from "@notux/ui"; import { useShapeStore, useToolStore } from "@notux/canvas"; import { COLORS } from "./palette"; +const ALIGN_OPTIONS: Array<{ id: TextAlign; icon: IconName; label: string }> = [ + { id: "left", icon: "align-left", label: "Align left" }, + { id: "center", icon: "align-center", label: "Align center" }, + { id: "right", icon: "align-right", label: "Align right" }, +]; + interface Props { pageId: string; } @@ -95,6 +102,9 @@ export function SelectionInspector({ pageId }: Props) { const hasColor = selected.some((s) => shapeColor(s) !== undefined); const fillShapes = selected.filter(isFillKind); const textShapes = selected.filter((s) => s.kind === "text"); + const alignShapes = selected.filter( + (s) => s.kind === "text" || s.kind === "sticky", + ); const currentColor = shared(selected, shapeColor); const currentFill = shared(fillShapes, (s) => @@ -104,6 +114,13 @@ export function SelectionInspector({ pageId }: Props) { const currentSize = shared(textShapes, (s) => s.kind === "text" ? s.size : undefined, ); + const currentAlign = shared(alignShapes, (s) => + s.kind === "text" + ? (s.align ?? "left") + : s.kind === "sticky" + ? (s.align ?? "center") + : undefined, + ); const allLocked = shared(selected, (s) => !!s.locked) === true; const opacityValue = @@ -185,6 +202,36 @@ export function SelectionInspector({ pageId }: Props) { />
+ {alignShapes.length > 0 && ( +
+ Align +
+ {ALIGN_OPTIONS.map((a) => ( + + ))} +
+
+ )} + {textShapes.length > 0 && (
Size diff --git a/apps/web/src/routes/Board.tsx b/apps/web/src/routes/Board.tsx index d7abd70..51464e4 100644 --- a/apps/web/src/routes/Board.tsx +++ b/apps/web/src/routes/Board.tsx @@ -4,16 +4,18 @@ import { CanvasStage, useAssetStore, usePageStore, + useSettingsStore, useShapeStore, } from "@notux/canvas"; import { useTheme } from "@notux/ui"; import { AppMenu } from "../features/canvas/AppMenu"; +import { CollabBar } from "../features/canvas/CollabBar"; import { Dock } from "../features/canvas/Dock"; +import { FollowPill } from "../features/canvas/FollowPill"; import { SaveStatus } from "../features/canvas/SaveStatus"; import { SelectionInspector } from "../features/canvas/SelectionInspector"; import { useIdentity } from "../features/canvas/useIdentity"; import { ensureBoardOwnership } from "../features/board/boardOwnership"; -import { BoardAccessIndicator } from "../features/board/BoardAccessIndicator"; import { getSupabase } from "../lib/supabase"; export default function Board() { @@ -43,6 +45,7 @@ export default function Board() { .then(() => { // Seed/migrate the page list against the IndexedDB-hydrated doc. usePageStore.getState().initPages(boardId); + useSettingsStore.getState().initSettings(boardId); setReady(true); // Claim board ownership when signed in — gates named snapshots. void ensureBoardOwnership(client, boardId).then((r) => { @@ -82,12 +85,14 @@ export default function Board() {
- + diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 035d6ae..664d629 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -178,43 +178,154 @@ a { text-decoration: underline; } -.board-access { +/* ----- Collaboration bar (top-right) ------------------------------------- */ + +.collab-bar { position: fixed; top: 16px; right: 16px; + z-index: 30; display: flex; align-items: center; - gap: 8px; - padding: 4px 6px 4px 12px; - border-radius: 999px; - background: var(--glass-tint, rgba(30, 30, 30, 0.75)); - border: 1px solid var(--glass-stroke, rgba(255, 255, 255, 0.12)); - backdrop-filter: blur(12px); - font-size: 12px; - z-index: 20; + gap: 4px; + padding: 4px; + border-radius: 12px; +} + +.collab-bar__avatars { + display: flex; + align-items: center; + padding: 0 2px; +} + +.collab-avatar { + appearance: none; + border: 2px solid var(--bg-1); + width: 28px; + height: 28px; + padding: 0; + border-radius: 50%; + display: grid; + place-items: center; + overflow: hidden; + color: #fff; + font-size: 11px; + font-weight: 700; + flex: none; + margin-left: -6px; + transition: transform 140ms ease; } -.board-access__label { - color: var(--fg-1, rgba(255, 255, 255, 0.7)); +.collab-bar__avatars > :first-child { + margin-left: 0; } -.board-access__toggle { - background: rgba(255, 255, 255, 0.12); - border: 1px solid var(--glass-stroke, rgba(255, 255, 255, 0.12)); - color: var(--fg-0, #fff); +.collab-avatar:is(button) { + cursor: pointer; +} + +.collab-avatar:is(button):hover { + transform: translateY(-2px) scale(1.08); + z-index: 1; +} + +.collab-avatar--spotlight { + box-shadow: 0 0 0 2px var(--accent); +} + +.collab-avatar--overflow { + background: var(--segment-bg); + color: var(--fg-1); +} + +.collab-avatar__img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.collab-bar__btn { + appearance: none; + border: 0; + background: transparent; + color: var(--fg-0); + width: 34px; + height: 34px; + border-radius: 8px; + display: grid; + place-items: center; + cursor: pointer; + transition: background 140ms ease, color 140ms ease; +} + +.collab-bar__btn:hover { + background: var(--glass-hover); +} + +.collab-bar__btn--active { + background: var(--accent); + color: #fff; +} + +.collab-bar__divider { + width: 1px; + height: 20px; + margin: 0 2px; + background: var(--glass-stroke); +} + +/* "Following X" pill, shown under the dock while follow mode is active. */ +.follow-pill { + position: fixed; + top: 76px; + left: 50%; + transform: translateX(-50%); + z-index: 30; + appearance: none; + display: flex; + align-items: center; + gap: 8px; + padding: 7px 14px; border-radius: 999px; - padding: 4px 10px; - font-size: 12px; + border: 1.5px solid var(--accent); + background: var(--popover-bg); + color: var(--fg-0); + font: inherit; + font-size: 13px; + font-weight: 600; + backdrop-filter: blur(18px) saturate(180%); + -webkit-backdrop-filter: blur(18px) saturate(180%); + box-shadow: var(--shadow-lg); cursor: pointer; } -.board-access__toggle:hover { - background: rgba(255, 255, 255, 0.2); +.follow-pill:hover { + background: var(--glass-hover); +} + +@keyframes pill-in { + from { + opacity: 0; + transform: translateX(-50%) translateY(-6px); + } } -.board-access__toggle:disabled { - opacity: 0.5; - cursor: default; +.follow-pill { + animation: pill-in 200ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +@media (prefers-reduced-motion: reduce) { + .follow-pill { + animation: none; + } +} + +.follow-pill__dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex: none; } .board { @@ -331,6 +442,20 @@ a { background: var(--glass-hover); } +.selection-inspector__btn--active { + background: color-mix(in srgb, var(--accent) 24%, transparent); + border-color: var(--accent); +} + +/* Inline segmented button group (e.g. text alignment). */ +.selection-inspector__seg { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + gap: 6px; + flex: 1; +} + .selection-inspector__lock { appearance: none; border: 1px solid var(--glass-stroke); diff --git a/packages/canvas/src/CanvasStage.tsx b/packages/canvas/src/CanvasStage.tsx index 7727833..24b90a4 100644 --- a/packages/canvas/src/CanvasStage.tsx +++ b/packages/canvas/src/CanvasStage.tsx @@ -4,7 +4,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Stage } from "react-konva"; import { newAuthorId } from "./ids"; import { useAwareness } from "./hooks/useAwareness"; +import { useFollowViewport } from "./hooks/useFollowViewport"; import { useUndoManager } from "./hooks/useUndoManager"; +import { useFollowStore } from "./store/followStore"; import { BackgroundLayer } from "./layers/BackgroundLayer"; import { OverlayLayer } from "./layers/OverlayLayer"; import { PresenceLayer } from "./layers/PresenceLayer"; @@ -13,7 +15,14 @@ import { TransformLayer } from "./layers/TransformLayer"; import { useAssetStore } from "./store/assetStore"; import { useCommandStore } from "./store/commandStore"; import { useDraftStore } from "./store/draftStore"; +import { usePrefsStore } from "./store/prefsStore"; +import { useSettingsStore } from "./store/settingsStore"; import { DEFAULT_PAGE_ID } from "./store/pageStore"; +import { resolveInkColor } from "./theme/adaptiveInk"; +import { + effectiveBackground, + isDarkBackground, +} from "./theme/backgroundPresets"; import { useShapeStore } from "./store/shapeStore"; import { useTextEditStore } from "./store/textEditStore"; import { useToolStore } from "./store/toolStore"; @@ -27,9 +36,9 @@ import { screenToWorld, zoomAt } from "./viewport/Viewport"; interface Props { boardId: string; pageId?: string; - // Changing this (the app's active theme) re-renders the Konva layers so they - // re-read CSS-variable colors via cssVar(). The value itself is unused. - theme?: string; + // The app's active theme. Picks the background-preset variant and re-renders + // the Konva layers so they re-read CSS-variable colors via cssVar(). + theme?: "light" | "dark"; } const ZOOM_PER_WHEEL_PIXEL = 0.0015; @@ -59,7 +68,7 @@ function isTypingTarget(t: EventTarget | null): boolean { export function CanvasStage({ boardId: _boardId, pageId = DEFAULT_PAGE_ID, - theme: _theme, + theme = "light", }: Props) { const containerRef = useRef(null); const stageRef = useRef(null); @@ -72,11 +81,17 @@ export function CanvasStage({ const { undo, redo, canUndo, canRedo } = useUndoManager(); const awareness = useAwareness(); + const showRemoteCursors = usePrefsStore((s) => s.showRemoteCursors); + const background = useSettingsStore((s) => s.background); + // Smart ink keys off the paper the shapes actually sit on, not the UI theme. + const darkCanvas = isDarkBackground(effectiveBackground(background, theme)); // Register undo/redo + zoom so the app menu (outside the canvas) can drive // them. Re-registers when handlers or canvas size change. const sizeRef = useRef(size); sizeRef.current = size; + const viewportRef = useRef(viewport); + viewportRef.current = viewport; useEffect(() => { const zoomBy = (factor: number) => setViewport((v) => @@ -93,6 +108,49 @@ export function CanvasStage({ }); }, [undo, redo, canUndo, canRedo]); + // ----- Spotlight / follow mode ------------------------------------------- + const spotlighting = useFollowStore((s) => s.spotlighting); + const followingClientID = useFollowStore((s) => s.followingClientID); + useFollowViewport({ awareness, setViewport, sizeRef }); + + // Publish our spotlight flag so peers auto-follow while we present. + useEffect(() => { + if (!awareness) return; + awareness.setLocalStateField("spotlight", spotlighting ? true : null); + }, [awareness, spotlighting]); + + // Publish our view (world-space screen centre + scale) at ~10 Hz so peers + // can follow us. Skipped while we ourselves follow someone — a follower's + // view is never consumed and would just flood the channel. + const viewTimerRef = useRef(null); + useEffect(() => { + if (!awareness || followingClientID !== null) return; + if (viewTimerRef.current !== null) return; // trailing-edge throttle + viewTimerRef.current = window.setTimeout(() => { + viewTimerRef.current = null; + const v = viewportRef.current; + const s = sizeRef.current; + awareness.setLocalStateField("view", { + cx: (s.w / 2 - v.x) / v.scale, + cy: (s.h / 2 - v.y) / v.scale, + scale: v.scale, + }); + }, 100); + }, [awareness, viewport, size, followingClientID]); + useEffect(() => { + return () => { + if (viewTimerRef.current !== null) window.clearTimeout(viewTimerRef.current); + }; + }, []); + + // Any manual viewport gesture breaks follow mode (and suppresses auto + // re-follow until that presenter re-toggles their spotlight). + const breakFollow = useCallback(() => { + if (useFollowStore.getState().followingClientID !== null) { + useFollowStore.getState().stopFollowing(true); + } + }, []); + // Publish the local cursor (world coords) to awareness, throttled to one // update per animation frame so rapid pointer moves don't flood the channel. const cursorRafRef = useRef(null); @@ -283,6 +341,10 @@ export function CanvasStage({ return () => { if (cursorRafRef.current !== null) cancelAnimationFrame(cursorRafRef.current); awareness?.setLocalStateField("cursor", null); + awareness?.setLocalStateField("view", null); + awareness?.setLocalStateField("spotlight", null); + useFollowStore.getState().stopFollowing(); + useFollowStore.getState().setSpotlighting(false); }; }, [awareness]); @@ -308,6 +370,7 @@ export function CanvasStage({ const onPointerDown = useCallback( (evt: React.PointerEvent) => { const native = evt.nativeEvent; + breakFollow(); if (native.button === 1 || spaceHeld || tool === "hand") { evt.preventDefault(); panRef.current = { active: true, lastX: native.clientX, lastY: native.clientY }; @@ -331,7 +394,7 @@ export function CanvasStage({ evt.currentTarget.setPointerCapture(native.pointerId); toolRef.current.onPointerDown(pointerToToolPoint(native), buildToolContext()); }, - [spaceHeld, tool, viewport, buildToolContext], + [spaceHeld, tool, viewport, buildToolContext, breakFollow], ); const onPointerMove = useCallback( @@ -378,6 +441,7 @@ export function CanvasStage({ const onWheel = useCallback((evt: React.WheelEvent) => { const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; + breakFollow(); const sx = evt.clientX - rect.left; const sy = evt.clientY - rect.top; if (evt.ctrlKey || evt.metaKey) { @@ -390,7 +454,7 @@ export function CanvasStage({ y: v.y - evt.deltaY * PAN_PER_WHEEL_PIXEL, })); } - }, []); + }, [breakFollow]); // File drag-and-drop import. dragover must preventDefault for drop to fire. const onDragOver = useCallback((evt: React.DragEvent) => { @@ -447,6 +511,7 @@ export function CanvasStage({ font: hit.font, size: hit.size, color: hit.color, + align: hit.align ?? "left", }); } else if (hit && hit.kind === "sticky" && !hit.locked) { const pad = 14; @@ -459,6 +524,7 @@ export function CanvasStage({ font: "-apple-system, system-ui, sans-serif", size: hit.fontSize ?? 18, color: "#1c1c1e", + align: hit.align ?? "center", }); } }, @@ -519,10 +585,27 @@ export function CanvasStage({ scaleY={viewport.scale} listening > - - - - + + + + {showRemoteCursors && ( + + )}
); diff --git a/packages/canvas/src/TextEditorOverlay.tsx b/packages/canvas/src/TextEditorOverlay.tsx index c0d9ef6..b3ff3a6 100644 --- a/packages/canvas/src/TextEditorOverlay.tsx +++ b/packages/canvas/src/TextEditorOverlay.tsx @@ -3,6 +3,7 @@ import type { YText } from "@notux/types"; import { newShapeId } from "./ids"; import { useShapeStore } from "./store/shapeStore"; import { useTextEditStore } from "./store/textEditStore"; +import { resolveInkColor } from "./theme/adaptiveInk"; import { useToolStore } from "./store/toolStore"; import type { ViewportState } from "./viewport/Viewport"; @@ -10,6 +11,8 @@ interface Props { viewport: ViewportState; pageId: string; authorId: string; + // Matches the canvas's adaptive-ink rendering so editing is WYSIWYG. + darkCanvas?: boolean; } // HTML