From 878157efc0ca5d51663ad4100a5d93339ecc40fc Mon Sep 17 00:00:00 2001 From: Talon Date: Thu, 12 Mar 2026 14:49:41 +0100 Subject: [PATCH] Add simple whiteboard --- client/src/items/whiteboardController.ts | 261 ++++++++++++++++++ client/src/main.ts | 27 +- client/src/network/messageHandlers.ts | 8 + client/src/state/gameState.ts | 13 +- server/app/items/types/whiteboard/__init__.py | 1 + server/app/items/types/whiteboard/actions.py | 23 ++ .../app/items/types/whiteboard/definition.py | 19 ++ server/app/items/types/whiteboard/plugin.py | 16 ++ .../app/items/types/whiteboard/validator.py | 32 +++ 9 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 client/src/items/whiteboardController.ts create mode 100644 server/app/items/types/whiteboard/__init__.py create mode 100644 server/app/items/types/whiteboard/actions.py create mode 100644 server/app/items/types/whiteboard/definition.py create mode 100644 server/app/items/types/whiteboard/plugin.py create mode 100644 server/app/items/types/whiteboard/validator.py diff --git a/client/src/items/whiteboardController.ts b/client/src/items/whiteboardController.ts new file mode 100644 index 0000000..c1eed80 --- /dev/null +++ b/client/src/items/whiteboardController.ts @@ -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; + 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, + }; +} diff --git a/client/src/main.ts b/client/src/main.ts index b082193..381ee25 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -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, }); diff --git a/client/src/network/messageHandlers.ts b/client/src/network/messageHandlers.ts index ef7f5d7..271e7b2 100644 --- a/client/src/network/messageHandlers.ts +++ b/client/src/network/messageHandlers.ts @@ -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; } diff --git a/client/src/state/gameState.ts b/client/src/state/gameState.ts index e631941..d72e667 100644 --- a/client/src/state/gameState.ts +++ b/client/src/state/gameState.ts @@ -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; items: Map; 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, }; } diff --git a/server/app/items/types/whiteboard/__init__.py b/server/app/items/types/whiteboard/__init__.py new file mode 100644 index 0000000..ea72100 --- /dev/null +++ b/server/app/items/types/whiteboard/__init__.py @@ -0,0 +1 @@ +"""Whiteboard item type package.""" diff --git a/server/app/items/types/whiteboard/actions.py b/server/app/items/types/whiteboard/actions.py new file mode 100644 index 0000000..f7c3610 --- /dev/null +++ b/server/app/items/types/whiteboard/actions.py @@ -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}.", + ) diff --git a/server/app/items/types/whiteboard/definition.py b/server/app/items/types/whiteboard/definition.py new file mode 100644 index 0000000..d3982a9 --- /dev/null +++ b/server/app/items/types/whiteboard/definition.py @@ -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}, +} diff --git a/server/app/items/types/whiteboard/plugin.py b/server/app/items/types/whiteboard/plugin.py new file mode 100644 index 0000000..8315208 --- /dev/null +++ b/server/app/items/types/whiteboard/plugin.py @@ -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, + ), +} diff --git a/server/app/items/types/whiteboard/validator.py b/server/app/items/types/whiteboard/validator.py new file mode 100644 index 0000000..b207e42 --- /dev/null +++ b/server/app/items/types/whiteboard/validator.py @@ -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)