Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/docs/src/routes/(components)/stage/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,10 @@
e.preventDefault();
stageProps.scene.zoom = Math.max(minZoom, Math.min(stageProps.scene.zoom - scrollDelta, maxZoom));
} else if (stageProps.activeLayer === MapLayerType.FogOfWar) {
stageProps.fogOfWar.tool.size = Math.max(10, Math.min(stageProps.fogOfWar.tool.size + 500.0 * scrollDelta, 1000));
stageProps.fogOfWar.tool.size = Math.max(
1,
Math.min(Math.round(stageProps.fogOfWar.tool.size + 40.0 * scrollDelta), 5)
);
}
}
</script>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
</script>

<Folder title="Annotations" expanded={false}>
<Slider bind:value={props.annotations.lineWidth} label="Line Width" min={1} max={200} step={1} />
<Slider bind:value={props.annotations.lineWidth} label="Line Width" min={0.25} max={5} step={0.25} />
<Folder title="Layers" expanded={true}>
{#each props.annotations.layers as layer}
<Folder title={layer.id + (layer.id === props.annotations.activeLayer ? ' (Active)' : '')} expanded={false}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
bind:value={props.fogOfWar.tool.size}
label="Brush Size"
min={1}
max={500}
max={5}
step={1}
disabled={props.fogOfWar.tool.type !== ToolType.Brush}
/>
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/src/routes/(components)/stage/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const StageDefaultProps: StageProps = {
}
],
activeLayer: 'default-layer',
lineWidth: 1
lineWidth: 0.5
},
debug: {
enableStats: false,
Expand Down Expand Up @@ -76,7 +76,7 @@ export const StageDefaultProps: StageProps = {
},
tool: {
type: ToolType.Brush,
size: 50,
size: 2,
mode: DrawMode.Erase
},
edge: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,10 +245,10 @@
};

const handleLineWidthChange = (value: number) => {
// Update the global state (value is now a percentage)
// Update the global state (value is in grid units)
queuePropertyUpdate(stageProps, ['annotations', 'lineWidth'], value, 'control');
// Save to preferences (debounced)
setPreferenceDebounced('annotationLineWidthPercent', value);
setPreferenceDebounced('annotationLineWidthGridUnits', value);
};

