diff --git a/frontend/src/canvas/TextEditor.ts b/frontend/src/canvas/TextEditor.ts index 400896f..6c5a56f 100644 --- a/frontend/src/canvas/TextEditor.ts +++ b/frontend/src/canvas/TextEditor.ts @@ -178,6 +178,15 @@ export class TextEditor { const onInput = () => { autoSize(); + // Live-update sticky background height as user types. + // PixiJS Text measures bounds regardless of visibility, so + // updateFromData works even with the text child hidden. + if (item.type === 'sticky' && item.displayObject instanceof StickySprite && !item.displayObject.destroyed) { + const data = item.data as StickyObject; + data.text = ta.value; + item.displayObject.updateFromData(data); + data.h = item.displayObject.computedHeight; + } }; ta.addEventListener('keydown', onKeyDown); diff --git a/frontend/src/canvas/TransformBox.ts b/frontend/src/canvas/TransformBox.ts index 70571dc..3ca7c36 100644 --- a/frontend/src/canvas/TransformBox.ts +++ b/frontend/src/canvas/TransformBox.ts @@ -9,6 +9,7 @@ import { Container, Graphics, FederatedPointerEvent, Text, TextStyle } from 'pixi.js'; import type { Viewport } from 'pixi-viewport'; import { type SceneItem, getItemWorldBounds } from './SceneManager'; +import { StickySprite } from './sprites/StickySprite'; import type { SnapGuides } from './SnapGuides'; // --------------------------------------------------------------------------- @@ -20,6 +21,7 @@ const BORDER_WIDTH = 1.5; const HANDLE_SIZE = 8; const HANDLE_FILL = 0xffffff; const HANDLE_STROKE = 0x4a90d9; +const MIN_STICKY_WIDTH = 80; type HandleId = 'tl' | 'tc' | 'tr' | 'ml' | 'mr' | 'bl' | 'bc' | 'br'; @@ -43,7 +45,7 @@ interface DragState { startX: number; startY: number; origBounds: { x: number; y: number; w: number; h: number }; - origTransforms: Map; + origTransforms: Map; } // --------------------------------------------------------------------------- @@ -60,6 +62,7 @@ export class TransformBox extends Container { private _onItemTransform: ((item: SceneItem) => void) | null = null; private _onDragEnd: ((itemIds: string[]) => void) | null = null; private _snapGuides: SnapGuides | null = null; + private _stickyOnly = false; private _dimLabel!: Text; private _dimLabelBg!: Graphics; @@ -161,6 +164,7 @@ export class TransformBox extends Container { } this._bounds = { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; + this._stickyOnly = items.length > 0 && items.every(i => i.type === 'sticky'); this._draw(); this.visible = true; } @@ -195,7 +199,18 @@ export class TransformBox extends Container { const s = HANDLE_SIZE / zoom; const half = s / 2; + // Vertical-only handles hidden for sticky-only selections (width-only resize) + const hideVertical = this._stickyOnly; + for (const [id, handle] of this._handles) { + if (hideVertical && (id === 'tc' || id === 'bc')) { + handle.visible = false; + handle.eventMode = 'none'; + continue; + } + handle.visible = true; + handle.eventMode = 'static'; + const pos = positions[id]; handle.clear(); @@ -212,7 +227,7 @@ export class TransformBox extends Container { private _onHandleDown(e: FederatedPointerEvent, id: HandleId): void { e.stopPropagation(); - const origTransforms = new Map(); + const origTransforms = new Map(); for (const item of this._items) { origTransforms.set(item.id, { sx: item.data.sx, @@ -220,6 +235,7 @@ export class TransformBox extends Container { angle: item.data.angle, x: item.data.x, y: item.data.y, + w: item.data.w, }); } @@ -313,6 +329,11 @@ export class TransformBox extends Container { } } + // Sticky-only: horizontal resize only — fy stays 1 + if (this._stickyOnly) { + fy = 1; + } + // Compute the anchor point (fixed edge of bounding box) let anchorX = ob.x; // default: top-left is fixed (br, mr, bc handles) let anchorY = ob.y; @@ -329,17 +350,28 @@ export class TransformBox extends Container { const orig = origTransforms.get(item.id); if (!orig) continue; - // Apply scale factors relative to original - item.data.sx = orig.sx * fx; - item.data.sy = orig.sy * fy; - // Reposition as a unit: scale each item's offset from the anchor point item.data.x = anchorX + (orig.x - anchorX) * fx; item.data.y = anchorY + (orig.y - anchorY) * fy; - item.displayObject.scale.set(item.data.sx, item.data.sy); - item.displayObject.position.set(item.data.x, item.data.y); + if (this._stickyOnly && item.type === 'sticky') { + // Live reflow: bake width directly so text re-wraps every frame + item.data.w = Math.max(MIN_STICKY_WIDTH, Math.round(orig.w * fx)); + item.data.sx = orig.sx > 0 ? 1 : -1; + item.data.sy = orig.sy > 0 ? 1 : -1; + if (item.displayObject instanceof StickySprite) { + item.displayObject.updateFromData(item.data as any); + item.data.h = item.displayObject.computedHeight; + } + item.displayObject.scale.set(item.data.sx, item.data.sy); + } else { + // Normal proportional scale + item.data.sx = orig.sx * fx; + item.data.sy = orig.sy * fy; + item.displayObject.scale.set(item.data.sx, item.data.sy); + } + item.displayObject.position.set(item.data.x, item.data.y); this._onItemTransform?.(item); } diff --git a/frontend/src/canvas/textLimits.ts b/frontend/src/canvas/textLimits.ts index 319e576..3036e02 100644 --- a/frontend/src/canvas/textLimits.ts +++ b/frontend/src/canvas/textLimits.ts @@ -1,9 +1,9 @@ /** * textLimits — single source of truth for font-size constraints. * - * Every path that creates, resizes, or mutates fontSize on text/sticky - * items must go through these helpers. This prevents inconsistent - * clamping when ranges are widened later. + * Used by creation defaults to keep initial sizes reasonable. + * Direct manipulation (resize-bake) intentionally bypasses these + * clamps so users can scale text to any size. */ // Plain text: generous range for direct manipulation diff --git a/frontend/src/components/TextFormatToolbar.tsx b/frontend/src/components/TextFormatToolbar.tsx index 9e31eac..9c00050 100644 --- a/frontend/src/components/TextFormatToolbar.tsx +++ b/frontend/src/components/TextFormatToolbar.tsx @@ -1,5 +1,15 @@ import React, { useState, useRef, useEffect } from 'react'; +type StickyTextSize = 'S' | 'M' | 'L'; + +const STICKY_SIZE_MAP: Record = { S: 12, M: 14, L: 18 }; + +function nearestStickySize(fontSize: number): StickyTextSize { + if (fontSize <= 12) return 'S'; + if (fontSize <= 15) return 'M'; + return 'L'; +} + interface TextFormatToolbarProps { kind: 'text' | 'sticky'; x: number; @@ -9,11 +19,15 @@ interface TextFormatToolbarProps { fill: string; /** Note background color (sticky only). */ noteFill?: string; + /** Current sticky fontSize (for S/M/L toggle). */ + stickyFontSize?: number; position?: 'above' | 'below'; onFontFamilyChange: (family: string) => void; onFillChange: (color: string) => void; /** Called when sticky note background color changes. */ onNoteFillChange?: (color: string) => void; + /** Called when sticky text size preset changes. */ + onStickySizeChange?: (fontSize: number) => void; } const FONT_FAMILIES = [ @@ -33,7 +47,7 @@ const PRESET_COLORS = [ ]; export default function TextFormatToolbar(props: TextFormatToolbarProps) { - const { kind, x, y, fontFamily, fill, noteFill, position = 'above', onFontFamilyChange, onFillChange, onNoteFillChange } = props; + const { kind, x, y, fontFamily, fill, noteFill, stickyFontSize, position = 'above', onFontFamilyChange, onFillChange, onNoteFillChange, onStickySizeChange } = props; const [showFontMenu, setShowFontMenu] = useState(false); const [showColorPicker, setShowColorPicker] = useState(false); const [showNoteFillPicker, setShowNoteFillPicker] = useState(false); @@ -95,6 +109,34 @@ export default function TextFormatToolbar(props: TextFormatToolbarProps) { }} onPointerDown={(e) => e.stopPropagation()} > + {/* S/M/L text size toggle (sticky only) */} + {kind === 'sticky' && onStickySizeChange && (<> + {(['S', 'M', 'L'] as StickyTextSize[]).map((size) => { + const active = nearestStickySize(stickyFontSize || 14) === size; + return ( + + ); + })} +
+ )} + {/* Font family dropdown */}