fix(sticky): fixed-size creation, shared presets module, live resize sync
Fixes three review findings: 1. Sticky creation no longer zoom-adapts — always creates at M preset (28px/260w) in world space. Consistent at any zoom level. 2. Preset definitions extracted to stickyPresets.ts (shared domain module). TextFormatToolbar and tools.ts both import from there — no more canvas→component dependency inversion. 3. TextEditor.onLiveResize callback syncs spatial index and transform box as sticky background grows during typing.
This commit is contained in:
@@ -19,6 +19,7 @@ export class TextEditor {
|
|||||||
private _viewport: Viewport | null = null;
|
private _viewport: Viewport | null = null;
|
||||||
private _container: HTMLElement | null = null;
|
private _container: HTMLElement | null = null;
|
||||||
private _onChange: (() => void) | null = null;
|
private _onChange: (() => void) | null = null;
|
||||||
|
private _onLiveResize: ((item: SceneItem) => void) | null = null;
|
||||||
private _originalText: string = '';
|
private _originalText: string = '';
|
||||||
|
|
||||||
/** True when a textarea is open. */
|
/** True when a textarea is open. */
|
||||||
@@ -42,6 +43,7 @@ export class TextEditor {
|
|||||||
viewport: Viewport,
|
viewport: Viewport,
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
onChange: () => void,
|
onChange: () => void,
|
||||||
|
onLiveResize?: (item: SceneItem) => void,
|
||||||
): void {
|
): void {
|
||||||
// Accept text and sticky items
|
// Accept text and sticky items
|
||||||
if (item.type !== 'text' && item.type !== 'sticky') return;
|
if (item.type !== 'text' && item.type !== 'sticky') return;
|
||||||
@@ -53,6 +55,7 @@ export class TextEditor {
|
|||||||
this._viewport = viewport;
|
this._viewport = viewport;
|
||||||
this._container = container;
|
this._container = container;
|
||||||
this._onChange = onChange;
|
this._onChange = onChange;
|
||||||
|
this._onLiveResize = onLiveResize || null;
|
||||||
|
|
||||||
const zoom = viewport.scale.x;
|
const zoom = viewport.scale.x;
|
||||||
|
|
||||||
@@ -186,6 +189,7 @@ export class TextEditor {
|
|||||||
data.text = ta.value;
|
data.text = ta.value;
|
||||||
item.displayObject.updateFromData(data);
|
item.displayObject.updateFromData(data);
|
||||||
data.h = item.displayObject.computedHeight;
|
data.h = item.displayObject.computedHeight;
|
||||||
|
this._onLiveResize?.(item);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -221,6 +225,7 @@ export class TextEditor {
|
|||||||
this._viewport = null;
|
this._viewport = null;
|
||||||
this._container = null;
|
this._container = null;
|
||||||
this._onChange = null;
|
this._onChange = null;
|
||||||
|
this._onLiveResize = null;
|
||||||
this._originalText = '';
|
this._originalText = '';
|
||||||
|
|
||||||
if (save) {
|
if (save) {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* stickyPresets — sticky note size presets (shared domain module).
|
||||||
|
*
|
||||||
|
* Defines the S/M/L/XL/XXL presets for sticky text size and card width.
|
||||||
|
* Used by both canvas logic (tools, SceneManager) and UI (TextFormatToolbar).
|
||||||
|
* No React dependencies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type StickyTextSize = 'S' | 'M' | 'L' | 'XL' | 'XXL';
|
||||||
|
|
||||||
|
export const STICKY_SIZES: StickyTextSize[] = ['S', 'M', 'L', 'XL', 'XXL'];
|
||||||
|
|
||||||
|
export const STICKY_FONT_MAP: Record<StickyTextSize, number> = {
|
||||||
|
S: 20, M: 28, L: 36, XL: 48, XXL: 60,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STICKY_WIDTH_MAP: Record<StickyTextSize, number> = {
|
||||||
|
S: 200, M: 260, L: 320, XL: 400, XXL: 480,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Default preset for new sticky creation. */
|
||||||
|
export const DEFAULT_STICKY_PRESET: StickyTextSize = 'M';
|
||||||
|
|
||||||
|
/** Map a fontSize to the nearest preset label. */
|
||||||
|
export function nearestStickySize(fontSize: number): StickyTextSize {
|
||||||
|
let best: StickyTextSize = 'M';
|
||||||
|
let bestDist = Infinity;
|
||||||
|
for (const size of STICKY_SIZES) {
|
||||||
|
const dist = Math.abs(fontSize - STICKY_FONT_MAP[size]);
|
||||||
|
if (dist < bestDist) { bestDist = dist; best = size; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the card width for a given fontSize (snaps to nearest preset). */
|
||||||
|
export function getStickyWidthForSize(fontSize: number): number {
|
||||||
|
return STICKY_WIDTH_MAP[nearestStickySize(fontSize)];
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import { DrawingSprite } from './sprites/DrawingSprite';
|
|||||||
import type { DrawingObject } from './scene-format';
|
import type { DrawingObject } from './scene-format';
|
||||||
import { TextEditor } from './TextEditor';
|
import { TextEditor } from './TextEditor';
|
||||||
import { clampTextFontSize } from './textLimits';
|
import { clampTextFontSize } from './textLimits';
|
||||||
import { snapToStickyPreset } from '../components/TextFormatToolbar';
|
import { DEFAULT_STICKY_PRESET, STICKY_FONT_MAP, STICKY_WIDTH_MAP } from './stickyPresets';
|
||||||
|
|
||||||
export enum ToolType {
|
export enum ToolType {
|
||||||
SELECT = 'SELECT',
|
SELECT = 'SELECT',
|
||||||
@@ -37,9 +37,6 @@ const defaultOptions: ToolOptions = {
|
|||||||
|
|
||||||
// Creation defaults — zoom-aware via screenToWorld()
|
// Creation defaults — zoom-aware via screenToWorld()
|
||||||
const DEFAULT_TEXT_SCREEN_FONT = 24;
|
const DEFAULT_TEXT_SCREEN_FONT = 24;
|
||||||
const DEFAULT_STICKY_SCREEN_FONT = 28;
|
|
||||||
const DEFAULT_STICKY_SCREEN_WIDTH = 220;
|
|
||||||
const DEFAULT_STICKY_SCREEN_HEIGHT = 66;
|
|
||||||
|
|
||||||
type CleanupFn = (() => void) | null;
|
type CleanupFn = (() => void) | null;
|
||||||
|
|
||||||
@@ -327,17 +324,15 @@ export function activateTool(
|
|||||||
const rect = container.getBoundingClientRect();
|
const rect = container.getBoundingClientRect();
|
||||||
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
|
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
|
||||||
|
|
||||||
const zoom = viewport.scale.x;
|
const cardW = STICKY_WIDTH_MAP[DEFAULT_STICKY_PRESET];
|
||||||
const preset = snapToStickyPreset(screenToWorld(DEFAULT_STICKY_SCREEN_FONT, zoom, 16, 72));
|
const cardH = 60; // auto-computed by StickySprite
|
||||||
const cardW = preset.width;
|
|
||||||
const cardH = screenToWorld(DEFAULT_STICKY_SCREEN_HEIGHT, zoom, 40, 200);
|
|
||||||
const stickyData = {
|
const stickyData = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
type: 'sticky' as const,
|
type: 'sticky' as const,
|
||||||
x: world.x - cardW / 2,
|
x: world.x - cardW / 2,
|
||||||
y: world.y - cardH / 2,
|
y: world.y - cardH / 2,
|
||||||
w: cardW,
|
w: cardW,
|
||||||
h: cardH, // will be auto-computed by StickySprite
|
h: cardH,
|
||||||
sx: 1,
|
sx: 1,
|
||||||
sy: 1,
|
sy: 1,
|
||||||
angle: 0,
|
angle: 0,
|
||||||
@@ -347,7 +342,7 @@ export function activateTool(
|
|||||||
name: '',
|
name: '',
|
||||||
visible: true,
|
visible: true,
|
||||||
text: '',
|
text: '',
|
||||||
fontSize: preset.fontSize,
|
fontSize: STICKY_FONT_MAP[DEFAULT_STICKY_PRESET],
|
||||||
fontFamily: 'Inter, system-ui, sans-serif',
|
fontFamily: 'Inter, system-ui, sans-serif',
|
||||||
fill: '#ffd43b', // default yellow
|
fill: '#ffd43b', // default yellow
|
||||||
textColor: '#1a1a1a',
|
textColor: '#1a1a1a',
|
||||||
|
|||||||
@@ -1,38 +1,8 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import {
|
||||||
type StickyTextSize = 'S' | 'M' | 'L' | 'XL' | 'XXL';
|
STICKY_SIZES, STICKY_FONT_MAP, nearestStickySize,
|
||||||
|
type StickyTextSize,
|
||||||
const STICKY_SIZES: StickyTextSize[] = ['S', 'M', 'L', 'XL', 'XXL'];
|
} from '../canvas/stickyPresets';
|
||||||
const STICKY_SIZE_MAP: Record<StickyTextSize, number> = { S: 20, M: 28, L: 36, XL: 48, XXL: 60 };
|
|
||||||
const STICKY_WIDTH_MAP: Record<StickyTextSize, number> = { S: 200, M: 260, L: 320, XL: 400, XXL: 480 };
|
|
||||||
const STICKY_SIZE_VALUES = Object.values(STICKY_SIZE_MAP);
|
|
||||||
|
|
||||||
function nearestStickySize(fontSize: number): StickyTextSize {
|
|
||||||
let best: StickyTextSize = 'M';
|
|
||||||
let bestDist = Infinity;
|
|
||||||
for (const size of STICKY_SIZES) {
|
|
||||||
const dist = Math.abs(fontSize - STICKY_SIZE_MAP[size]);
|
|
||||||
if (dist < bestDist) { bestDist = dist; best = size; }
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Snap a raw fontSize to the nearest preset. Returns { fontSize, width }. */
|
|
||||||
export function snapToStickyPreset(fontSize: number): { fontSize: number; width: number } {
|
|
||||||
let best: StickyTextSize = 'M';
|
|
||||||
let bestDist = Infinity;
|
|
||||||
for (const size of STICKY_SIZES) {
|
|
||||||
const dist = Math.abs(fontSize - STICKY_SIZE_MAP[size]);
|
|
||||||
if (dist < bestDist) { bestDist = dist; best = size; }
|
|
||||||
}
|
|
||||||
return { fontSize: STICKY_SIZE_MAP[best], width: STICKY_WIDTH_MAP[best] };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the width for a given sticky text size preset. */
|
|
||||||
export function getStickyWidthForSize(fontSize: number): number {
|
|
||||||
const size = nearestStickySize(fontSize);
|
|
||||||
return STICKY_WIDTH_MAP[size];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TextFormatToolbarProps {
|
interface TextFormatToolbarProps {
|
||||||
kind: 'text' | 'sticky';
|
kind: 'text' | 'sticky';
|
||||||
@@ -149,8 +119,8 @@ export default function TextFormatToolbar(props: TextFormatToolbarProps) {
|
|||||||
color: active ? '#fff' : '#666',
|
color: active ? '#fff' : '#666',
|
||||||
background: active ? '#333' : 'transparent',
|
background: active ? '#333' : 'transparent',
|
||||||
}}
|
}}
|
||||||
onClick={() => onStickySizeChange(STICKY_SIZE_MAP[size])}
|
onClick={() => onStickySizeChange(STICKY_FONT_MAP[size])}
|
||||||
title={`${STICKY_SIZE_MAP[size]}px text`}
|
title={`${STICKY_FONT_MAP[size]}px text`}
|
||||||
onMouseEnter={(e) => { if (!active) { e.currentTarget.style.background = '#333'; e.currentTarget.style.color = '#fff'; } }}
|
onMouseEnter={(e) => { if (!active) { e.currentTarget.style.background = '#333'; e.currentTarget.style.color = '#fff'; } }}
|
||||||
onMouseLeave={(e) => { if (!active) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#666'; } }}
|
onMouseLeave={(e) => { if (!active) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#666'; } }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
|||||||
}
|
}
|
||||||
syncRef.current?.broadcastElements([item.id]);
|
syncRef.current?.broadcastElements([item.id]);
|
||||||
onCanvasChange();
|
onCanvasChange();
|
||||||
|
}, (resizedItem) => {
|
||||||
|
scene.updateSpatialEntry(resizedItem);
|
||||||
|
selection.transformBox.update([resizedItem]);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import UserCursors from '../components/UserCursors';
|
|||||||
import ContextMenu from '../components/ContextMenu';
|
import ContextMenu from '../components/ContextMenu';
|
||||||
import LayerPanel from '../components/LayerPanel';
|
import LayerPanel from '../components/LayerPanel';
|
||||||
import SelectionToolbar from '../components/SelectionToolbar';
|
import SelectionToolbar from '../components/SelectionToolbar';
|
||||||
import TextFormatToolbar, { getStickyWidthForSize } from '../components/TextFormatToolbar';
|
import TextFormatToolbar from '../components/TextFormatToolbar';
|
||||||
|
import { getStickyWidthForSize } from '../canvas/stickyPresets';
|
||||||
import VideoControls from '../components/VideoControls';
|
import VideoControls from '../components/VideoControls';
|
||||||
import ShortcutsHelp from '../components/ShortcutsHelp';
|
import ShortcutsHelp from '../components/ShortcutsHelp';
|
||||||
import MattermostImport from '../components/MattermostImport';
|
import MattermostImport from '../components/MattermostImport';
|
||||||
|
|||||||
Reference in New Issue
Block a user