feat(sticky): S/M/L text size toggle, live reflow resize, min width
Sticky UX overhaul — clear separation between card resize and text size: - S/M/L toggle (12/14/18px) in contextual toolbar for sticky text size - TransformBox: horizontal-only resize for stickies with live text reflow during drag (no more stretch-then-snap) - Hide vertical handles (tc/bc) for sticky-only selections - Minimum sticky width of 80px enforced in both drag and bake - TextEditor: sticky background auto-resizes as user types - textLimits.ts comment corrected to match actual usage Plain text unchanged: resize gesture scales fontSize via bake. Fully backward compatible — existing stickies map to nearest preset.
This commit is contained in:
@@ -178,6 +178,15 @@ export class TextEditor {
|
|||||||
|
|
||||||
const onInput = () => {
|
const onInput = () => {
|
||||||
autoSize();
|
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);
|
ta.addEventListener('keydown', onKeyDown);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { Container, Graphics, FederatedPointerEvent, Text, TextStyle } from 'pixi.js';
|
import { Container, Graphics, FederatedPointerEvent, Text, TextStyle } from 'pixi.js';
|
||||||
import type { Viewport } from 'pixi-viewport';
|
import type { Viewport } from 'pixi-viewport';
|
||||||
import { type SceneItem, getItemWorldBounds } from './SceneManager';
|
import { type SceneItem, getItemWorldBounds } from './SceneManager';
|
||||||
|
import { StickySprite } from './sprites/StickySprite';
|
||||||
import type { SnapGuides } from './SnapGuides';
|
import type { SnapGuides } from './SnapGuides';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -20,6 +21,7 @@ const BORDER_WIDTH = 1.5;
|
|||||||
const HANDLE_SIZE = 8;
|
const HANDLE_SIZE = 8;
|
||||||
const HANDLE_FILL = 0xffffff;
|
const HANDLE_FILL = 0xffffff;
|
||||||
const HANDLE_STROKE = 0x4a90d9;
|
const HANDLE_STROKE = 0x4a90d9;
|
||||||
|
const MIN_STICKY_WIDTH = 80;
|
||||||
|
|
||||||
type HandleId = 'tl' | 'tc' | 'tr' | 'ml' | 'mr' | 'bl' | 'bc' | 'br';
|
type HandleId = 'tl' | 'tc' | 'tr' | 'ml' | 'mr' | 'bl' | 'bc' | 'br';
|
||||||
|
|
||||||
@@ -43,7 +45,7 @@ interface DragState {
|
|||||||
startX: number;
|
startX: number;
|
||||||
startY: number;
|
startY: number;
|
||||||
origBounds: { x: number; y: number; w: number; h: number };
|
origBounds: { x: number; y: number; w: number; h: number };
|
||||||
origTransforms: Map<string, { sx: number; sy: number; angle: number; x: number; y: number }>;
|
origTransforms: Map<string, { sx: number; sy: number; angle: number; x: number; y: number; w: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -60,6 +62,7 @@ export class TransformBox extends Container {
|
|||||||
private _onItemTransform: ((item: SceneItem) => void) | null = null;
|
private _onItemTransform: ((item: SceneItem) => void) | null = null;
|
||||||
private _onDragEnd: ((itemIds: string[]) => void) | null = null;
|
private _onDragEnd: ((itemIds: string[]) => void) | null = null;
|
||||||
private _snapGuides: SnapGuides | null = null;
|
private _snapGuides: SnapGuides | null = null;
|
||||||
|
private _stickyOnly = false;
|
||||||
private _dimLabel!: Text;
|
private _dimLabel!: Text;
|
||||||
private _dimLabelBg!: Graphics;
|
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._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._draw();
|
||||||
this.visible = true;
|
this.visible = true;
|
||||||
}
|
}
|
||||||
@@ -195,7 +199,18 @@ export class TransformBox extends Container {
|
|||||||
const s = HANDLE_SIZE / zoom;
|
const s = HANDLE_SIZE / zoom;
|
||||||
const half = s / 2;
|
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) {
|
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];
|
const pos = positions[id];
|
||||||
handle.clear();
|
handle.clear();
|
||||||
|
|
||||||
@@ -212,7 +227,7 @@ export class TransformBox extends Container {
|
|||||||
private _onHandleDown(e: FederatedPointerEvent, id: HandleId): void {
|
private _onHandleDown(e: FederatedPointerEvent, id: HandleId): void {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
const origTransforms = new Map<string, { sx: number; sy: number; angle: number; x: number; y: number }>();
|
const origTransforms = new Map<string, { sx: number; sy: number; angle: number; x: number; y: number; w: number }>();
|
||||||
for (const item of this._items) {
|
for (const item of this._items) {
|
||||||
origTransforms.set(item.id, {
|
origTransforms.set(item.id, {
|
||||||
sx: item.data.sx,
|
sx: item.data.sx,
|
||||||
@@ -220,6 +235,7 @@ export class TransformBox extends Container {
|
|||||||
angle: item.data.angle,
|
angle: item.data.angle,
|
||||||
x: item.data.x,
|
x: item.data.x,
|
||||||
y: item.data.y,
|
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)
|
// Compute the anchor point (fixed edge of bounding box)
|
||||||
let anchorX = ob.x; // default: top-left is fixed (br, mr, bc handles)
|
let anchorX = ob.x; // default: top-left is fixed (br, mr, bc handles)
|
||||||
let anchorY = ob.y;
|
let anchorY = ob.y;
|
||||||
@@ -329,17 +350,28 @@ export class TransformBox extends Container {
|
|||||||
const orig = origTransforms.get(item.id);
|
const orig = origTransforms.get(item.id);
|
||||||
if (!orig) continue;
|
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
|
// Reposition as a unit: scale each item's offset from the anchor point
|
||||||
item.data.x = anchorX + (orig.x - anchorX) * fx;
|
item.data.x = anchorX + (orig.x - anchorX) * fx;
|
||||||
item.data.y = anchorY + (orig.y - anchorY) * fy;
|
item.data.y = anchorY + (orig.y - anchorY) * fy;
|
||||||
|
|
||||||
item.displayObject.scale.set(item.data.sx, item.data.sy);
|
if (this._stickyOnly && item.type === 'sticky') {
|
||||||
item.displayObject.position.set(item.data.x, item.data.y);
|
// 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);
|
this._onItemTransform?.(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* textLimits — single source of truth for font-size constraints.
|
* textLimits — single source of truth for font-size constraints.
|
||||||
*
|
*
|
||||||
* Every path that creates, resizes, or mutates fontSize on text/sticky
|
* Used by creation defaults to keep initial sizes reasonable.
|
||||||
* items must go through these helpers. This prevents inconsistent
|
* Direct manipulation (resize-bake) intentionally bypasses these
|
||||||
* clamping when ranges are widened later.
|
* clamps so users can scale text to any size.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Plain text: generous range for direct manipulation
|
// Plain text: generous range for direct manipulation
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
type StickyTextSize = 'S' | 'M' | 'L';
|
||||||
|
|
||||||
|
const STICKY_SIZE_MAP: Record<StickyTextSize, number> = { 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 {
|
interface TextFormatToolbarProps {
|
||||||
kind: 'text' | 'sticky';
|
kind: 'text' | 'sticky';
|
||||||
x: number;
|
x: number;
|
||||||
@@ -9,11 +19,15 @@ interface TextFormatToolbarProps {
|
|||||||
fill: string;
|
fill: string;
|
||||||
/** Note background color (sticky only). */
|
/** Note background color (sticky only). */
|
||||||
noteFill?: string;
|
noteFill?: string;
|
||||||
|
/** Current sticky fontSize (for S/M/L toggle). */
|
||||||
|
stickyFontSize?: number;
|
||||||
position?: 'above' | 'below';
|
position?: 'above' | 'below';
|
||||||
onFontFamilyChange: (family: string) => void;
|
onFontFamilyChange: (family: string) => void;
|
||||||
onFillChange: (color: string) => void;
|
onFillChange: (color: string) => void;
|
||||||
/** Called when sticky note background color changes. */
|
/** Called when sticky note background color changes. */
|
||||||
onNoteFillChange?: (color: string) => void;
|
onNoteFillChange?: (color: string) => void;
|
||||||
|
/** Called when sticky text size preset changes. */
|
||||||
|
onStickySizeChange?: (fontSize: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FONT_FAMILIES = [
|
const FONT_FAMILIES = [
|
||||||
@@ -33,7 +47,7 @@ const PRESET_COLORS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function TextFormatToolbar(props: TextFormatToolbarProps) {
|
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 [showFontMenu, setShowFontMenu] = useState(false);
|
||||||
const [showColorPicker, setShowColorPicker] = useState(false);
|
const [showColorPicker, setShowColorPicker] = useState(false);
|
||||||
const [showNoteFillPicker, setShowNoteFillPicker] = useState(false);
|
const [showNoteFillPicker, setShowNoteFillPicker] = useState(false);
|
||||||
@@ -95,6 +109,34 @@ export default function TextFormatToolbar(props: TextFormatToolbarProps) {
|
|||||||
}}
|
}}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
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 (
|
||||||
|
<button
|
||||||
|
key={size}
|
||||||
|
style={{
|
||||||
|
...btnStyle,
|
||||||
|
width: '26px',
|
||||||
|
padding: 0,
|
||||||
|
fontSize: size === 'S' ? '10px' : size === 'M' ? '12px' : '14px',
|
||||||
|
fontWeight: active ? 700 : 400,
|
||||||
|
color: active ? '#fff' : '#666',
|
||||||
|
background: active ? '#333' : 'transparent',
|
||||||
|
}}
|
||||||
|
onClick={() => onStickySizeChange(STICKY_SIZE_MAP[size])}
|
||||||
|
title={`${size === 'S' ? 'Small' : size === 'M' ? 'Medium' : 'Large'} text`}
|
||||||
|
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'; } }}
|
||||||
|
>
|
||||||
|
{size}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div style={{ width: '1px', height: '18px', background: '#333', margin: '0 2px', flexShrink: 0 }} />
|
||||||
|
</>)}
|
||||||
|
|
||||||
{/* Font family dropdown */}
|
{/* Font family dropdown */}
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
|||||||
const d = item.data as any;
|
const d = item.data as any;
|
||||||
// Bake width only — text re-wraps and height auto-adjusts.
|
// Bake width only — text re-wraps and height auto-adjusts.
|
||||||
// fontSize stays stable so the card acts like a resizable text box.
|
// fontSize stays stable so the card acts like a resizable text box.
|
||||||
d.w = Math.round(d.w * absSx);
|
d.w = Math.max(80, Math.round(d.w * absSx));
|
||||||
d.sx = item.data.sx > 0 ? 1 : -1;
|
d.sx = item.data.sx > 0 ? 1 : -1;
|
||||||
d.sy = item.data.sy > 0 ? 1 : -1;
|
d.sy = item.data.sy > 0 ? 1 : -1;
|
||||||
if (item.displayObject instanceof StickySprite) {
|
if (item.displayObject instanceof StickySprite) {
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
const [selToolbar, setSelToolbar] = useState<{ x: number; y: number; count: number } | null>(null);
|
const [selToolbar, setSelToolbar] = useState<{ x: number; y: number; count: number } | null>(null);
|
||||||
const [textToolbar, setTextToolbar] = useState<
|
const [textToolbar, setTextToolbar] = useState<
|
||||||
| { kind: 'text'; x: number; y: number; fontFamily: string; fill: string; items: SceneItem[] }
|
| { kind: 'text'; x: number; y: number; fontFamily: string; fill: string; items: SceneItem[] }
|
||||||
| { kind: 'sticky'; x: number; y: number; fontFamily: string; textColor: string; fill: string; items: SceneItem[] }
|
| { kind: 'sticky'; x: number; y: number; fontFamily: string; textColor: string; fill: string; stickyFontSize: number; items: SceneItem[] }
|
||||||
| null
|
| null
|
||||||
>(null);
|
>(null);
|
||||||
const [videoCtrl, setVideoCtrl] = useState<{ videoSprite: VideoSprite; screenRect: { x: number; y: number; w: number; h: number } } | null>(null);
|
const [videoCtrl, setVideoCtrl] = useState<{ videoSprite: VideoSprite; screenRect: { x: number; y: number; w: number; h: number } } | null>(null);
|
||||||
@@ -628,6 +628,7 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
kind: 'sticky', x: posX, y: posY,
|
kind: 'sticky', x: posX, y: posY,
|
||||||
fontFamily: sd.fontFamily || 'Inter, system-ui, sans-serif',
|
fontFamily: sd.fontFamily || 'Inter, system-ui, sans-serif',
|
||||||
textColor: sd.textColor || '#1a1a1a', fill: sd.fill || '#ffd43b',
|
textColor: sd.textColor || '#1a1a1a', fill: sd.fill || '#ffd43b',
|
||||||
|
stickyFontSize: sd.fontSize || 14,
|
||||||
items: stickyItems,
|
items: stickyItems,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -915,6 +916,7 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
fontFamily={textToolbar.fontFamily}
|
fontFamily={textToolbar.fontFamily}
|
||||||
fill={textToolbar.kind === 'sticky' ? textToolbar.textColor : textToolbar.fill}
|
fill={textToolbar.kind === 'sticky' ? textToolbar.textColor : textToolbar.fill}
|
||||||
noteFill={textToolbar.kind === 'sticky' ? textToolbar.fill : undefined}
|
noteFill={textToolbar.kind === 'sticky' ? textToolbar.fill : undefined}
|
||||||
|
stickyFontSize={textToolbar.kind === 'sticky' ? textToolbar.stickyFontSize : undefined}
|
||||||
position={textToolbar.items.length >= 2 ? 'below' : 'above'}
|
position={textToolbar.items.length >= 2 ? 'below' : 'above'}
|
||||||
onFontFamilyChange={(family) => {
|
onFontFamilyChange={(family) => {
|
||||||
const scene = canvasRef.current?.getScene();
|
const scene = canvasRef.current?.getScene();
|
||||||
@@ -971,6 +973,22 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
onCanvasChange(textToolbar.items.map(i => i.id));
|
onCanvasChange(textToolbar.items.map(i => i.id));
|
||||||
updateOverlays();
|
updateOverlays();
|
||||||
} : undefined}
|
} : undefined}
|
||||||
|
onStickySizeChange={textToolbar.kind === 'sticky' ? (fontSize) => {
|
||||||
|
const scene = canvasRef.current?.getScene();
|
||||||
|
for (const item of textToolbar.items) {
|
||||||
|
const d = item.data as StickyObject;
|
||||||
|
d.fontSize = fontSize;
|
||||||
|
if (item.displayObject instanceof StickySprite) {
|
||||||
|
item.displayObject.updateFromData(d);
|
||||||
|
d.h = item.displayObject.computedHeight;
|
||||||
|
}
|
||||||
|
if (scene) scene.updateSpatialEntry(item);
|
||||||
|
}
|
||||||
|
setTextToolbar((prev) => prev && prev.kind === 'sticky' ? { ...prev, stickyFontSize: fontSize } : prev);
|
||||||
|
selectionRef.current?.transformBox.update(textToolbar.items);
|
||||||
|
onCanvasChange(textToolbar.items.map(i => i.id));
|
||||||
|
updateOverlays();
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user