Skip to content

Commit 7500582

Browse files
committed
demo
1 parent f378951 commit 7500582

7 files changed

Lines changed: 721 additions & 0 deletions

File tree

docs/demo/app.js

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
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("<", "&lt;")
22+
.replaceAll(">", "&gt;")
23+
.replaceAll('"', "&quot;")
24+
.replaceAll("'", "&#039;");
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

Comments
 (0)