|
1 | | -const FOUNDATION_MILESTONES = Object.freeze([ |
2 | | - Object.freeze({ |
3 | | - milestone: "Tool shell", |
4 | | - notes: "Theme V2 page shell is available from the toolbox.", |
5 | | - status: "Ready", |
6 | | - }), |
7 | | - Object.freeze({ |
8 | | - milestone: "Local API contract", |
9 | | - notes: "Placeholder service boundary is reserved for later persistence work.", |
10 | | - status: "Placeholder", |
11 | | - }), |
12 | | - Object.freeze({ |
13 | | - milestone: "Editor", |
14 | | - notes: "Drawing and editing start in a later focused PR.", |
15 | | - status: "Deferred", |
16 | | - }), |
17 | | -]); |
| 1 | +import { createServerRepositoryClient } from "../../../../src/api/server-api-client.js"; |
| 2 | + |
| 3 | +const EMPTY_STATE = "Create an Object and assign a sprite or vector before editing hitboxes."; |
| 4 | +const DEFAULT_BOUNDS = Object.freeze({ height: 64, width: 64, x: 0, y: 0 }); |
| 5 | +const DEFAULT_ORIGIN = Object.freeze({ x: 0, y: 0 }); |
18 | 6 |
|
19 | 7 | const elements = { |
20 | | - contractCount: document.querySelector("[data-hitboxes-contract-count]"), |
21 | | - foundationStatus: document.querySelector("[data-hitboxes-foundation-status]"), |
| 8 | + boundingBox: document.querySelector("[data-hitboxes-bounding-box]"), |
| 9 | + list: document.querySelector("[data-hitboxes-object-list]"), |
22 | 10 | log: document.querySelector("[data-hitboxes-log]"), |
23 | | - mode: document.querySelector("[data-hitboxes-mode]"), |
24 | | - owner: document.querySelector("[data-hitboxes-owner]"), |
25 | | - persistence: document.querySelector("[data-hitboxes-persistence]"), |
26 | | - scopeCount: document.querySelector("[data-hitboxes-scope-count]"), |
27 | | - service: document.querySelector("[data-hitboxes-service]"), |
28 | | - table: document.querySelector("[data-hitboxes-foundation-table]"), |
29 | | - toolState: document.querySelector("[data-hitboxes-tool-state]"), |
| 11 | + metaBounds: document.querySelector("[data-hitboxes-meta-bounds]"), |
| 12 | + metaKey: document.querySelector("[data-hitboxes-meta-key]"), |
| 13 | + metaName: document.querySelector("[data-hitboxes-meta-name]"), |
| 14 | + metaOrigin: document.querySelector("[data-hitboxes-meta-origin]"), |
| 15 | + metaVisual: document.querySelector("[data-hitboxes-meta-visual]"), |
| 16 | + objectCount: document.querySelector("[data-hitboxes-object-count]"), |
| 17 | + originMarker: document.querySelector("[data-hitboxes-origin-marker]"), |
| 18 | + previewFallback: document.querySelector("[data-hitboxes-preview-fallback]"), |
| 19 | + previewFrame: document.querySelector("[data-hitboxes-preview-frame]"), |
| 20 | + previewImage: document.querySelector("[data-hitboxes-preview-image]"), |
| 21 | + previewSummary: document.querySelector("[data-hitboxes-preview-summary]"), |
| 22 | + previewTitle: document.querySelector("[data-hitboxes-preview-title]"), |
| 23 | + selectedKey: document.querySelector("[data-hitboxes-selected-key]"), |
| 24 | + sourceStatus: document.querySelector("[data-hitboxes-source-status]"), |
| 25 | + visualType: document.querySelector("[data-hitboxes-visual-type]"), |
| 26 | +}; |
| 27 | + |
| 28 | +const objectsRepository = createServerRepositoryClient("objects"); |
| 29 | +const state = { |
| 30 | + eligibleObjects: [], |
| 31 | + selectedKey: "", |
30 | 32 | }; |
31 | 33 |
|
| 34 | +function normalizeText(value) { |
| 35 | + return String(value || "").trim(); |
| 36 | +} |
| 37 | + |
| 38 | +function objectKey(object = {}) { |
| 39 | + return normalizeText(object.id || object.key || object.objectKey || object.name) |
| 40 | + .toLowerCase() |
| 41 | + .replace(/[^a-z0-9]+/g, "-") |
| 42 | + .replace(/^-+|-+$/g, ""); |
| 43 | +} |
| 44 | + |
32 | 45 | function setText(element, value) { |
33 | 46 | if (element) { |
34 | 47 | element.textContent = value; |
35 | 48 | } |
36 | 49 | } |
37 | 50 |
|
38 | | -function createCell(text) { |
| 51 | +function finiteNumber(value, fallback) { |
| 52 | + return Number.isFinite(value) ? value : fallback; |
| 53 | +} |
| 54 | + |
| 55 | +function normalizeBounds(source = {}) { |
| 56 | + const bounds = source.bounds && typeof source.bounds === "object" ? source.bounds : {}; |
| 57 | + const size = source.size && typeof source.size === "object" ? source.size : {}; |
| 58 | + return Object.freeze({ |
| 59 | + height: Math.max(1, finiteNumber(bounds.height ?? size.height, DEFAULT_BOUNDS.height)), |
| 60 | + width: Math.max(1, finiteNumber(bounds.width ?? size.width, DEFAULT_BOUNDS.width)), |
| 61 | + x: finiteNumber(bounds.x ?? source.x, DEFAULT_BOUNDS.x), |
| 62 | + y: finiteNumber(bounds.y ?? source.y, DEFAULT_BOUNDS.y), |
| 63 | + }); |
| 64 | +} |
| 65 | + |
| 66 | +function normalizeOrigin(source = {}) { |
| 67 | + const origin = source.origin && typeof source.origin === "object" |
| 68 | + ? source.origin |
| 69 | + : source.objectOrigin && typeof source.objectOrigin === "object" |
| 70 | + ? source.objectOrigin |
| 71 | + : DEFAULT_ORIGIN; |
| 72 | + return Object.freeze({ |
| 73 | + x: finiteNumber(origin.x, DEFAULT_ORIGIN.x), |
| 74 | + y: finiteNumber(origin.y, DEFAULT_ORIGIN.y), |
| 75 | + }); |
| 76 | +} |
| 77 | + |
| 78 | +function visualMetadataForObject(object = {}) { |
| 79 | + const render = object.render && typeof object.render === "object" ? object.render : {}; |
| 80 | + const assignedVisual = object.assignedVisualAsset && typeof object.assignedVisualAsset === "object" ? object.assignedVisualAsset : {}; |
| 81 | + const visualAsset = object.visualAsset && typeof object.visualAsset === "object" ? object.visualAsset : {}; |
| 82 | + const candidates = [render, assignedVisual, visualAsset, object]; |
| 83 | + const assetKey = candidates.map((candidate) => normalizeText(candidate.assetKey || candidate.assetId || candidate.id)).find(Boolean) || ""; |
| 84 | + const previewPath = candidates.map((candidate) => normalizeText(candidate.previewPath || candidate.path || candidate.storedPath || candidate.imagePath)).find(Boolean) || ""; |
| 85 | + const type = candidates.map((candidate) => normalizeText(candidate.type || candidate.assetType || candidate.kind)).find(Boolean) || ""; |
| 86 | + const label = candidates.map((candidate) => normalizeText(candidate.label || candidate.name || candidate.assetName)).find(Boolean) || assetKey; |
| 87 | + |
| 88 | + if (!assetKey && !previewPath) { |
| 89 | + return null; |
| 90 | + } |
| 91 | + |
| 92 | + return Object.freeze({ |
| 93 | + assetKey, |
| 94 | + label: label || assetKey || "Assigned visual asset", |
| 95 | + previewPath, |
| 96 | + type: type || "Asset", |
| 97 | + }); |
| 98 | +} |
| 99 | + |
| 100 | +function eligibleObjectFromSource(object = {}) { |
| 101 | + const visual = visualMetadataForObject(object); |
| 102 | + if (!visual) { |
| 103 | + return null; |
| 104 | + } |
| 105 | + const key = objectKey(object); |
| 106 | + if (!key) { |
| 107 | + return null; |
| 108 | + } |
| 109 | + return Object.freeze({ |
| 110 | + bounds: normalizeBounds(object), |
| 111 | + key, |
| 112 | + name: normalizeText(object.name) || key, |
| 113 | + origin: normalizeOrigin(object), |
| 114 | + source: Object.freeze({ ...object }), |
| 115 | + visual, |
| 116 | + }); |
| 117 | +} |
| 118 | + |
| 119 | +function tableMessage(text) { |
| 120 | + const row = document.createElement("tr"); |
| 121 | + const cell = document.createElement("td"); |
| 122 | + cell.colSpan = 3; |
| 123 | + cell.textContent = text; |
| 124 | + row.append(cell); |
| 125 | + return row; |
| 126 | +} |
| 127 | + |
| 128 | +function tableCell(text) { |
39 | 129 | const cell = document.createElement("td"); |
40 | 130 | cell.textContent = text; |
41 | 131 | return cell; |
42 | 132 | } |
43 | 133 |
|
44 | | -function createRow(milestone) { |
| 134 | +function visualLabel(visual) { |
| 135 | + const type = normalizeText(visual.type); |
| 136 | + const label = normalizeText(visual.label); |
| 137 | + const key = normalizeText(visual.assetKey); |
| 138 | + return `${label || key || "Assigned visual"}${type ? ` / ${type}` : ""}`; |
| 139 | +} |
| 140 | + |
| 141 | +function sourceButton(object) { |
| 142 | + const button = document.createElement("button"); |
| 143 | + button.className = object.key === state.selectedKey ? "btn btn--compact primary" : "btn btn--compact"; |
| 144 | + button.type = "button"; |
| 145 | + button.dataset.hitboxesSelectObject = object.key; |
| 146 | + button.textContent = object.name; |
| 147 | + return button; |
| 148 | +} |
| 149 | + |
| 150 | +function objectRow(object) { |
45 | 151 | const row = document.createElement("tr"); |
46 | | - const heading = document.createElement("th"); |
47 | | - heading.scope = "row"; |
48 | | - heading.textContent = milestone.milestone; |
49 | | - row.append(heading, createCell(milestone.status), createCell(milestone.notes)); |
| 152 | + row.dataset.hitboxesObjectRow = object.key; |
| 153 | + const nameCell = document.createElement("th"); |
| 154 | + nameCell.scope = "row"; |
| 155 | + nameCell.append(sourceButton(object)); |
| 156 | + row.append(nameCell, tableCell(object.key), tableCell(visualLabel(object.visual))); |
50 | 157 | return row; |
51 | 158 | } |
52 | 159 |
|
53 | | -function renderFoundation() { |
54 | | - elements.table?.replaceChildren(...FOUNDATION_MILESTONES.map(createRow)); |
55 | | - setText(elements.contractCount, String(FOUNDATION_MILESTONES.length)); |
56 | | - setText(elements.scopeCount, "0"); |
57 | | - setText(elements.foundationStatus, "Foundation ready"); |
58 | | - setText(elements.owner, "Team Delta"); |
59 | | - setText(elements.persistence, "Local API contract pending"); |
60 | | - setText(elements.service, "hitboxes-service-placeholder"); |
61 | | - setText(elements.mode, "read-only foundation"); |
62 | | - setText(elements.toolState, "Foundation"); |
63 | | - setText(elements.log, "Hitboxes foundation loaded. Drawing and editing are intentionally deferred."); |
| 160 | +function previewImageUrl(visual) { |
| 161 | + const path = normalizeText(visual.previewPath); |
| 162 | + if (!path) { |
| 163 | + return ""; |
| 164 | + } |
| 165 | + if (/^(https?:|data:|blob:|\/)/i.test(path)) { |
| 166 | + return path; |
| 167 | + } |
| 168 | + return `/api/storage/project-assets/read?key=${encodeURIComponent(path)}`; |
| 169 | +} |
| 170 | + |
| 171 | +function boundsText(bounds) { |
| 172 | + return `${bounds.width}x${bounds.height} at ${bounds.x},${bounds.y}`; |
| 173 | +} |
| 174 | + |
| 175 | +function originText(origin) { |
| 176 | + return `${origin.x},${origin.y}`; |
| 177 | +} |
| 178 | + |
| 179 | +function selectedObject() { |
| 180 | + return state.eligibleObjects.find((object) => object.key === state.selectedKey) || null; |
| 181 | +} |
| 182 | + |
| 183 | +function renderList() { |
| 184 | + if (!elements.list) { |
| 185 | + return; |
| 186 | + } |
| 187 | + if (!state.eligibleObjects.length) { |
| 188 | + elements.list.replaceChildren(tableMessage(EMPTY_STATE)); |
| 189 | + return; |
| 190 | + } |
| 191 | + elements.list.replaceChildren(...state.eligibleObjects.map(objectRow)); |
| 192 | +} |
| 193 | + |
| 194 | +function renderSelectedObject() { |
| 195 | + const object = selectedObject(); |
| 196 | + setText(elements.objectCount, String(state.eligibleObjects.length)); |
| 197 | + |
| 198 | + if (!object) { |
| 199 | + setText(elements.selectedKey, "None"); |
| 200 | + setText(elements.visualType, "None"); |
| 201 | + setText(elements.previewTitle, "No Object selected"); |
| 202 | + setText(elements.previewSummary, state.eligibleObjects.length ? "Select an Object with assigned visual metadata to prepare hitbox editing." : EMPTY_STATE); |
| 203 | + setText(elements.previewFallback, state.eligibleObjects.length ? "Select an Object to preview assigned visual metadata." : EMPTY_STATE); |
| 204 | + setText(elements.boundingBox, "Bounding box: none"); |
| 205 | + setText(elements.originMarker, "Origin: none"); |
| 206 | + setText(elements.metaName, "None"); |
| 207 | + setText(elements.metaKey, "None"); |
| 208 | + setText(elements.metaVisual, "None"); |
| 209 | + setText(elements.metaBounds, "None"); |
| 210 | + setText(elements.metaOrigin, "None"); |
| 211 | + setText(elements.log, state.eligibleObjects.length ? "Select Object A." : EMPTY_STATE); |
| 212 | + if (elements.previewImage) { |
| 213 | + elements.previewImage.hidden = true; |
| 214 | + elements.previewImage.removeAttribute("src"); |
| 215 | + } |
| 216 | + return; |
| 217 | + } |
| 218 | + |
| 219 | + const imageUrl = previewImageUrl(object.visual); |
| 220 | + setText(elements.selectedKey, object.key); |
| 221 | + setText(elements.visualType, object.visual.type); |
| 222 | + setText(elements.previewTitle, object.name); |
| 223 | + setText(elements.previewSummary, `Object A uses ${visualLabel(object.visual)} from the Objects service contract.`); |
| 224 | + setText(elements.previewFallback, `Assigned visual metadata exists for ${object.name}; rendering is pending if no image appears.`); |
| 225 | + setText(elements.boundingBox, `Bounding box: ${boundsText(object.bounds)}`); |
| 226 | + setText(elements.originMarker, `Origin: ${originText(object.origin)}`); |
| 227 | + setText(elements.metaName, object.name); |
| 228 | + setText(elements.metaKey, object.key); |
| 229 | + setText(elements.metaVisual, visualLabel(object.visual)); |
| 230 | + setText(elements.metaBounds, boundsText(object.bounds)); |
| 231 | + setText(elements.metaOrigin, originText(object.origin)); |
| 232 | + setText(elements.log, `Selected Object A: ${object.name}.`); |
| 233 | + |
| 234 | + if (elements.previewImage) { |
| 235 | + if (imageUrl) { |
| 236 | + elements.previewImage.alt = `${object.name} assigned visual preview`; |
| 237 | + elements.previewImage.src = imageUrl; |
| 238 | + elements.previewImage.hidden = false; |
| 239 | + } else { |
| 240 | + elements.previewImage.hidden = true; |
| 241 | + elements.previewImage.removeAttribute("src"); |
| 242 | + } |
| 243 | + } |
| 244 | +} |
| 245 | + |
| 246 | +function render() { |
| 247 | + setText(elements.sourceStatus, state.eligibleObjects.length ? "Objects loaded from Local API." : EMPTY_STATE); |
| 248 | + renderList(); |
| 249 | + renderSelectedObject(); |
64 | 250 | } |
65 | 251 |
|
66 | | -renderFoundation(); |
| 252 | +function loadObjects() { |
| 253 | + const result = objectsRepository.listObjects(); |
| 254 | + if (!Array.isArray(result)) { |
| 255 | + const message = result?.message || result?.validation?.findings?.[0]?.action || "Objects service is unavailable."; |
| 256 | + state.eligibleObjects = []; |
| 257 | + state.selectedKey = ""; |
| 258 | + render(); |
| 259 | + setText(elements.sourceStatus, message); |
| 260 | + setText(elements.log, message); |
| 261 | + return; |
| 262 | + } |
| 263 | + |
| 264 | + state.eligibleObjects = result.map(eligibleObjectFromSource).filter(Boolean); |
| 265 | + const params = new URLSearchParams(window.location.search); |
| 266 | + const requestedKey = normalizeText(params.get("objectKey")); |
| 267 | + state.selectedKey = state.eligibleObjects.some((object) => object.key === requestedKey) |
| 268 | + ? requestedKey |
| 269 | + : state.eligibleObjects[0]?.key || ""; |
| 270 | + render(); |
| 271 | +} |
| 272 | + |
| 273 | +elements.list?.addEventListener("click", (event) => { |
| 274 | + const button = event.target instanceof HTMLElement ? event.target.closest("[data-hitboxes-select-object]") : null; |
| 275 | + if (!button) { |
| 276 | + return; |
| 277 | + } |
| 278 | + state.selectedKey = button.dataset.hitboxesSelectObject || ""; |
| 279 | + render(); |
| 280 | +}); |
| 281 | + |
| 282 | +elements.previewImage?.addEventListener("error", () => { |
| 283 | + const object = selectedObject(); |
| 284 | + if (!object || !elements.previewImage) { |
| 285 | + return; |
| 286 | + } |
| 287 | + elements.previewImage.hidden = true; |
| 288 | + elements.previewImage.removeAttribute("src"); |
| 289 | + setText(elements.previewFallback, `Assigned visual metadata exists for ${object.name}; rendering is pending for ${object.visual.previewPath || object.visual.assetKey}.`); |
| 290 | +}); |
| 291 | + |
| 292 | +loadObjects(); |
0 commit comments