// Export handlers for external use (e.g., DrawingSliders)
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/lib/utils/buildSceneProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export const buildSceneProps = (
},
tool: {
type: ToolType.Brush,
size: 10.0, // Default brush size as percentage (5-20% range)
size: 2, // Default brush size in grid units (0.25-3 range)
mode: DrawMode.Erase
},
edge: {
Expand Down
33 changes: 11 additions & 22 deletions apps/web/src/lib/utils/gameSessionPreferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,8 @@ export interface PaneConfig {
}

export interface GameSessionPreferences {
brushSize?: number;
brushSizePercent?: number;
annotationLineWidth?: number;
annotationLineWidthPercent?: number;
brushSizeGridUnits?: number;
annotationLineWidthGridUnits?: number;
annotationSmoothing?: boolean;
paneLayoutDesktop?: PaneConfig[];
paneLayoutMobile?: PaneConfig[];
Expand All @@ -35,25 +33,16 @@ const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
// Preference configurations
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const PREFERENCE_CONFIGS: Record<keyof GameSessionPreferences, PreferenceConfig<any>> = {
brushSize: {
cookieName: 'tableslayer:brushSize',
defaultValue: 75, // Old pixel-based default (deprecated)
validate: (value): value is number => typeof value === 'number' && value >= 10 && value <= 1000
brushSizeGridUnits: {
cookieName: 'tableslayer:brushSizeGridUnits',
defaultValue: 2,
validate: (value): value is number =>
typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 5
},
brushSizePercent: {
cookieName: 'tableslayer:brushSizePercent',
defaultValue: 10.0, // New percentage-based default (5-20% range)
validate: (value): value is number => typeof value === 'number' && value >= 5 && value <= 20
},
annotationLineWidth: {
cookieName: 'tableslayer:annotationLineWidth',
defaultValue: 50,
validate: (value): value is number => typeof value === 'number' && value >= 1 && value <= 200
},
annotationLineWidthPercent: {
cookieName: 'tableslayer:annotationLineWidthPercent',
defaultValue: 2.0,
validate: (value): value is number => typeof value === 'number' && value >= 0.01 && value <= 5.0
annotationLineWidthGridUnits: {
cookieName: 'tableslayer:annotationLineWidthGridUnits',
defaultValue: 0.5,
validate: (value): value is number => typeof value === 'number' && value >= 0.25 && value <= 5
},
annotationSmoothing: {
cookieName: 'tableslayer:annotationSmoothing',
Expand Down
19 changes: 1 addition & 18 deletions apps/web/src/lib/utils/handleStageZoom.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { type StageProps, MapLayerType } from '@tableslayer/stage';
import { type StageProps } from '@tableslayer/stage';
import { trackChecklistItem } from './checklistTracker';
import { setPreferenceDebounced } from './gameSessionPreferences';
import { queuePropertyUpdate } from './propertyUpdateBroadcaster';

export const handleStageZoom = (e: WheelEvent, stageProps: StageProps) => {
Expand All @@ -23,21 +22,5 @@ export const handleStageZoom = (e: WheelEvent, stageProps: StageProps) => {
e.preventDefault();
const newSceneZoom = Math.max(minZoom, Math.min(stageProps.scene.zoom - scrollDelta, maxZoom));
queuePropertyUpdate(stageProps, ['scene', 'zoom'], newSceneZoom, 'control');
} else if (stageProps.activeLayer === MapLayerType.FogOfWar) {
const newFogSize = Math.round(Math.max(40, Math.min(stageProps.fogOfWar.tool.size - 500.0 * scrollDelta, 200)));
queuePropertyUpdate(stageProps, ['fogOfWar', 'tool', 'size'], newFogSize, 'control');
// Save brush size to cookie with debouncing
setPreferenceDebounced('brushSize', newFogSize);
} else if (stageProps.activeLayer === MapLayerType.Annotation) {
// Handle annotation line width adjustment
const currentLineWidth = stageProps.annotations.lineWidth || 50;
const lineWidthDelta = scrollDelta * 200; // Scale to make it more responsive
const newLineWidth = Math.round(Math.max(1, Math.min(currentLineWidth - lineWidthDelta, 200)));

// Update annotation line width locally
queuePropertyUpdate(stageProps, ['annotations', 'lineWidth'], newLineWidth, 'control');

// Save preference with debouncing
setPreferenceDebounced('annotationLineWidth', newLineWidth);
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ export const load: PageServerLoad = async ({ parent, params, url, cookies }) =>

const paneLayoutDesktop = getPreferenceServer(cookies, 'paneLayoutDesktop');
const paneLayoutMobile = getPreferenceServer(cookies, 'paneLayoutMobile');
const brushSize = getPreferenceServer(cookies, 'brushSize');

const checklistState = await getUserChecklistState(user.id);
const isEligibleForAutoShow = checkUserChecklistEligibility(
Expand All @@ -75,7 +74,6 @@ export const load: PageServerLoad = async ({ parent, params, url, cookies }) =>
activeScene,
paneLayoutDesktop,
paneLayoutMobile,
brushSize,
partykitHost,
checklistState: {
completedItems: checklistState.completedItems,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,15 +149,13 @@
// stageProps identical to the doc. Local-only view state is carried over.
// ---------------------------------------------------------------------------

const getMinBrushSize = (gridSpacing: number, displayWidth: number) => {
if (gridSpacing && displayWidth && displayWidth > 0) {
return Math.max(0.5, (gridSpacing / displayWidth) * 100);
}
return 2;
};

const clampFogBrush = (value: number, props: StageProps) =>
Math.max(getMinBrushSize(props.grid.spacing, props.display.size.x), Math.min(20, value));
// Brush sizes are in grid units (number of grid squares the brush spans).
// Fog uses whole squares; annotations use quarter steps up to one square,
// then whole squares (must match BRUSH_SIZES in DrawingSliders).
const ANNOTATION_BRUSH_SIZES = [0.25, 0.5, 0.75, 1, 2, 3, 4, 5];
const clampFogBrush = (value: number) => Math.max(1, Math.min(5, Math.round(value)));
const snapAnnotationBrush = (value: number) =>
ANNOTATION_BRUSH_SIZES.reduce((best, size) => (Math.abs(size - value) < Math.abs(best - value) ? size : best));

// SSR strips masks from annotation rows (serialization) and ships them as a
// separate record; merge them back so the seed props paint layers immediately
Expand All @@ -176,8 +174,8 @@
data.selectedSceneLights,
data.bucketUrl
);
props.fogOfWar.tool.size = clampFogBrush(getPreference('brushSizePercent') || 10.0, props);
props.annotations.lineWidth = Math.max(0.01, Math.min(5.0, getPreference('annotationLineWidthPercent') || 2.0));
props.fogOfWar.tool.size = clampFogBrush(getPreference('brushSizeGridUnits') || 2);
props.annotations.lineWidth = snapAnnotationBrush(getPreference('annotationLineWidthGridUnits') || 0.5);
props.annotations.smoothingEnabled = getPreference('annotationSmoothing') ?? true;
return props;
});
Expand Down Expand Up @@ -205,11 +203,8 @@
data.selectedSceneLights,
data.bucketUrl
);
props.fogOfWar.tool.size = clampFogBrush(getPreference('brushSizePercent') || 10.0, props);
props.annotations.lineWidth = Math.max(
0.01,
Math.min(5.0, getPreference('annotationLineWidthPercent') || 2.0)
);
props.fogOfWar.tool.size = clampFogBrush(getPreference('brushSizeGridUnits') || 2);
props.annotations.lineWidth = snapAnnotationBrush(getPreference('annotationLineWidthGridUnits') || 0.5);
props.annotations.smoothingEnabled = getPreference('annotationSmoothing') ?? true;
stageProps = props;
activeControl = 'none';
Expand Down Expand Up @@ -242,12 +237,12 @@
: { offset: prev.scene.offset, zoom: prev.scene.zoom, rotation: prev.scene.rotation },
markerPositions: drags,
fogTool: isSceneSwitch
? { size: clampFogBrush(getPreference('brushSizePercent') || 10.0, prev) }
? { size: clampFogBrush(getPreference('brushSizeGridUnits') || 2) }
: { type: prev.fogOfWar.tool.type, size: prev.fogOfWar.tool.size, mode: prev.fogOfWar.tool.mode },
annotations: isSceneSwitch
? {
activeLayer: null,
lineWidth: Math.max(0.01, Math.min(5.0, getPreference('annotationLineWidthPercent') || 2.0)),
lineWidth: snapAnnotationBrush(getPreference('annotationLineWidthGridUnits') || 0.5),
smoothingEnabled: getPreference('annotationSmoothing') ?? true
}
: {
Expand Down Expand Up @@ -1000,26 +995,39 @@
queuePropertyUpdate(stageProps, ['scene', 'zoom'], zoom, 'control');
}

// Brush sizes step through discrete values, so accumulate wheel deltas and
// emit one step (+1 grows, -1 shrinks) per ~100 units (one wheel notch)
let brushWheelAccum = 0;
const brushWheelStep = (e: WheelEvent): number => {
brushWheelAccum += Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
if (Math.abs(brushWheelAccum) < 100) return 0;
const direction = brushWheelAccum > 0 ? -1 : 1;
brushWheelAccum = 0;
return direction;
};

const onWheel = (e: WheelEvent) => {
if (e.ctrlKey) e.preventDefault();

if (stageProps.activeLayer === MapLayerType.Annotation && !e.shiftKey && !e.ctrlKey) {
e.preventDefault();
const scrollDelta = (Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY) * 0.05;
const currentLineWidth = stageProps.annotations.lineWidth || 2.0;
const newLineWidth = Math.max(0.01, Math.min(currentLineWidth - scrollDelta * 0.01, 5.0));
const direction = brushWheelStep(e);
if (direction === 0) return;
const currentIndex = ANNOTATION_BRUSH_SIZES.indexOf(snapAnnotationBrush(stageProps.annotations.lineWidth || 0.5));
const newLineWidth =
ANNOTATION_BRUSH_SIZES[Math.max(0, Math.min(ANNOTATION_BRUSH_SIZES.length - 1, currentIndex + direction))];
stageProps.annotations.lineWidth = newLineWidth;
setPreference('annotationLineWidthPercent', newLineWidth);
setPreference('annotationLineWidthGridUnits', newLineWidth);
return;
}

if (stageProps.activeLayer === MapLayerType.FogOfWar && !e.shiftKey && !e.ctrlKey) {
e.preventDefault();
const scrollDelta = (Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY) * 0.1;
const currentSize = stageProps.fogOfWar.tool.size || 10.0;
const newSize = clampFogBrush(currentSize - scrollDelta * 0.1, stageProps);
const direction = brushWheelStep(e);
if (direction === 0) return;
const newSize = clampFogBrush((stageProps.fogOfWar.tool.size || 2) + direction);
stageProps.fogOfWar.tool.size = newSize;
setPreference('brushSizePercent', newSize);
setPreference('brushSizeGridUnits', newSize);
return;
}

Expand Down Expand Up @@ -1154,7 +1162,7 @@
{#if stageProps.activeLayer === MapLayerType.Annotation && activeAnnotation && handleOpacityChange && handleBrushSizeChange && handleColorChange}
<DrawingSliders
opacity={activeAnnotation.opacity}
brushSize={stageProps.annotations.lineWidth || 2.0}
brushSize={stageProps.annotations.lineWidth || 0.5}
color={activeAnnotation.color}
currentEffect={activeAnnotation.effect?.type ?? AnnotationEffect.None}
activeLayerIndex={stageProps.annotations.layers.findIndex(
Expand All @@ -1169,12 +1177,10 @@
{/if}
{#if stageProps.activeLayer === MapLayerType.FogOfWar && stageProps.fogOfWar.tool.type === ToolType.Brush}
<FogSliders
brushSize={stageProps.fogOfWar.tool.size || 10.0}
gridSpacing={stageProps.grid.spacing}
displayWidth={stageProps.display.size.x}
brushSize={stageProps.fogOfWar.tool.size || 2}
onBrushSizeChange={(size) => {
queuePropertyUpdate(stageProps, ['fogOfWar', 'tool', 'size'], size, 'control');
setPreference('brushSizePercent', size);
setPreference('brushSizeGridUnits', size);
}}
/>
{/if}
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/routes/(app)/[party]/play/usePlayTools.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ const DRAW_EFFECTS: Record<string, AnnotationEffect> = {
'effect-entangle': AnnotationEffect.Entangle
};

const PLAYFIELD_FOG_BRUSH_SIZE = 7.0;
// Fog brush size in grid units (number of grid squares the brush spans)
const PLAYFIELD_FOG_BRUSH_SIZE = 2;
const FOG_COMMIT_DEBOUNCE_MS = 500;
const DRAWING_IDLE_RESET_MS = 3000;
const PERSIST_BUTTON_HIDE_MS = 3000;
Expand All @@ -71,7 +72,7 @@ export class PlayTools {
// Local-only view state (never shared)
activeLayer = $state<MapLayerType>(MapLayerType.None);
annotationsActiveLayer = $state<string | null>(null);
lineWidth = $state(1.0);
lineWidth = $state(0.25);
fogTool = $state<{ mode: DrawMode; size: number }>({ mode: DrawMode.Erase, size: PLAYFIELD_FOG_BRUSH_SIZE });
measurement = $state<{ type: number; coneAngle?: number; beamWidth?: number }>({ type: MeasurementType.Line });

Expand Down Expand Up @@ -350,7 +351,7 @@ export class PlayTools {

this.annotationsActiveLayer = this.currentTemporaryLayerId;
this.activeLayer = MapLayerType.Annotation;
this.lineWidth = 1.0;
this.lineWidth = 0.25;
this.resetToNoneAfterDelay();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,29 +100,20 @@
// Check if current selection is an effect
const hasEffect = $derived(currentEffect !== AnnotationEffect.None);

// Brush size is now stored as a percentage (0.01% to 5%)
// Use quadratic curve for slider to give more precision to lower values
// Slider range: 0-100, maps to percentage range: 0.01-5.0
// At 50% slider we want 2%, so we use: percentage = 0.0008 * slider^2
// This gives: 10% → 0.08%, 50% → 2%, 100% → 8% (capped at 5%)
const percentageToSlider = (percentage: number): number => {
// Inverse: slider = sqrt(percentage / 0.0008)
// Clamp to minimum of 0.01
const clampedPercentage = Math.max(0.01, percentage);
return Math.sqrt(clampedPercentage / 0.0008);
};

const sliderToPercentage = (slider: number): number => {
// Quadratic curve: percentage = 0.0008 * slider^2
const percentage = 0.0008 * slider * slider;
return Math.max(0.01, Math.min(5.0, percentage));
};

let brushSliderValue = $derived(percentageToSlider(brushSize));

const handleBrushSliderChange = (value: number) => {
const actualPercentage = sliderToPercentage(value);
onBrushSizeChange(actualPercentage);
// Brush size is stored in grid units (number of grid squares the line width
// spans on the display): quarter-square steps up to one square, then whole squares.
// The slider operates on indices into this list since the steps are non-uniform.
const BRUSH_SIZES = [0.25, 0.5, 0.75, 1, 2, 3, 4, 5];

const brushSliderIndex = $derived(
BRUSH_SIZES.reduce(
(best, size, i) => (Math.abs(size - brushSize) < Math.abs(BRUSH_SIZES[best] - brushSize) ? i : best),
0
)
);

const handleBrushSliderChange = (index: number) => {
onBrushSizeChange(BRUSH_SIZES[Math.max(0, Math.min(BRUSH_SIZES.length - 1, Math.round(index)))]);
};

const handleColorSelect = (selectedColor: string, close?: () => void) => {
Expand Down Expand Up @@ -209,14 +200,15 @@
type="range"
class="drawingSliders__input"
min="0"
max="100"
step="0.1"
value={brushSliderValue}
max={BRUSH_SIZES.length - 1}
step="1"
value={brushSliderIndex}
oninput={(e) => handleBrushSliderChange(Number(e.currentTarget.value))}
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
title="Brush size in grid squares"
/>
<div class="drawingSliders__value">{brushSize.toFixed(2)}%</div>
<div class="drawingSliders__value">{String(Number(brushSize.toFixed(2))).replace(/^0\./, '.')} sq</div>
</div>

<IconButton
Expand Down
Loading
Loading