Add simple whiteboard

This commit is contained in:
2026-03-12 14:49:41 +01:00
parent f000b4423d
commit 5a458d7fca
9 changed files with 398 additions and 2 deletions

View File

@@ -0,0 +1,261 @@
import { handleListControlKey } from '../input/listController';
import type { OutgoingMessage } from '../network/protocol';
import type { GameMode, WorldItem } from '../state/gameState';
const LINE_ACTIONS = ['Edit', 'Delete'] as const;
type WhiteboardControllerDeps = {
state: {
mode: GameMode;
nicknameInput: string;
cursorPos: number;
items: Map<string, WorldItem>;
whiteboardItemId: string | null;
whiteboardLineIndex: number;
whiteboardLineActionIndex: number;
whiteboardEditingLineIndex: number | null;
};
signalingSend: (message: OutgoingMessage) => void;
updateStatus: (message: string) => void;
sfxUiBlip: () => void;
sfxUiCancel: () => void;
applyTextInputEdit: (code: string, key: string, maxLength: number, ctrlKey?: boolean) => void;
setReplaceTextOnNextType: (value: boolean) => void;
};
function getLines(item: WorldItem): string[] {
const raw = item.params['lines'];
if (!Array.isArray(raw)) return [];
return raw.filter((l): l is string => typeof l === 'string');
}
export function createWhiteboardController(deps: WhiteboardControllerDeps): {
beginWhiteboardLines: (item: WorldItem) => void;
handleWhiteboardLinesModeInput: (code: string, key: string) => void;
handleWhiteboardLineActionsModeInput: (code: string, key: string) => void;
handleWhiteboardLineEditModeInput: (code: string, key: string, ctrlKey: boolean) => void;
refreshWhiteboardStatus: () => void;
} {
function beginWhiteboardLines(item: WorldItem): void {
deps.state.whiteboardItemId = item.id;
deps.state.whiteboardLineIndex = 0;
deps.state.whiteboardLineActionIndex = 0;
deps.state.whiteboardEditingLineIndex = null;
deps.state.mode = 'whiteboardLines';
const lines = getLines(item);
const n = lines.length;
const countText = `${n} line${n !== 1 ? 's' : ''}`;
const firstEntry = n > 0 ? lines[0] : 'Add line';
deps.updateStatus(`${item.title}. ${countText}. ${firstEntry}.`);
deps.sfxUiBlip();
}
function handleWhiteboardLinesModeInput(code: string, key: string): void {
const item = deps.state.whiteboardItemId ? deps.state.items.get(deps.state.whiteboardItemId) : null;
if (!item) {
deps.state.mode = 'normal';
deps.state.whiteboardItemId = null;
return;
}
const lines = getLines(item);
const entries = [...lines, 'Add line'];
const control = handleListControlKey(code, key, entries, deps.state.whiteboardLineIndex, (e) => e);
if (control.type === 'move') {
deps.state.whiteboardLineIndex = control.index;
deps.updateStatus(entries[control.index]);
deps.sfxUiBlip();
return;
}
if (control.type === 'select') {
if (deps.state.whiteboardLineIndex === lines.length) {
// "Add line" selected
deps.state.whiteboardEditingLineIndex = null;
deps.state.nicknameInput = '';
deps.state.cursorPos = 0;
deps.setReplaceTextOnNextType(false);
deps.state.mode = 'whiteboardLineEdit';
deps.updateStatus('Add line. Type and press Enter.');
deps.sfxUiBlip();
} else {
// Select a line → actions submenu
deps.state.whiteboardLineActionIndex = 0;
deps.state.mode = 'whiteboardLineActions';
deps.updateStatus(`${lines[deps.state.whiteboardLineIndex]}. Edit or Delete.`);
deps.sfxUiBlip();
}
return;
}
if (control.type === 'cancel') {
deps.state.mode = 'normal';
deps.state.whiteboardItemId = null;
deps.updateStatus('Cancelled.');
deps.sfxUiCancel();
}
}
function handleWhiteboardLineActionsModeInput(code: string, key: string): void {
const item = deps.state.whiteboardItemId ? deps.state.items.get(deps.state.whiteboardItemId) : null;
if (!item) {
deps.state.mode = 'normal';
deps.state.whiteboardItemId = null;
return;
}
const lines = getLines(item);
const lineIndex = deps.state.whiteboardLineIndex;
if (lineIndex >= lines.length) {
deps.state.whiteboardLineIndex = Math.max(0, lines.length);
deps.state.mode = 'whiteboardLines';
const n = lines.length;
const countText = `${n} line${n !== 1 ? 's' : ''}`;
const firstEntry = n > 0 ? lines[0] : 'Add line';
deps.updateStatus(`${item.title}. ${countText}. ${firstEntry}.`);
deps.sfxUiCancel();
return;
}
const control = handleListControlKey(code, key, LINE_ACTIONS, deps.state.whiteboardLineActionIndex, (e) => e);
if (control.type === 'move') {
deps.state.whiteboardLineActionIndex = control.index;
deps.updateStatus(LINE_ACTIONS[control.index]);
deps.sfxUiBlip();
return;
}
if (control.type === 'select') {
if (deps.state.whiteboardLineActionIndex === 0) {
// Edit
deps.state.whiteboardEditingLineIndex = lineIndex;
deps.state.nicknameInput = lines[lineIndex];
deps.state.cursorPos = lines[lineIndex].length;
deps.setReplaceTextOnNextType(true);
deps.state.mode = 'whiteboardLineEdit';
deps.updateStatus(`Edit line. ${lines[lineIndex]}`);
deps.sfxUiBlip();
} else {
// Delete
const newLines = [...lines];
newLines.splice(lineIndex, 1);
deps.signalingSend({ type: 'item_update', itemId: item.id, params: { lines: newLines } });
deps.state.whiteboardLineIndex = Math.min(deps.state.whiteboardLineIndex, newLines.length);
deps.state.mode = 'whiteboardLines';
deps.updateStatus('Line deleted.');
deps.sfxUiBlip();
}
return;
}
if (control.type === 'cancel') {
deps.state.mode = 'whiteboardLines';
deps.updateStatus(lines[lineIndex]);
deps.sfxUiCancel();
}
}
function handleWhiteboardLineEditModeInput(code: string, key: string, ctrlKey: boolean): void {
const item = deps.state.whiteboardItemId ? deps.state.items.get(deps.state.whiteboardItemId) : null;
if (!item) {
deps.state.mode = 'normal';
deps.state.whiteboardItemId = null;
return;
}
if (code === 'Enter') {
const text = deps.state.nicknameInput.trim();
if (!text) {
deps.updateStatus('Cannot add empty line.');
deps.sfxUiCancel();
return;
}
const lines = getLines(item);
const editIndex = deps.state.whiteboardEditingLineIndex;
let newLines: string[];
if (editIndex !== null) {
newLines = [...lines];
newLines[editIndex] = text;
} else {
newLines = [...lines, text];
}
deps.signalingSend({ type: 'item_update', itemId: item.id, params: { lines: newLines } });
if (editIndex === null) {
deps.state.whiteboardLineIndex = newLines.length - 1;
}
deps.state.nicknameInput = '';
deps.state.cursorPos = 0;
deps.setReplaceTextOnNextType(false);
deps.state.whiteboardEditingLineIndex = null;
deps.state.mode = 'whiteboardLines';
deps.updateStatus(editIndex !== null ? 'Line updated.' : 'Line added.');
deps.sfxUiBlip();
return;
}
if (code === 'Escape') {
const wasEditing = deps.state.whiteboardEditingLineIndex !== null;
deps.state.nicknameInput = '';
deps.state.cursorPos = 0;
deps.setReplaceTextOnNextType(false);
deps.state.whiteboardEditingLineIndex = null;
if (wasEditing) {
deps.state.mode = 'whiteboardLineActions';
const lines = getLines(item);
const line = lines[deps.state.whiteboardLineIndex] ?? '';
deps.updateStatus(`${line}. Edit or Delete.`);
deps.sfxUiCancel();
} else {
deps.state.mode = 'whiteboardLines';
deps.updateStatus('Cancelled.');
deps.sfxUiCancel();
}
return;
}
deps.applyTextInputEdit(code, key, 200, ctrlKey);
}
function refreshWhiteboardStatus(): void {
const item = deps.state.whiteboardItemId ? deps.state.items.get(deps.state.whiteboardItemId) : null;
if (!item) return;
const lines = getLines(item);
const n = lines.length;
// Clamp index if lines shrank
deps.state.whiteboardLineIndex = Math.min(deps.state.whiteboardLineIndex, n);
if (deps.state.mode === 'whiteboardLines') {
const entries = [...lines, 'Add line'];
const current = entries[deps.state.whiteboardLineIndex] ?? 'Add line';
deps.updateStatus(`Updated. ${current}.`);
} else if (deps.state.mode === 'whiteboardLineActions') {
if (deps.state.whiteboardLineIndex < lines.length) {
deps.updateStatus(`Updated. ${lines[deps.state.whiteboardLineIndex]}. Edit or Delete.`);
} else {
deps.state.mode = 'whiteboardLines';
deps.state.whiteboardLineIndex = Math.max(0, n);
const countText = `${n} line${n !== 1 ? 's' : ''}`;
deps.updateStatus(`Updated. ${countText}.`);
}
}
}
return {
beginWhiteboardLines,
handleWhiteboardLinesModeInput,
handleWhiteboardLineActionsModeInput,
handleWhiteboardLineEditModeInput,
refreshWhiteboardStatus,
};
}

