Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/web/src/app/components/MeetsMainContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,8 @@ export default function MeetsMainContent({
closeApp,
setLocked,
refreshState,
isHiddenForMe,
showForMe,
} = useApps();
const { isActive: isGameActive, refresh: refreshGameState } = useGame();
const [isGamesOpen, setIsGamesOpen] = useState(false);
Expand Down Expand Up @@ -1699,6 +1701,8 @@ export default function MeetsMainContent({
isWhiteboardActive,
onOpenWhiteboard: isAdmin ? openWhiteboard : undefined,
onCloseWhiteboard: isAdmin ? closeWhiteboard : undefined,
isWhiteboardHiddenForMe: isHiddenForMe,
onShowWhiteboardForMe: showForMe,
isWatchActive,
onOpenWatch: isAdmin ? openWatch : undefined,
onCloseWatch: isAdmin ? closeWatch : undefined,
Expand Down Expand Up @@ -2119,7 +2123,8 @@ export default function MeetsMainContent({
)}
</div>
) : ActiveMeetingApp &&
(!isDevPlaygroundActive || isDevPlaygroundEnabled) ? (
(!isDevPlaygroundActive || isDevPlaygroundEnabled) &&
!isHiddenForMe ? (
<MeetingAppLayout
app={ActiveMeetingApp}
frameContent={!isDevPlaygroundActive}
Expand Down
49 changes: 41 additions & 8 deletions apps/web/src/app/components/apps/AppsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,17 @@ export function AppsPanel({
maxDockWidth?: number;
onDockWidthChange?: (width: number) => void;
}) {
const { apps, state, openApp, closeApp, setLocked, isAdmin, isReadOnly } =
useApps();
const {
apps,
state,
openApp,
closeApp,
setLocked,
isAdmin,
isReadOnly,
isHiddenForMe,
showForMe,
} = useApps();
const [busyAppId, setBusyAppId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

Expand All @@ -51,8 +60,14 @@ export function AppsPanel({
};

const handlePick = (appId: string) => {
if (!canManage || busyAppId) return;
if (busyAppId) return;
const isActive = state.activeAppId === appId;
// Anyone can bring back an app they hid just for themselves, admin or not.
if (isActive && isHiddenForMe) {
showForMe();
return;
}
if (!canManage) return;
void run(appId, () => (isActive ? closeApp() : openApp(appId)));
};

Expand Down Expand Up @@ -84,16 +99,21 @@ export function AppsPanel({

{apps.map((app) => {
const isActive = state.activeAppId === app.id;
const isHiddenHere = isActive && isHiddenForMe;
const isBusy = busyAppId === app.id;
const interactive = canManage && !busyAppId;
const interactive = (canManage || isHiddenHere) && !busyAppId;
return (
<button
key={app.id}
type="button"
disabled={!interactive}
onClick={() => handlePick(app.id)}
aria-label={
isActive ? `Close ${app.name}` : `Open ${app.name}`
isHiddenHere
? `Show ${app.name} (still open for others)`
: isActive
? `Close ${app.name}`
: `Open ${app.name}`
}
className="group relative flex w-full items-center gap-3 overflow-hidden rounded-xl border p-3 text-left transition-colors disabled:cursor-default"
style={{
Expand All @@ -117,10 +137,16 @@ export function AppsPanel({
<span className="text-[14.5px] font-medium text-[#fafafa]">
{app.name}
</span>
{app.description && (
<span className="truncate text-[12px] text-[#a1a1aa]">
{app.description}
{isHiddenHere ? (
<span className="truncate text-[12px]" style={{ color: color.accent }}>
In use by others — tap to rejoin
</span>
) : (
app.description && (
<span className="truncate text-[12px] text-[#a1a1aa]">
{app.description}
</span>
)
)}
</span>
<span className="flex shrink-0 items-center gap-2">
Expand All @@ -131,6 +157,13 @@ export function AppsPanel({
className="animate-spin text-[#a1a1aa]"
aria-hidden="true"
/>
) : isHiddenHere ? (
<span
className="text-[11.5px] font-medium"
style={{ color: color.accent }}
>
Rejoin
</span>
) : isActive ? (
// The accent border + icon already say "this one is on";
// a quiet dot marks it without LIVE-badge chrome.
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/app/components/controls-config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
AudioLines,
Blocks,
Eye,
Gamepad2,
FileText,
Globe,
Expand Down Expand Up @@ -120,6 +121,8 @@ export interface ControlsBarProps {
isWhiteboardActive?: boolean;
onOpenWhiteboard?: () => void;
onCloseWhiteboard?: () => void;
isWhiteboardHiddenForMe?: boolean;
onShowWhiteboardForMe?: () => void;
isWatchActive?: boolean;
onOpenWatch?: () => void;
onCloseWatch?: () => void;
Expand Down Expand Up @@ -206,6 +209,9 @@ export const BROWSER_APPS: { id: string; name: string; description: string; url:
function canManageWhiteboard(p: ControlsBarProps): boolean {
return Boolean(p.isAdmin && (p.onOpenWhiteboard || p.onCloseWhiteboard));
}
function canShowWhiteboardForMe(p: ControlsBarProps): boolean {
return Boolean(p.isWhiteboardActive && p.isWhiteboardHiddenForMe && p.onShowWhiteboardForMe);
}
function canManageWatch(p: ControlsBarProps): boolean {
return Boolean(p.isAdmin && (p.onOpenWatch || p.onCloseWatch));
}
Expand Down Expand Up @@ -464,6 +470,15 @@ export function buildControlsConfig(p: ControlsBarProps): ControlsConfig {
onPress: () => (p.isWhiteboardActive ? p.onCloseWhiteboard?.() : p.onOpenWhiteboard?.()),
});
}
if (canShowWhiteboardForMe(p)) {
overflow.push({
id: "whiteboard-show-for-me",
icon: Eye,
label: "Show whiteboard",
paletteOnly: appsPanelOwnsApps,
onPress: () => p.onShowWhiteboardForMe?.(),
});
}
if (canManageWatch(p)) {
overflow.push({
id: "watch",
Expand Down
30 changes: 28 additions & 2 deletions packages/apps-sdk/src/apps/whiteboard/core/tools/engine.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Point, WhiteboardElement } from "../model/types";
import { createId, addElement, updateElement, removeElement, getPageElements } from "../doc/index";
import { hitTestElement, translateElement } from "../model/geometry";
import { getBoundsForElement, hitTestElement, translateElement } from "../model/geometry";
import { computeSnapAdjustment, type SnapGuide } from "./snapping";
import type * as Y from "yjs";

export type ToolKind =
Expand Down Expand Up @@ -64,6 +65,8 @@ export class ToolEngine {
private dragStart: Point | null = null;
private lastPoint: Point | null = null;
private selectedId: string | null = null;
private snapThresholdCanvasUnits = 0;
private snapGuides: SnapGuide[] = [];

constructor(doc: Y.Doc, pageId: string, tool: ToolKind, settings: ToolSettings) {
this.doc = doc;
Expand All @@ -77,6 +80,7 @@ export class ToolEngine {
this.activeElementId = null;
this.dragStart = null;
this.lastPoint = null;
this.snapGuides = [];
if (tool !== "select" && tool !== "pan") {
this.selectedId = null;
}
Expand All @@ -88,11 +92,20 @@ export class ToolEngine {
this.settings = settings;
}

setSnapThreshold(canvasUnits: number) {
this.snapThresholdCanvasUnits = canvasUnits;
}

getSnapGuides(): SnapGuide[] {
return this.snapGuides;
}

setPage(pageId: string) {
this.pageId = pageId;
this.activeElementId = null;
this.dragStart = null;
this.lastPoint = null;
this.snapGuides = [];
}

getSelectedId() {
Expand Down Expand Up @@ -268,8 +281,20 @@ export class ToolEngine {
const dx = point.x - this.lastPoint.x;
const dy = point.y - this.lastPoint.y;
if (dx === 0 && dy === 0) return;
const next = translateElement(element, dx, dy);
const prospective = translateElement(element, dx, dy);
const otherBounds = elements
.filter((item) => item.id !== element.id)
.map(getBoundsForElement);
const adjustment = computeSnapAdjustment(
getBoundsForElement(prospective),
otherBounds,
this.snapThresholdCanvasUnits
);
this.snapGuides = adjustment.guides;
const next = translateElement(element, dx + adjustment.dx, dy + adjustment.dy);
Comment on lines +293 to +294

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Snapped Drag Cannot Detach Gradually

After an element snaps, the next candidate starts from that already-snapped position but uses only the latest raw pointer delta. Moving away by less than the threshold per frame therefore snaps the element back on every move, so it remains stuck until one pointer event crosses the full threshold. The candidate position needs to come from an unsnapped drag origin or account for the previous snap offset.

Artifacts

Repro: executable TypeScript harness using the real whiteboard tool engine and Yjs document helpers

  • Contains supporting evidence from the run (text/typescript; charset=utf-8).

Repro: runtime trace showing raw pointer deltas, cumulative movement, bounds, snap guides, and the passing bug assertion

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

Fix in Codex Fix in Claude Code Fix in Cursor

updateElement(this.doc, this.pageId, next);
// Keep lastPoint raw (unsnapped) so small moves within the snap radius
// don't accumulate drift between the cursor and the element.
this.lastPoint = point;
return;
}
Expand Down Expand Up @@ -301,6 +326,7 @@ export class ToolEngine {
this.dragStart = null;
this.lastPoint = null;
this.activeElementId = null;
this.snapGuides = [];
}

private eraseAtPoint(point: Point) {
Expand Down
94 changes: 94 additions & 0 deletions packages/apps-sdk/src/apps/whiteboard/core/tools/snapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { Bounds } from "../model/geometry";

export type SnapEdge = "start" | "center" | "end";
export type SnapGuide = { axis: "x" | "y"; position: number; spanStart: number; spanEnd: number };
export type SnapAdjustment = { dx: number; dy: number; guides: SnapGuide[] };

const ALL_EDGES: SnapEdge[] = ["start", "center", "end"];

const edgeValue = (bounds: Bounds, axis: "x" | "y", edge: SnapEdge): number => {
const start = axis === "x" ? bounds.x : bounds.y;
const size = axis === "x" ? bounds.width : bounds.height;
if (edge === "start") return start;
if (edge === "end") return start + size;
return start + size / 2;
};

type AxisSnap = { offset: number; guide: SnapGuide };

const computeAxisSnap = (
movingBounds: Bounds,
otherBounds: Bounds[],
axis: "x" | "y",
edges: SnapEdge[],
threshold: number
): AxisSnap | null => {
let best: { offset: number; distance: number; position: number; other: Bounds } | null = null;

for (const edge of edges) {
const value = edgeValue(movingBounds, axis, edge);
for (const other of otherBounds) {
for (const otherEdge of ALL_EDGES) {
const target = edgeValue(other, axis, otherEdge);
const distance = Math.abs(value - target);
if (distance <= threshold && (!best || distance < best.distance)) {
best = { offset: target - value, distance, position: target, other };
}
}
}
}

if (!best) return null;

const crossAxis = axis === "x" ? "y" : "x";
const movingStart = crossAxis === "x" ? movingBounds.x : movingBounds.y;
const movingSize = crossAxis === "x" ? movingBounds.width : movingBounds.height;
const otherStart = crossAxis === "x" ? best.other.x : best.other.y;
const otherSize = crossAxis === "x" ? best.other.width : best.other.height;

return {
offset: best.offset,
guide: {
axis,
position: best.position,
spanStart: Math.min(movingStart, otherStart),
spanEnd: Math.max(movingStart + movingSize, otherStart + otherSize),
},
};
};

/**
* Compares movingBounds' edges/centers against every bounds in otherBounds on
* each axis independently, and returns the delta needed to align to the
* closest match within threshold (0 if nothing is close enough to snap).
*/
export function computeSnapAdjustment(
movingBounds: Bounds,
otherBounds: Bounds[],
thresholdCanvasUnits: number,
options?: { xEdges?: SnapEdge[]; yEdges?: SnapEdge[] }
): SnapAdjustment {
if (thresholdCanvasUnits <= 0 || otherBounds.length === 0) {
return { dx: 0, dy: 0, guides: [] };
}

const xEdges = options?.xEdges ?? ALL_EDGES;
const yEdges = options?.yEdges ?? ALL_EDGES;
const guides: SnapGuide[] = [];
let dx = 0;
let dy = 0;

const xSnap = computeAxisSnap(movingBounds, otherBounds, "x", xEdges, thresholdCanvasUnits);
if (xSnap) {
dx = xSnap.offset;
guides.push(xSnap.guide);
}

const ySnap = computeAxisSnap(movingBounds, otherBounds, "y", yEdges, thresholdCanvasUnits);
if (ySnap) {
dy = ySnap.offset;
guides.push(ySnap.guide);
}

return { dx, dy, guides };
}
Loading