|
| 1 | +const state = { |
| 2 | + cases: [], |
| 3 | + activeIndex: 0, |
| 4 | + query: "", |
| 5 | +}; |
| 6 | + |
| 7 | +const els = { |
| 8 | + caseCount: document.querySelector("#case-count"), |
| 9 | + caseList: document.querySelector("#case-list"), |
| 10 | + video: document.querySelector("#video"), |
| 11 | + title: document.querySelector("#case-title"), |
| 12 | + subtitle: document.querySelector("#case-subtitle"), |
| 13 | + caption: document.querySelector("#caption"), |
| 14 | + search: document.querySelector("#caption-search"), |
| 15 | + copy: document.querySelector("#copy-caption"), |
| 16 | +}; |
| 17 | + |
| 18 | +function escapeHtml(value) { |
| 19 | + return value |
| 20 | + .replaceAll("&", "&") |
| 21 | + .replaceAll("<", "<") |
| 22 | + .replaceAll(">", ">") |
| 23 | + .replaceAll('"', """) |
| 24 | + .replaceAll("'", "'"); |
| 25 | +} |
| 26 | + |
| 27 | +function appendHighlightedText(parent, text, query) { |
| 28 | + if (!query) { |
| 29 | + parent.append(document.createTextNode(text)); |
| 30 | + return; |
| 31 | + } |
| 32 | + |
| 33 | + const lowerText = text.toLowerCase(); |
| 34 | + const lowerQuery = query.toLowerCase(); |
| 35 | + let cursor = 0; |
| 36 | + let matchIndex = lowerText.indexOf(lowerQuery, cursor); |
| 37 | + |
| 38 | + while (matchIndex !== -1) { |
| 39 | + if (matchIndex > cursor) { |
| 40 | + parent.append(document.createTextNode(text.slice(cursor, matchIndex))); |
| 41 | + } |
| 42 | + |
| 43 | + const mark = document.createElement("mark"); |
| 44 | + mark.textContent = text.slice(matchIndex, matchIndex + query.length); |
| 45 | + parent.append(mark); |
| 46 | + |
| 47 | + cursor = matchIndex + query.length; |
| 48 | + matchIndex = lowerText.indexOf(lowerQuery, cursor); |
| 49 | + } |
| 50 | + |
| 51 | + if (cursor < text.length) { |
| 52 | + parent.append(document.createTextNode(text.slice(cursor))); |
| 53 | + } |
| 54 | +} |
| 55 | + |
| 56 | +function appendInline(parent, text) { |
| 57 | + const inlinePattern = /(\*\*.+?\*\*|\[\d{2}:\d{2}(?:\s*-\s*\d{2}:\d{2})?\])/g; |
| 58 | + let cursor = 0; |
| 59 | + let match; |
| 60 | + |
| 61 | + while ((match = inlinePattern.exec(text)) !== null) { |
| 62 | + if (match.index > cursor) { |
| 63 | + appendHighlightedText(parent, text.slice(cursor, match.index), state.query); |
| 64 | + } |
| 65 | + |
| 66 | + const token = match[0]; |
| 67 | + if (token.startsWith("**")) { |
| 68 | + const strong = document.createElement("strong"); |
| 69 | + appendHighlightedText(strong, token.slice(2, -2), state.query); |
| 70 | + parent.append(strong); |
| 71 | + } else { |
| 72 | + const timestamp = document.createElement("span"); |
| 73 | + timestamp.className = "timestamp"; |
| 74 | + timestamp.textContent = token; |
| 75 | + parent.append(timestamp); |
| 76 | + } |
| 77 | + |
| 78 | + cursor = inlinePattern.lastIndex; |
| 79 | + } |
| 80 | + |
| 81 | + if (cursor < text.length) { |
| 82 | + appendHighlightedText(parent, text.slice(cursor), state.query); |
| 83 | + } |
| 84 | +} |
| 85 | + |
| 86 | +function splitCaption(caption) { |
| 87 | + const normalized = caption.replace(/\r\n/g, "\n").trim(); |
| 88 | + const sections = []; |
| 89 | + const headingPattern = /\*\*(Setting|Key Visuals|Sequence of Events):\*\*/g; |
| 90 | + const matches = [...normalized.matchAll(headingPattern)]; |
| 91 | + |
| 92 | + if (!matches.length) { |
| 93 | + return [{ heading: "Caption", body: normalized }]; |
| 94 | + } |
| 95 | + |
| 96 | + const intro = normalized.slice(0, matches[0].index).trim(); |
| 97 | + if (intro) sections.push({ heading: "Overview", body: intro }); |
| 98 | + |
| 99 | + matches.forEach((match, index) => { |
| 100 | + const start = match.index + match[0].length; |
| 101 | + const end = matches[index + 1]?.index ?? normalized.length; |
| 102 | + sections.push({ |
| 103 | + heading: match[1], |
| 104 | + body: normalized.slice(start, end).trim(), |
| 105 | + }); |
| 106 | + }); |
| 107 | + |
| 108 | + return sections; |
| 109 | +} |
| 110 | + |
| 111 | +function splitKeyVisuals(body) { |
| 112 | + const matches = [...body.matchAll(/(?:^|\n)\s*-\s+\*\*(.+?):\*\*/g)]; |
| 113 | + if (!matches.length) return body.split(/\n\s*\n/); |
| 114 | + |
| 115 | + const intro = body.slice(0, matches[0].index).trim(); |
| 116 | + const blocks = intro ? [intro] : []; |
| 117 | + |
| 118 | + matches.forEach((match, index) => { |
| 119 | + const start = match.index; |
| 120 | + const end = matches[index + 1]?.index ?? body.length; |
| 121 | + blocks.push(body.slice(start, end).replace(/^\s*-\s+/, "").trim()); |
| 122 | + }); |
| 123 | + |
| 124 | + return blocks; |
| 125 | +} |
| 126 | + |
| 127 | +function splitSequence(body) { |
| 128 | + const compact = body.replace(/\n+/g, " ").replace(/\s+/g, " ").trim(); |
| 129 | + const matches = [...compact.matchAll(/\[\d{2}:\d{2}(?:\s*-\s*\d{2}:\d{2})?\]/g)]; |
| 130 | + if (!matches.length) return body.split(/\n\s*\n/); |
| 131 | + |
| 132 | + const blocks = []; |
| 133 | + const intro = compact.slice(0, matches[0].index).trim(); |
| 134 | + if (intro) blocks.push(intro); |
| 135 | + |
| 136 | + matches.forEach((match, index) => { |
| 137 | + const start = match.index; |
| 138 | + const end = matches[index + 1]?.index ?? compact.length; |
| 139 | + blocks.push(compact.slice(start, end).trim()); |
| 140 | + }); |
| 141 | + |
| 142 | + return blocks; |
| 143 | +} |
| 144 | + |
| 145 | +function splitBlocks(section) { |
| 146 | + if (section.heading === "Key Visuals") return splitKeyVisuals(section.body); |
| 147 | + if (section.heading === "Sequence of Events") return splitSequence(section.body); |
| 148 | + return section.body.split(/\n\s*\n/); |
| 149 | +} |
| 150 | + |
| 151 | +function renderCaption(caption) { |
| 152 | + const sections = splitCaption(caption); |
| 153 | + els.caption.replaceChildren(); |
| 154 | + |
| 155 | + sections.forEach((section) => { |
| 156 | + const sectionEl = document.createElement("section"); |
| 157 | + sectionEl.className = "caption-section"; |
| 158 | + |
| 159 | + const heading = document.createElement("h3"); |
| 160 | + heading.className = "caption-heading"; |
| 161 | + heading.textContent = section.heading; |
| 162 | + sectionEl.append(heading); |
| 163 | + |
| 164 | + const blocks = splitBlocks(section) |
| 165 | + .map((block) => block.trim()) |
| 166 | + .filter(Boolean); |
| 167 | + |
| 168 | + if (!blocks.length) { |
| 169 | + const empty = document.createElement("p"); |
| 170 | + empty.className = "empty"; |
| 171 | + empty.textContent = "No caption text."; |
| 172 | + sectionEl.append(empty); |
| 173 | + } |
| 174 | + |
| 175 | + blocks.forEach((block) => { |
| 176 | + const paragraph = document.createElement("p"); |
| 177 | + appendInline(paragraph, block); |
| 178 | + sectionEl.append(paragraph); |
| 179 | + }); |
| 180 | + |
| 181 | + els.caption.append(sectionEl); |
| 182 | + }); |
| 183 | +} |
| 184 | + |
| 185 | +function renderCaseList() { |
| 186 | + els.caseCount.textContent = String(state.cases.length); |
| 187 | + els.caseList.innerHTML = state.cases |
| 188 | + .map((item, index) => { |
| 189 | + const selected = index === state.activeIndex ? "true" : "false"; |
| 190 | + return ` |
| 191 | + <button class="case-button" type="button" aria-selected="${selected}" data-index="${index}"> |
| 192 | + <strong>${escapeHtml(item.title || `Case ${index + 1}`)}</strong> |
| 193 | + <span>${escapeHtml(item.subtitle || "")}</span> |
| 194 | + </button> |
| 195 | + `; |
| 196 | + }) |
| 197 | + .join(""); |
| 198 | +} |
| 199 | + |
| 200 | +function selectCase(index) { |
| 201 | + state.activeIndex = index; |
| 202 | + const item = state.cases[index]; |
| 203 | + els.video.src = `${item.video}?v=${encodeURIComponent(item.id || index)}`; |
| 204 | + els.video.load(); |
| 205 | + els.video.poster = ""; |
| 206 | + els.title.textContent = item.title || `Case ${index + 1}`; |
| 207 | + els.subtitle.textContent = item.subtitle || ""; |
| 208 | + renderCaseList(); |
| 209 | + renderCaption(item.caption || ""); |
| 210 | +} |
| 211 | + |
| 212 | +async function loadCases() { |
| 213 | + const response = await fetch("cases.json"); |
| 214 | + if (!response.ok) { |
| 215 | + throw new Error(`Failed to load cases.json: ${response.status}`); |
| 216 | + } |
| 217 | + state.cases = await response.json(); |
| 218 | + if (!Array.isArray(state.cases) || !state.cases.length) { |
| 219 | + throw new Error("cases.json does not contain any demo cases."); |
| 220 | + } |
| 221 | + selectCase(0); |
| 222 | +} |
| 223 | + |
| 224 | +els.caseList.addEventListener("click", (event) => { |
| 225 | + const button = event.target.closest(".case-button"); |
| 226 | + if (!button) return; |
| 227 | + selectCase(Number(button.dataset.index)); |
| 228 | +}); |
| 229 | + |
| 230 | +els.search.addEventListener("input", (event) => { |
| 231 | + state.query = event.target.value.trim(); |
| 232 | + const item = state.cases[state.activeIndex]; |
| 233 | + if (item) renderCaption(item.caption || ""); |
| 234 | +}); |
| 235 | + |
| 236 | +els.copy.addEventListener("click", async () => { |
| 237 | + const item = state.cases[state.activeIndex]; |
| 238 | + if (!item) return; |
| 239 | + |
| 240 | + await navigator.clipboard.writeText(item.caption || ""); |
| 241 | + els.copy.textContent = "Copied"; |
| 242 | + els.copy.classList.add("is-copied"); |
| 243 | + |
| 244 | + window.setTimeout(() => { |
| 245 | + els.copy.textContent = "Copy caption"; |
| 246 | + els.copy.classList.remove("is-copied"); |
| 247 | + }, 1400); |
| 248 | +}); |
| 249 | + |
| 250 | +loadCases().catch((error) => { |
| 251 | + els.title.textContent = "Demo failed to load"; |
| 252 | + els.subtitle.textContent = error.message; |
| 253 | + els.caption.innerHTML = `<p class="empty">${escapeHtml(error.message)}</p>`; |
| 254 | +}); |
0 commit comments