View File

@@ -64,6 +64,7 @@ import {
itemTypeLabel,
} from './items/itemRegistry';
import { createItemInteractionController } from './items/itemInteractionController';
import { createWhiteboardController } from './items/whiteboardController';
import { createItemPropertyEditor } from './items/itemPropertyEditor';
import { createItemPropertyPresentation } from './items/itemPropertyPresentation';
import { ItemBehaviorRegistry } from './items/types/behaviorRegistry';
@@ -461,6 +462,17 @@ const itemInteractionController = createItemInteractionController({
useItem: (item) => useItem(item),
secondaryUseItem: (item) => secondaryUseItem(item),
});
const whiteboardController = createWhiteboardController({
state,
signalingSend: (message) => signaling.send(message),
updateStatus,
sfxUiBlip: () => audio.sfxUiBlip(),
sfxUiCancel: () => audio.sfxUiCancel(),
applyTextInputEdit,
setReplaceTextOnNextType: (value) => {
replaceTextOnNextType = value;
},
});
/** Toggles updates panel visibility and syncs associated ARIA state. */
function setUpdatesExpanded(expanded: boolean): void {
@@ -970,6 +982,10 @@ function recomputeActiveItemPropertyKeys(itemId: string): void {
/** Sends an item-use request for the selected item. */
function useItem(item: WorldItem): void {
if (item.type === 'whiteboard') {
whiteboardController.beginWhiteboardLines(item);
return;
}
signaling.send({ type: 'item_use', itemId: item.id });
}
@@ -1001,6 +1017,7 @@ function textInputMaxLengthForMode(mode: typeof state.mode): number | null {
if (mode === 'itemPropertyEdit') return 500;
if (mode === 'micGainEdit') return 8;
if (mode === 'adminRoleNameEdit') return 32;
if (mode === 'whiteboardLineEdit') return 200;
return null;
}
@@ -1025,7 +1042,8 @@ function isTextEditingMode(mode: typeof state.mode): boolean {
mode === 'chat' ||
mode === 'itemPropertyEdit' ||
mode === 'micGainEdit' ||
mode === 'adminRoleNameEdit'
mode === 'adminRoleNameEdit' ||
mode === 'whiteboardLineEdit'
);
}
@@ -1586,6 +1604,7 @@ const onAppMessage = createOnMessageHandler({
connectToLiveKit: (url, token) => {
void connectLiveKit(url, token);
},
refreshWhiteboardStatus: () => whiteboardController.refreshWhiteboardStatus(),
});
/** Handles signaling packets with heartbeat/restart metadata before app-level dispatch. */
@@ -2627,6 +2646,12 @@ function handleModeInput(input: ModeInput): void {
itemPropertyEditor.handleItemPropertyEditModeInput(currentCode, currentKey, currentCtrlKey),
itemPropertyOptionSelect: ({ code: currentCode, key: currentKey }) =>
itemPropertyEditor.handleItemPropertyOptionSelectModeInput(currentCode, currentKey),
whiteboardLines: ({ code: currentCode, key: currentKey }) =>
whiteboardController.handleWhiteboardLinesModeInput(currentCode, currentKey),
whiteboardLineActions: ({ code: currentCode, key: currentKey }) =>
whiteboardController.handleWhiteboardLineActionsModeInput(currentCode, currentKey),
whiteboardLineEdit: ({ code: currentCode, key: currentKey, ctrlKey: currentCtrlKey }) =>
whiteboardController.handleWhiteboardLineEditModeInput(currentCode, currentKey, currentCtrlKey),
},
onNormalMode: handleNormalModeInput,
});

