feat(refboard): canvas polish — transform box sync, dot grid, snap tuning, perf fixes

- Fix transform box not updating after alignment/arrangement/normalize/flip operations
  (shortcuts, context menu, and selection toolbar all fixed with DRY _opUpdate helper)
- Replace 100ms polling loop with event-driven viewport updates (moved + wheel-scroll)
- Add adaptive dot grid background that responds to zoom/pan
- Reduce snap guide threshold from 8px to 4px for subtler snapping
- Remove PresenceOverlay (remote selection highlighting) — too heavy for minimal benefit
- Offset multiple dropped images so they don't overlap
- Add new canvas modules: SnapGuides, clipboard, grouping, FrameSprite, DrawingSprite,
  LaserPointer, context-menu-items, SelectionToolbar, Minimap
- Extract Editor hooks into dedicated files (useBoardLoader, useCanvasSetup,
  useShortcutHandler, useLayerPanel, useSaveManager, useFollowMode)
- Sync improvements: real-time transform broadcast, board rooms, viewport sync
This commit is contained in:
Hiren Kangad
2026-03-10 03:58:53 +05:30
parent cfd7adf73d
commit e9509ef7b0
41 changed files with 4171 additions and 705 deletions
+299 -122
View File
@@ -13,9 +13,103 @@
* - Added Ctrl+S (prevent browser save-as)
*/
import { ShortcutDef } from './shortcuts';
import { ShortcutDef, ShortcutContext } from './shortcuts';
import type { SceneItem, SceneManager } from './SceneManager';
import type { GroupObject } from './scene-format';
import * as ops from './operations';
// Tracks when the last internal copy happened so paste can decide
// whether to use internal clipboard (just copied) vs system clipboard (external app).
let _lastInternalCopyTime = 0;
/** Mark that an internal copy just happened. Called by copy/cut handlers. */
export function markInternalCopy(): void {
_lastInternalCopyTime = Date.now();
}
/** Collect selected items + their group children (for deep clone). */
function _collectGroupChildren(items: SceneItem[], scene: SceneManager): SceneItem[] {
const all: SceneItem[] = [];
const seen = new Set<string>();
for (const item of items) {
if (seen.has(item.id)) continue;
seen.add(item.id);
all.push(item);
if (item.data.type === 'group') {
const gd = item.data as GroupObject;
for (const cid of gd.children) {
if (seen.has(cid)) continue;
seen.add(cid);
const child = scene.getById(cid);
if (child) all.push(child);
}
}
}
return all;
}
/** Deep-clone a list of scene items, remapping group children IDs. */
function _cloneItemsWithOffset(
items: { data: any }[],
scene: { nextZ: () => number },
offsetX = 20,
offsetY = 20,
): any[] {
// First pass: build old→new ID mapping for all items
const idMap = new Map<string, string>();
for (const item of items) {
idMap.set(item.data.id, crypto.randomUUID());
}
// Second pass: clone data with new IDs and remapped children
const clones: any[] = [];
for (const item of items) {
const newId = idMap.get(item.data.id)!;
const newData = {
...item.data,
id: newId,
x: item.data.x + offsetX,
y: item.data.y + offsetY,
z: scene.nextZ(),
};
// Remap group children references
if (newData.type === 'group' && Array.isArray(newData.children)) {
newData.children = newData.children
.map((cid: string) => idMap.get(cid) ?? cid)
.filter((cid: string) => idMap.has(cid) || items.some((i) => i.data.id === cid));
}
clones.push(newData);
}
return clones;
}
/** Paste from internal clipboard — duplicates scene items with offset. */
async function _pasteInternal(ctx: ShortcutContext): Promise<void> {
const clones = _cloneItemsWithOffset(ctx.clipboardRef.current, ctx.scene);
const newItems: typeof ctx.clipboardRef.current = [];
// Create children first, then groups (so children exist when group is created)
const sorted = [...clones].sort((a, b) =>
(a.type === 'group' ? 1 : 0) - (b.type === 'group' ? 1 : 0));
for (const newData of sorted) {
await ctx.scene._createItem(newData, true);
const newItem = ctx.scene.getById(newData.id);
if (newItem) newItems.push(newItem);
}
ctx.clipboardRef.current = newItems;
ctx.scene._applyZOrder();
ctx.onChange();
}
/** Run op on selected items → update transform box → fire onChange. */
function _opUpdate(ctx: ShortcutContext, op: (items: SceneItem[]) => void): void {
const items = ctx.selection.getSelectedItems();
op(items);
ctx.selection.transformBox.update(items);
ctx.onChange();
}
export const shortcuts: ShortcutDef[] = [
// ═══════════════════════════════════════
@@ -25,34 +119,22 @@ export const shortcuts: ShortcutDef[] = [
{
id: 'align-left', keys: { key: 'arrowleft', ctrl: true },
category: 'alignment', description: 'Align left', needsSelection: true, minSelection: 2,
handler: (ctx) => {
ops.alignLeft(ctx.selection.getSelectedItems());
ctx.onChange();
},
handler: (ctx) => _opUpdate(ctx, ops.alignLeft),
},
{
id: 'align-right', keys: { key: 'arrowright', ctrl: true },
category: 'alignment', description: 'Align right', needsSelection: true, minSelection: 2,
handler: (ctx) => {
ops.alignRight(ctx.selection.getSelectedItems());
ctx.onChange();
},
handler: (ctx) => _opUpdate(ctx, ops.alignRight),
},
{
id: 'align-top', keys: { key: 'arrowup', ctrl: true },
category: 'alignment', description: 'Align top', needsSelection: true, minSelection: 2,
handler: (ctx) => {
ops.alignTop(ctx.selection.getSelectedItems());
ctx.onChange();
},
handler: (ctx) => _opUpdate(ctx, ops.alignTop),
},
{
id: 'align-bottom', keys: { key: 'arrowdown', ctrl: true },
category: 'alignment', description: 'Align bottom', needsSelection: true, minSelection: 2,
handler: (ctx) => {
ops.alignBottom(ctx.selection.getSelectedItems());
ctx.onChange();
},
handler: (ctx) => _opUpdate(ctx, ops.alignBottom),
},
// ═══════════════════════════════════════
@@ -62,18 +144,12 @@ export const shortcuts: ShortcutDef[] = [
{
id: 'distribute-h', keys: { key: 'arrowup', ctrl: true, alt: true, shift: true },
category: 'alignment', description: 'Distribute horizontal', needsSelection: true, minSelection: 3,
handler: (ctx) => {
ops.distributeHorizontal(ctx.selection.getSelectedItems());
ctx.onChange();
},
handler: (ctx) => _opUpdate(ctx, ops.distributeHorizontal),
},
{
id: 'distribute-v', keys: { key: 'arrowdown', ctrl: true, alt: true, shift: true },
category: 'alignment', description: 'Distribute vertical', needsSelection: true, minSelection: 3,
handler: (ctx) => {
ops.distributeVertical(ctx.selection.getSelectedItems());
ctx.onChange();
},
handler: (ctx) => _opUpdate(ctx, ops.distributeVertical),
},
// ═══════════════════════════════════════
@@ -83,34 +159,22 @@ export const shortcuts: ShortcutDef[] = [
{
id: 'normalize-size', keys: { key: 'arrowup', ctrl: true, alt: true },
category: 'normalize', description: 'Normalize size (same area)', needsSelection: true, minSelection: 2,
handler: (ctx) => {
ops.normalizeSize(ctx.selection.getSelectedItems());
ctx.onChange();
},
handler: (ctx) => _opUpdate(ctx, ops.normalizeSize),
},
{
id: 'normalize-scale', keys: { key: 'arrowdown', ctrl: true, alt: true },
category: 'normalize', description: 'Normalize scale', needsSelection: true, minSelection: 2,
handler: (ctx) => {
ops.normalizeScale(ctx.selection.getSelectedItems());
ctx.onChange();
},
handler: (ctx) => _opUpdate(ctx, ops.normalizeScale),
},
{
id: 'normalize-height', keys: { key: 'arrowleft', ctrl: true, alt: true },
category: 'normalize', description: 'Normalize height', needsSelection: true, minSelection: 2,
handler: (ctx) => {
ops.normalizeHeight(ctx.selection.getSelectedItems());
ctx.onChange();
},
handler: (ctx) => _opUpdate(ctx, ops.normalizeHeight),
},
{
id: 'normalize-width', keys: { key: 'arrowright', ctrl: true, alt: true },
category: 'normalize', description: 'Normalize width', needsSelection: true, minSelection: 2,
handler: (ctx) => {
ops.normalizeWidth(ctx.selection.getSelectedItems());
ctx.onChange();
},
handler: (ctx) => _opUpdate(ctx, ops.normalizeWidth),
},
// ═══════════════════════════════════════
@@ -120,42 +184,27 @@ export const shortcuts: ShortcutDef[] = [
{
id: 'arrange-optimal', keys: { key: 'p', ctrl: true, shift: true },
category: 'arrangement', description: 'Arrange optimal (pack)', needsSelection: true, minSelection: 2,
handler: (ctx) => {
ops.arrangeOptimal(ctx.selection.getSelectedItems());
ctx.onChange();
},
handler: (ctx) => _opUpdate(ctx, ops.arrangeOptimal),
},
{
id: 'arrange-by-name', keys: { key: 'n', ctrl: true, alt: true },
category: 'arrangement', description: 'Arrange by name', needsSelection: true, minSelection: 2,
handler: (ctx) => {
ops.arrangeByName(ctx.selection.getSelectedItems());
ctx.onChange();
},
handler: (ctx) => _opUpdate(ctx, ops.arrangeByName),
},
{
id: 'arrange-by-order', keys: { key: 'o', ctrl: true, alt: true },
category: 'arrangement', description: 'Arrange by z-order', needsSelection: true, minSelection: 2,
handler: (ctx) => {
ops.arrangeByZOrder(ctx.selection.getSelectedItems());
ctx.onChange();
},
handler: (ctx) => _opUpdate(ctx, ops.arrangeByZOrder),
},
{
id: 'arrange-random', keys: { key: 'r', ctrl: true, alt: true },
category: 'arrangement', description: 'Arrange randomly', needsSelection: true, minSelection: 2,
handler: (ctx) => {
ops.arrangeRandomly(ctx.selection.getSelectedItems());
ctx.onChange();
},
handler: (ctx) => _opUpdate(ctx, ops.arrangeRandomly),
},
{
id: 'stack', keys: { key: 's', ctrl: true, alt: true },
category: 'arrangement', description: 'Stack (pile on top)', needsSelection: true, minSelection: 2,
handler: (ctx) => {
ops.stackObjects(ctx.selection.getSelectedItems());
ctx.onChange();
},
handler: (ctx) => _opUpdate(ctx, ops.stackObjects),
},
// ═══════════════════════════════════════
@@ -165,26 +214,17 @@ export const shortcuts: ShortcutDef[] = [
{
id: 'flip-h', keys: { key: 'h', alt: true, shift: true },
category: 'image', description: 'Flip horizontal', needsSelection: true,
handler: (ctx) => {
ops.flipHorizontal(ctx.selection.getSelectedItems());
ctx.onChange();
},
handler: (ctx) => _opUpdate(ctx, ops.flipHorizontal),
},
{
id: 'flip-v', keys: { key: 'v', alt: true, shift: true },
category: 'image', description: 'Flip vertical', needsSelection: true,
handler: (ctx) => {
ops.flipVertical(ctx.selection.getSelectedItems());
ctx.onChange();
},
handler: (ctx) => _opUpdate(ctx, ops.flipVertical),
},
{
id: 'reset-transform', keys: { key: 't', ctrl: true, shift: true },
category: 'image', description: 'Reset transform', needsSelection: true,
handler: (ctx) => {
ops.resetTransform(ctx.selection.getSelectedItems());
ctx.onChange();
},
handler: (ctx) => _opUpdate(ctx, ops.resetTransform),
},
{
id: 'toggle-grayscale', keys: { key: 'g', alt: true },
@@ -199,16 +239,95 @@ export const shortcuts: ShortcutDef[] = [
category: 'image', description: 'Toggle locked', needsSelection: true,
handler: (ctx) => {
ops.toggleLocked(ctx.selection.getSelectedItems());
ctx.onChange();
ctx.refreshLayers();
},
},
{
id: 'overlay-compare', keys: { key: 'y', ctrl: true, shift: true },
category: 'image', description: 'Overlay / compare', needsSelection: true, minSelection: 2,
handler: (ctx) => {
ops.overlayCompare(ctx.selection.getSelectedItems());
ctx.onChange();
},
handler: (ctx) => _opUpdate(ctx, ops.overlayCompare),
},
// ═══════════════════════════════════════
// NUDGE (Arrow keys with selection)
// ═══════════════════════════════════════
// Shift+Arrow = 10px nudge (more specific, checked first)
{
id: 'nudge-left-10', keys: { key: 'arrowleft', shift: true },
category: 'arrangement', description: 'Nudge left 10px', needsSelection: true,
handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, -10, 0)),
},
{
id: 'nudge-right-10', keys: { key: 'arrowright', shift: true },
category: 'arrangement', description: 'Nudge right 10px', needsSelection: true,
handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 10, 0)),
},
{
id: 'nudge-up-10', keys: { key: 'arrowup', shift: true },
category: 'arrangement', description: 'Nudge up 10px', needsSelection: true,
handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 0, -10)),
},
{
id: 'nudge-down-10', keys: { key: 'arrowdown', shift: true },
category: 'arrangement', description: 'Nudge down 10px', needsSelection: true,
handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 0, 10)),
},
// ═══════════════════════════════════════
// SCALE & ROTATE (selection)
// ═══════════════════════════════════════
{
id: 'scale-up', keys: { key: '=', alt: true },
category: 'image', description: 'Scale up 10%', needsSelection: true,
handler: (ctx) => _opUpdate(ctx, (items) => ops.scaleBy(items, 1.1)),
},
{
id: 'scale-down', keys: { key: '-', alt: true },
category: 'image', description: 'Scale down 10%', needsSelection: true,
handler: (ctx) => _opUpdate(ctx, (items) => ops.scaleBy(items, 1 / 1.1)),
},
{
id: 'rotate-cw', keys: { key: 'r' },
category: 'image', description: 'Rotate 90° clockwise', needsSelection: true,
handler: (ctx) => _opUpdate(ctx, (items) => ops.rotate90(items, true)),
},
{
id: 'rotate-ccw', keys: { key: 'r', shift: true },
category: 'image', description: 'Rotate 90° counter-clockwise', needsSelection: true,
handler: (ctx) => _opUpdate(ctx, (items) => ops.rotate90(items, false)),
},
// ═══════════════════════════════════════
// ALIGN CENTER
// ═══════════════════════════════════════
{
id: 'align-center-h', keys: { key: 'arrowleft', ctrl: true, shift: true },
category: 'alignment', description: 'Align center horizontal', needsSelection: true, minSelection: 2,
handler: (ctx) => _opUpdate(ctx, ops.alignCenterH),
},
{
id: 'align-center-v', keys: { key: 'arrowup', ctrl: true, shift: true },
category: 'alignment', description: 'Align center vertical', needsSelection: true, minSelection: 2,
handler: (ctx) => _opUpdate(ctx, ops.alignCenterV),
},
// ═══════════════════════════════════════
// EQUAL SPACING
// ═══════════════════════════════════════
{
id: 'equal-spacing-h', keys: { key: 'h', ctrl: true, shift: true },
category: 'arrangement', description: 'Equal horizontal spacing', needsSelection: true, minSelection: 2,
handler: (ctx) => _opUpdate(ctx, ops.equalSpacingH),
},
{
id: 'equal-spacing-v', keys: { key: 'v', ctrl: true, shift: true },
category: 'arrangement', description: 'Equal vertical spacing', needsSelection: true, minSelection: 2,
handler: (ctx) => _opUpdate(ctx, ops.equalSpacingV),
},
// ═══════════════════════════════════════
@@ -227,32 +346,55 @@ export const shortcuts: ShortcutDef[] = [
category: 'navigation', description: 'Fit all in view',
handler: (ctx) => ctx.fitAll(),
},
// Bare arrow Left/Right only cycle when nothing is selected.
// When something IS selected, they're no-ops (prevent accidental cycling).
// Layer ordering uses ] / [ to avoid arrow conflicts.
{
id: 'cycle-next', keys: { key: 'arrowright' },
category: 'navigation', description: 'Select next object',
id: 'focus-selection', keys: { key: 'f' },
category: 'navigation', description: 'Fit selection in view', needsSelection: true,
handler: (ctx) => ctx.fitSelection(),
},
{
id: 'toggle-focus-mode', keys: { key: 'tab' },
category: 'view', description: 'Toggle focus mode (hide UI)',
handler: (ctx) => ctx.toggleFocusMode(),
},
// Bare arrows: nudge 1px if selection, cycle if no selection
{
id: 'nudge-or-cycle-right', keys: { key: 'arrowright' },
category: 'navigation', description: 'Nudge 1px / Select next',
handler: (ctx) => {
if (ctx.selection.selectedIds.size > 0) return;
const all = ctx.scene.getAllItems();
if (all.length === 0) return;
// Sort by z to get consistent order
all.sort((a, b) => a.data.z - b.data.z);
ctx.selection.selectOnly(all[0].id);
if (ctx.selection.selectedIds.size > 0) {
_opUpdate(ctx, (items) => ops.nudge(items, 1, 0));
} else {
const all = ctx.scene.getAllItems();
if (all.length === 0) return;
all.sort((a, b) => a.data.z - b.data.z);
ctx.selection.selectOnly(all[0].id);
}
},
},
{
id: 'cycle-prev', keys: { key: 'arrowleft' },
category: 'navigation', description: 'Select previous object',
id: 'nudge-or-cycle-left', keys: { key: 'arrowleft' },
category: 'navigation', description: 'Nudge 1px / Select prev',
handler: (ctx) => {
if (ctx.selection.selectedIds.size > 0) return;
const all = ctx.scene.getAllItems();
if (all.length === 0) return;
all.sort((a, b) => a.data.z - b.data.z);
ctx.selection.selectOnly(all[all.length - 1].id);
if (ctx.selection.selectedIds.size > 0) {
_opUpdate(ctx, (items) => ops.nudge(items, -1, 0));
} else {
const all = ctx.scene.getAllItems();
if (all.length === 0) return;
all.sort((a, b) => a.data.z - b.data.z);
ctx.selection.selectOnly(all[all.length - 1].id);
}
},
},
{
id: 'nudge-up', keys: { key: 'arrowup' },
category: 'navigation', description: 'Nudge up 1px', needsSelection: true,
handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 0, -1)),
},
{
id: 'nudge-down', keys: { key: 'arrowdown' },
category: 'navigation', description: 'Nudge down 1px', needsSelection: true,
handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 0, 1)),
},
// Layer ordering: ] brings forward, [ sends backward
{
id: 'send-to-front', keys: { key: ']' },
@@ -311,7 +453,9 @@ export const shortcuts: ShortcutDef[] = [
handler: (ctx) => {
const selected = ctx.selection.getSelectedItems();
if (selected.length > 0) {
ctx.clipboardRef.current = [...selected];
// Include group children in clipboard for proper paste
ctx.clipboardRef.current = _collectGroupChildren(selected, ctx.scene);
markInternalCopy();
ctx.writeCanvasToClipboard(selected);
ctx.showToast('Copied');
} else {
@@ -332,24 +476,36 @@ export const shortcuts: ShortcutDef[] = [
id: 'paste', keys: { key: 'v', ctrl: true },
category: 'editing', description: 'Paste',
handler: async (ctx) => {
if (ctx.clipboardRef.current.length === 0) return;
const newItems: typeof ctx.clipboardRef.current = [];
for (const original of ctx.clipboardRef.current) {
// Clone: duplicate the item data with new ID and offset position
const newData = {
...original.data,
id: crypto.randomUUID(),
x: original.data.x + 20,
y: original.data.y + 20,
z: ctx.scene.nextZ(),
};
await ctx.scene._createItem(newData, true);
const newItem = ctx.scene.getById(newData.id);
if (newItem) newItems.push(newItem);
// Strategy: Check system clipboard for images first.
// - If system clipboard has an image AND we did NOT just do an internal copy
// (or it's been a while), paste from system clipboard (external image).
// - If we just did an internal copy (lastInternalCopyTime is recent),
// use internal clipboard to duplicate scene items (preserves vector data).
// - If system clipboard has no images, fall back to internal clipboard.
const timeSinceInternalCopy = Date.now() - _lastInternalCopyTime;
const hasInternalItems = ctx.clipboardRef.current.length > 0;
const recentInternalCopy = hasInternalItems && timeSinceInternalCopy < 500;
// If we JUST did an internal copy (<500ms ago), use internal clipboard
// (the system clipboard image is just the rasterized version of what we copied)
if (recentInternalCopy) {
await _pasteInternal(ctx);
return;
}
// Try system clipboard first
try {
const result = await ctx.pasteFromSystemClipboard();
if (result === 'Pasted image') return;
} catch {
// Clipboard API denied or unavailable — fall through
}
// Fall back to internal clipboard
if (hasInternalItems) {
await _pasteInternal(ctx);
}
ctx.clipboardRef.current = newItems;
ctx.scene._applyZOrder();
ctx.onChange();
},
},
{
@@ -358,7 +514,8 @@ export const shortcuts: ShortcutDef[] = [
handler: (ctx) => {
const selected = ctx.selection.getSelectedItems();
if (selected.length === 0) return;
ctx.clipboardRef.current = [...selected];
ctx.clipboardRef.current = _collectGroupChildren(selected, ctx.scene);
markInternalCopy();
ctx.writeCanvasToClipboard(selected);
for (const item of selected) {
ctx.scene.removeItem(item.id, true);
@@ -374,14 +531,13 @@ export const shortcuts: ShortcutDef[] = [
handler: async (ctx) => {
const selected = ctx.selection.getSelectedItems();
if (selected.length === 0) return;
for (const original of selected) {
const newData = {
...original.data,
id: crypto.randomUUID(),
x: original.data.x + 20,
y: original.data.y + 20,
z: ctx.scene.nextZ(),
};
// Collect group children and clone with remapped IDs
const allItems = _collectGroupChildren(selected, ctx.scene);
const clones = _cloneItemsWithOffset(allItems, ctx.scene);
const sorted = [...clones].sort((a: any, b: any) =>
(a.type === 'group' ? 1 : 0) - (b.type === 'group' ? 1 : 0));
for (const newData of sorted) {
await ctx.scene._createItem(newData, true);
}
ctx.selection.clear();
@@ -455,6 +611,27 @@ export const shortcuts: ShortcutDef[] = [
category: 'editing', description: 'Ungroup', needsSelection: true,
handler: (ctx) => ctx.handleUngroup(),
},
// Opacity: [ and ] with Alt
{
id: 'opacity-down', keys: { key: '[', alt: true },
category: 'image', description: 'Decrease opacity 10%', needsSelection: true,
handler: (ctx) => {
const items = ctx.selection.getSelectedItems();
const current = items[0]?.data.opacity ?? 1;
ops.setOpacity(items, Math.max(0.1, current - 0.1));
ctx.onChange();
},
},
{
id: 'opacity-up', keys: { key: ']', alt: true },
category: 'image', description: 'Increase opacity 10%', needsSelection: true,
handler: (ctx) => {
const items = ctx.selection.getSelectedItems();
const current = items[0]?.data.opacity ?? 1;
ops.setOpacity(items, Math.min(1, current + 0.1));
ctx.onChange();
},
},
// Block Ctrl+S from opening browser save-as dialog
{
id: 'save', keys: { key: 's', ctrl: true },