refactor(sticky): fixed-width card with S/M/L/XL/XXL font presets
Stickies are now fixed-width cards — no resize handles shown. Text size controlled via 5 presets (10/14/18/24/32px). On creation, zoom-aware screenToWorld snaps to nearest preset. Height auto-adjusts from content. Plain text resize unchanged. Removes: sticky width bake, live reflow drag, min width constraint. Backward compatible — existing stickies map to nearest preset.
This commit is contained in:
@@ -9,7 +9,6 @@
|
|||||||
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';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -21,7 +20,6 @@ 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';
|
||||||
|
|
||||||
@@ -199,11 +197,9 @@ 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')) {
|
// No resize handles for sticky-only selections (fixed width, auto height)
|
||||||
|
if (this._stickyOnly) {
|
||||||
handle.visible = false;
|
handle.visible = false;
|
||||||
handle.eventMode = 'none';
|
handle.eventMode = 'none';
|
||||||
continue;
|
continue;
|
||||||
@@ -329,11 +325,6 @@ 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;
|
||||||
@@ -354,22 +345,9 @@ export class TransformBox extends Container {
|
|||||||
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;
|
||||||
|
|
||||||
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.sx = orig.sx * fx;
|
||||||
item.data.sy = orig.sy * fy;
|
item.data.sy = orig.sy * fy;
|
||||||
item.displayObject.scale.set(item.data.sx, item.data.sy);
|
item.displayObject.scale.set(item.data.sx, item.data.sy);
|
||||||
}
|
|
||||||
|
|
||||||
item.displayObject.position.set(item.data.x, item.data.y);
|
item.displayObject.position.set(item.data.x, item.data.y);
|
||||||
this._onItemTransform?.(item);
|
this._onItemTransform?.(item);
|
||||||
|
|||||||
@@ -1,23 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* textLimits — single source of truth for font-size constraints.
|
* textLimits — font-size constraints for plain text items.
|
||||||
*
|
*
|
||||||
* Used by creation defaults to keep initial sizes reasonable.
|
* Used by creation defaults to keep initial sizes reasonable.
|
||||||
* Direct manipulation (resize-bake) intentionally bypasses these
|
* Direct manipulation (resize-bake) intentionally bypasses these
|
||||||
* clamps so users can scale text to any size.
|
* clamps so users can scale text to any size.
|
||||||
|
*
|
||||||
|
* Sticky notes use discrete presets (S/M/L/XL/XXL) defined in
|
||||||
|
* TextFormatToolbar.tsx — not continuous clamps.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Plain text: generous range for direct manipulation
|
|
||||||
const TEXT_FONT_MIN = 6;
|
const TEXT_FONT_MIN = 6;
|
||||||
const TEXT_FONT_MAX = 160;
|
const TEXT_FONT_MAX = 160;
|
||||||
|
|
||||||
// Sticky note text: narrower range (card layout breaks at extremes)
|
|
||||||
const STICKY_FONT_MIN = 8;
|
|
||||||
const STICKY_FONT_MAX = 96;
|
|
||||||
|
|
||||||
export function clampTextFontSize(size: number): number {
|
export function clampTextFontSize(size: number): number {
|
||||||
return Math.round(Math.min(TEXT_FONT_MAX, Math.max(TEXT_FONT_MIN, size)));
|
return Math.round(Math.min(TEXT_FONT_MAX, Math.max(TEXT_FONT_MIN, size)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clampStickyFontSize(size: number): number {
|
|
||||||
return Math.round(Math.min(STICKY_FONT_MAX, Math.max(STICKY_FONT_MIN, size)));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import type { SelectionManager } from './SelectionManager';
|
|||||||
import { DrawingSprite } from './sprites/DrawingSprite';
|
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, clampStickyFontSize } from './textLimits';
|
import { clampTextFontSize } from './textLimits';
|
||||||
|
import { snapToStickyPreset } from '../components/TextFormatToolbar';
|
||||||
|
|
||||||
export enum ToolType {
|
export enum ToolType {
|
||||||
SELECT = 'SELECT',
|
SELECT = 'SELECT',
|
||||||
@@ -345,7 +346,7 @@ export function activateTool(
|
|||||||
name: '',
|
name: '',
|
||||||
visible: true,
|
visible: true,
|
||||||
text: '',
|
text: '',
|
||||||
fontSize: clampStickyFontSize(screenToWorld(DEFAULT_STICKY_SCREEN_FONT, zoom, 8, 96)),
|
fontSize: snapToStickyPreset(screenToWorld(DEFAULT_STICKY_SCREEN_FONT, zoom, 8, 40)),
|
||||||
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,13 +1,30 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
type StickyTextSize = 'S' | 'M' | 'L';
|
type StickyTextSize = 'S' | 'M' | 'L' | 'XL' | 'XXL';
|
||||||
|
|
||||||
const STICKY_SIZE_MAP: Record<StickyTextSize, number> = { S: 12, M: 14, L: 18 };
|
const STICKY_SIZES: StickyTextSize[] = ['S', 'M', 'L', 'XL', 'XXL'];
|
||||||
|
const STICKY_SIZE_MAP: Record<StickyTextSize, number> = { S: 10, M: 14, L: 18, XL: 24, XXL: 32 };
|
||||||
|
const STICKY_SIZE_VALUES = Object.values(STICKY_SIZE_MAP);
|
||||||
|
|
||||||
function nearestStickySize(fontSize: number): StickyTextSize {
|
function nearestStickySize(fontSize: number): StickyTextSize {
|
||||||
if (fontSize <= 12) return 'S';
|
let best: StickyTextSize = 'M';
|
||||||
if (fontSize <= 15) return 'M';
|
let bestDist = Infinity;
|
||||||
return 'L';
|
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 value. */
|
||||||
|
export function snapToStickyPreset(fontSize: number): number {
|
||||||
|
let best = STICKY_SIZE_VALUES[0];
|
||||||
|
let bestDist = Infinity;
|
||||||
|
for (const v of STICKY_SIZE_VALUES) {
|
||||||
|
const dist = Math.abs(fontSize - v);
|
||||||
|
if (dist < bestDist) { bestDist = dist; best = v; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TextFormatToolbarProps {
|
interface TextFormatToolbarProps {
|
||||||
@@ -111,7 +128,7 @@ export default function TextFormatToolbar(props: TextFormatToolbarProps) {
|
|||||||
>
|
>
|
||||||
{/* S/M/L text size toggle (sticky only) */}
|
{/* S/M/L text size toggle (sticky only) */}
|
||||||
{kind === 'sticky' && onStickySizeChange && (<>
|
{kind === 'sticky' && onStickySizeChange && (<>
|
||||||
{(['S', 'M', 'L'] as StickyTextSize[]).map((size) => {
|
{STICKY_SIZES.map((size) => {
|
||||||
const active = nearestStickySize(stickyFontSize || 14) === size;
|
const active = nearestStickySize(stickyFontSize || 14) === size;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -120,13 +137,13 @@ export default function TextFormatToolbar(props: TextFormatToolbarProps) {
|
|||||||
...btnStyle,
|
...btnStyle,
|
||||||
width: '26px',
|
width: '26px',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
fontSize: size === 'S' ? '10px' : size === 'M' ? '12px' : '14px',
|
fontSize: size === 'S' ? '9px' : size === 'M' ? '10px' : size === 'L' ? '11px' : size === 'XL' ? '12px' : '13px',
|
||||||
fontWeight: active ? 700 : 400,
|
fontWeight: active ? 700 : 400,
|
||||||
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_SIZE_MAP[size])}
|
||||||
title={`${size === 'S' ? 'Small' : size === 'M' ? 'Medium' : 'Large'} text`}
|
title={`${STICKY_SIZE_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'; } }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { AnnotationStore } from '../stores/annotationStore';
|
|||||||
import { PinOverlay } from '../canvas/PinOverlay';
|
import { PinOverlay } from '../canvas/PinOverlay';
|
||||||
import { CropOverlay } from '../canvas/CropOverlay';
|
import { CropOverlay } from '../canvas/CropOverlay';
|
||||||
import { TextSprite } from '../canvas/sprites/TextSprite';
|
import { TextSprite } from '../canvas/sprites/TextSprite';
|
||||||
import { StickySprite } from '../canvas/sprites/StickySprite';
|
|
||||||
import { TextSharpnessManager } from '../canvas/textSharpness';
|
import { TextSharpnessManager } from '../canvas/textSharpness';
|
||||||
// PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit
|
// PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit
|
||||||
import { connectSocket, disconnectSocket } from '../socket';
|
import { connectSocket, disconnectSocket } from '../socket';
|
||||||
@@ -229,18 +228,6 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
|||||||
d.h = item.displayObject.measuredHeight;
|
d.h = item.displayObject.measuredHeight;
|
||||||
}
|
}
|
||||||
item.displayObject.scale.set(d.sx, d.sy);
|
item.displayObject.scale.set(d.sx, d.sy);
|
||||||
} else if (item.type === 'sticky') {
|
|
||||||
const d = item.data as any;
|
|
||||||
// Bake width only — text re-wraps and height auto-adjusts.
|
|
||||||
// fontSize stays stable so the card acts like a resizable text box.
|
|
||||||
d.w = Math.max(80, Math.round(d.w * absSx));
|
|
||||||
d.sx = item.data.sx > 0 ? 1 : -1;
|
|
||||||
d.sy = item.data.sy > 0 ? 1 : -1;
|
|
||||||
if (item.displayObject instanceof StickySprite) {
|
|
||||||
item.displayObject.updateFromData(d);
|
|
||||||
d.h = item.displayObject.computedHeight;
|
|
||||||
}
|
|
||||||
item.displayObject.scale.set(d.sx, d.sy);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onCanvasChange(itemIds);
|
onCanvasChange(itemIds);
|
||||||
|
|||||||
Reference in New Issue
Block a user