View File

@@ -22,7 +22,9 @@ type MessageHandlerDeps = {
itemPropertyKeys: string[];
itemPropertyIndex: number;
carriedItemId: string | null;
whiteboardItemId: string | null;
};
refreshWhiteboardStatus: () => void;
dom: {
connectButton: HTMLElement;
disconnectButton: HTMLElement;
@@ -258,6 +260,12 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
deps.updateStatus(`${deps.itemPropertyLabel(key)}: ${deps.getItemPropertyValue(message.item, key)}`);
}
}
if (
deps.state.whiteboardItemId === message.item.id &&
(deps.state.mode === 'whiteboardLines' || deps.state.mode === 'whiteboardLineActions')
) {
deps.refreshWhiteboardStatus();
}
await deps.refreshAudioSubscriptions(true);
break;
}

View File

@@ -51,7 +51,10 @@ export type GameMode =
| 'adminUserRoleSelect'
| 'adminUserDeleteConfirm'
| 'adminRoleNameEdit'
| 'pianoUse';
| 'pianoUse'
| 'whiteboardLines'
| 'whiteboardLineActions'
| 'whiteboardLineEdit';
export type Player = {
id: string | null;
@@ -96,6 +99,10 @@ export type GameState = {
peers: Map<string, PeerState>;
items: Map<string, WorldItem>;
carriedItemId: string | null;
whiteboardItemId: string | null;
whiteboardLineIndex: number;
whiteboardLineActionIndex: number;
whiteboardEditingLineIndex: number | null;
};
export function createInitialState(): GameState {
@@ -132,6 +139,10 @@ export function createInitialState(): GameState {
peers: new Map(),
items: new Map(),
carriedItemId: null,
whiteboardItemId: null,
whiteboardLineIndex: 0,
whiteboardLineActionIndex: 0,
whiteboardEditingLineIndex: null,
};
}

View File

@@ -0,0 +1 @@
"""Whiteboard item type package."""

View File

@@ -0,0 +1,23 @@
"""Whiteboard item use actions."""
from __future__ import annotations
from typing import Callable
from ....item_types import ItemUseResult
from ....models import WorldItem
def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult:
"""Report whiteboard contents to the user who used it."""
lines = item.params.get("lines", [])
if not isinstance(lines, list):
lines = []
n = len(lines)
line_text = f"{n} line{'s' if n != 1 else ''}"
return ItemUseResult(
self_message=f"You open {item.title}. {line_text}.",
others_message=f"{nickname} opens {item.title}.",
)

View File

@@ -0,0 +1,19 @@
"""Whiteboard item static metadata and defaults."""
from __future__ import annotations
LABEL = "whiteboard"
TOOLTIP = "A shared text board. Use to read and edit lines."
EDITABLE_PROPERTIES: tuple[str, ...] = ("title",)
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
USE_SOUND: str | None = None
EMIT_SOUND: str | None = None
USE_COOLDOWN_MS = 500
EMIT_RANGE = 15
DIRECTIONAL = False
DEFAULT_TITLE = "whiteboard"
DEFAULT_PARAMS: dict = {"lines": []}
PARAM_KEYS: tuple[str, ...] = ("lines",)
PROPERTY_METADATA: dict[str, dict[str, object]] = {
"title": {"valueType": "text", "tooltip": "Display name.", "maxLength": 80},
}

View File

@@ -0,0 +1,16 @@
"""Plugin registration for whiteboard item type."""
from __future__ import annotations
from ..plugin_helpers import build_item_module
from . import actions, definition, validator
ITEM_TYPE_PLUGIN = {
"type": "whiteboard",
"order": 70,
"module": build_item_module(
definition,
validate_update=validator.validate_update,
use_item=actions.use_item,
),
}

View File

@@ -0,0 +1,32 @@
"""Whiteboard item validation/normalization."""
from __future__ import annotations
from ....models import WorldItem
from ...helpers import keep_only_known_params
from .definition import PARAM_KEYS
_MAX_LINES = 20
_MAX_LINE_LENGTH = 200
def validate_update(_item: WorldItem, next_params: dict) -> dict:
"""Validate and normalize whiteboard params."""
lines = next_params.get("lines", [])
if not isinstance(lines, list):
raise ValueError("lines must be a list.")
if len(lines) > _MAX_LINES:
raise ValueError(f"A whiteboard can have at most {_MAX_LINES} lines.")
cleaned: list[str] = []
for line in lines:
if not isinstance(line, str):
raise ValueError("Each line must be a string.")
stripped = line.strip()
if len(stripped) > _MAX_LINE_LENGTH:
raise ValueError(f"Each line must be at most {_MAX_LINE_LENGTH} characters.")
cleaned.append(stripped)
next_params["lines"] = cleaned
return keep_only_known_params(next_params, PARAM_KEYS)