Add simple whiteboard
This commit is contained in:
261
client/src/items/whiteboardController.ts
Normal file
261
client/src/items/whiteboardController.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -64,6 +64,7 @@ import {
|
|||||||
itemTypeLabel,
|
itemTypeLabel,
|
||||||
} from './items/itemRegistry';
|
} from './items/itemRegistry';
|
||||||
import { createItemInteractionController } from './items/itemInteractionController';
|
import { createItemInteractionController } from './items/itemInteractionController';
|
||||||
|
import { createWhiteboardController } from './items/whiteboardController';
|
||||||
import { createItemPropertyEditor } from './items/itemPropertyEditor';
|
import { createItemPropertyEditor } from './items/itemPropertyEditor';
|
||||||
import { createItemPropertyPresentation } from './items/itemPropertyPresentation';
|
import { createItemPropertyPresentation } from './items/itemPropertyPresentation';
|
||||||
import { ItemBehaviorRegistry } from './items/types/behaviorRegistry';
|
import { ItemBehaviorRegistry } from './items/types/behaviorRegistry';
|
||||||
@@ -461,6 +462,17 @@ const itemInteractionController = createItemInteractionController({
|
|||||||
useItem: (item) => useItem(item),
|
useItem: (item) => useItem(item),
|
||||||
secondaryUseItem: (item) => secondaryUseItem(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. */
|
/** Toggles updates panel visibility and syncs associated ARIA state. */
|
||||||
function setUpdatesExpanded(expanded: boolean): void {
|
function setUpdatesExpanded(expanded: boolean): void {
|
||||||
@@ -970,6 +982,10 @@ function recomputeActiveItemPropertyKeys(itemId: string): void {
|
|||||||
|
|
||||||
/** Sends an item-use request for the selected item. */
|
/** Sends an item-use request for the selected item. */
|
||||||
function useItem(item: WorldItem): void {
|
function useItem(item: WorldItem): void {
|
||||||
|
if (item.type === 'whiteboard') {
|
||||||
|
whiteboardController.beginWhiteboardLines(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
signaling.send({ type: 'item_use', itemId: item.id });
|
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 === 'itemPropertyEdit') return 500;
|
||||||
if (mode === 'micGainEdit') return 8;
|
if (mode === 'micGainEdit') return 8;
|
||||||
if (mode === 'adminRoleNameEdit') return 32;
|
if (mode === 'adminRoleNameEdit') return 32;
|
||||||
|
if (mode === 'whiteboardLineEdit') return 200;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1025,7 +1042,8 @@ function isTextEditingMode(mode: typeof state.mode): boolean {
|
|||||||
mode === 'chat' ||
|
mode === 'chat' ||
|
||||||
mode === 'itemPropertyEdit' ||
|
mode === 'itemPropertyEdit' ||
|
||||||
mode === 'micGainEdit' ||
|
mode === 'micGainEdit' ||
|
||||||
mode === 'adminRoleNameEdit'
|
mode === 'adminRoleNameEdit' ||
|
||||||
|
mode === 'whiteboardLineEdit'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1586,6 +1604,7 @@ const onAppMessage = createOnMessageHandler({
|
|||||||
connectToLiveKit: (url, token) => {
|
connectToLiveKit: (url, token) => {
|
||||||
void connectLiveKit(url, token);
|
void connectLiveKit(url, token);
|
||||||
},
|
},
|
||||||
|
refreshWhiteboardStatus: () => whiteboardController.refreshWhiteboardStatus(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Handles signaling packets with heartbeat/restart metadata before app-level dispatch. */
|
/** 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),
|
itemPropertyEditor.handleItemPropertyEditModeInput(currentCode, currentKey, currentCtrlKey),
|
||||||
itemPropertyOptionSelect: ({ code: currentCode, key: currentKey }) =>
|
itemPropertyOptionSelect: ({ code: currentCode, key: currentKey }) =>
|
||||||
itemPropertyEditor.handleItemPropertyOptionSelectModeInput(currentCode, 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,
|
onNormalMode: handleNormalModeInput,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ type MessageHandlerDeps = {
|
|||||||
itemPropertyKeys: string[];
|
itemPropertyKeys: string[];
|
||||||
itemPropertyIndex: number;
|
itemPropertyIndex: number;
|
||||||
carriedItemId: string | null;
|
carriedItemId: string | null;
|
||||||
|
whiteboardItemId: string | null;
|
||||||
};
|
};
|
||||||
|
refreshWhiteboardStatus: () => void;
|
||||||
dom: {
|
dom: {
|
||||||
connectButton: HTMLElement;
|
connectButton: HTMLElement;
|
||||||
disconnectButton: HTMLElement;
|
disconnectButton: HTMLElement;
|
||||||
@@ -258,6 +260,12 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
|
|||||||
deps.updateStatus(`${deps.itemPropertyLabel(key)}: ${deps.getItemPropertyValue(message.item, key)}`);
|
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);
|
await deps.refreshAudioSubscriptions(true);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ export type GameMode =
|
|||||||
| 'adminUserRoleSelect'
|
| 'adminUserRoleSelect'
|
||||||
| 'adminUserDeleteConfirm'
|
| 'adminUserDeleteConfirm'
|
||||||
| 'adminRoleNameEdit'
|
| 'adminRoleNameEdit'
|
||||||
| 'pianoUse';
|
| 'pianoUse'
|
||||||
|
| 'whiteboardLines'
|
||||||
|
| 'whiteboardLineActions'
|
||||||
|
| 'whiteboardLineEdit';
|
||||||
|
|
||||||
export type Player = {
|
export type Player = {
|
||||||
id: string | null;
|
id: string | null;
|
||||||
@@ -96,6 +99,10 @@ export type GameState = {
|
|||||||
peers: Map<string, PeerState>;
|
peers: Map<string, PeerState>;
|
||||||
items: Map<string, WorldItem>;
|
items: Map<string, WorldItem>;
|
||||||
carriedItemId: string | null;
|
carriedItemId: string | null;
|
||||||
|
whiteboardItemId: string | null;
|
||||||
|
whiteboardLineIndex: number;
|
||||||
|
whiteboardLineActionIndex: number;
|
||||||
|
whiteboardEditingLineIndex: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createInitialState(): GameState {
|
export function createInitialState(): GameState {
|
||||||
@@ -132,6 +139,10 @@ export function createInitialState(): GameState {
|
|||||||
peers: new Map(),
|
peers: new Map(),
|
||||||
items: new Map(),
|
items: new Map(),
|
||||||
carriedItemId: null,
|
carriedItemId: null,
|
||||||
|
whiteboardItemId: null,
|
||||||
|
whiteboardLineIndex: 0,
|
||||||
|
whiteboardLineActionIndex: 0,
|
||||||
|
whiteboardEditingLineIndex: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
server/app/items/types/whiteboard/__init__.py
Normal file
1
server/app/items/types/whiteboard/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Whiteboard item type package."""
|
||||||
23
server/app/items/types/whiteboard/actions.py
Normal file
23
server/app/items/types/whiteboard/actions.py
Normal 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}.",
|
||||||
|
)
|
||||||
19
server/app/items/types/whiteboard/definition.py
Normal file
19
server/app/items/types/whiteboard/definition.py
Normal 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},
|
||||||
|
}
|
||||||
16
server/app/items/types/whiteboard/plugin.py
Normal file
16
server/app/items/types/whiteboard/plugin.py
Normal 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,
|
||||||
|
),
|
||||||
|
}
|
||||||
32
server/app/items/types/whiteboard/validator.py
Normal file
32
server/app/items/types/whiteboard/validator.py
Normal 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)
|
||||||
Reference in New Issue
Block a user