feat: unify card runtime and event-driven web ui
This commit is contained in:
parent
0edf8c3fef
commit
4dfb7ca3cc
105 changed files with 17382 additions and 8505 deletions
708
examples/cards/templates/todo-item-live/card.js
Normal file
708
examples/cards/templates/todo-item-live/card.js
Normal file
|
|
@ -0,0 +1,708 @@
|
|||
const TASK_LANES = ["backlog", "committed", "in-progress", "blocked", "done", "canceled"];
|
||||
|
||||
const TASK_ACTION_LABELS = {
|
||||
backlog: "Backlog",
|
||||
committed: "Commit",
|
||||
"in-progress": "Start",
|
||||
blocked: "Block",
|
||||
done: "Done",
|
||||
canceled: "Cancel",
|
||||
};
|
||||
|
||||
const TASK_LANE_LABELS = {
|
||||
backlog: "Backlog",
|
||||
committed: "Committed",
|
||||
"in-progress": "In Progress",
|
||||
blocked: "Blocked",
|
||||
done: "Done",
|
||||
canceled: "Canceled",
|
||||
};
|
||||
|
||||
const TASK_LANE_THEMES = {
|
||||
backlog: {
|
||||
accent: "#5f7884",
|
||||
accentSoft: "rgba(95, 120, 132, 0.13)",
|
||||
muted: "#6b7e87",
|
||||
buttonInk: "#294a57",
|
||||
},
|
||||
committed: {
|
||||
accent: "#8a6946",
|
||||
accentSoft: "rgba(138, 105, 70, 0.14)",
|
||||
muted: "#7f664a",
|
||||
buttonInk: "#5a3b19",
|
||||
},
|
||||
"in-progress": {
|
||||
accent: "#4f7862",
|
||||
accentSoft: "rgba(79, 120, 98, 0.13)",
|
||||
muted: "#5e7768",
|
||||
buttonInk: "#214437",
|
||||
},
|
||||
blocked: {
|
||||
accent: "#a55f4b",
|
||||
accentSoft: "rgba(165, 95, 75, 0.13)",
|
||||
muted: "#906659",
|
||||
buttonInk: "#6c2f21",
|
||||
},
|
||||
done: {
|
||||
accent: "#6d7f58",
|
||||
accentSoft: "rgba(109, 127, 88, 0.12)",
|
||||
muted: "#6b755d",
|
||||
buttonInk: "#304121",
|
||||
},
|
||||
canceled: {
|
||||
accent: "#7b716a",
|
||||
accentSoft: "rgba(123, 113, 106, 0.12)",
|
||||
muted: "#7b716a",
|
||||
buttonInk: "#433932",
|
||||
},
|
||||
};
|
||||
|
||||
function isTaskLane(value) {
|
||||
return typeof value === "string" && TASK_LANES.includes(value);
|
||||
}
|
||||
|
||||
function normalizeTag(raw) {
|
||||
const trimmed = String(raw || "")
|
||||
.trim()
|
||||
.replace(/^#+/, "")
|
||||
.replace(/\s+/g, "-");
|
||||
return trimmed ? `#${trimmed}` : "";
|
||||
}
|
||||
|
||||
function normalizeTags(raw) {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const seen = new Set();
|
||||
const tags = [];
|
||||
for (const value of raw) {
|
||||
const tag = normalizeTag(value);
|
||||
const key = tag.toLowerCase();
|
||||
if (!tag || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
tags.push(tag);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
function normalizeMetadata(raw) {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||
const entries = Object.entries(raw).filter(([, value]) => value !== undefined);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
function normalizeTask(raw, fallbackTitle) {
|
||||
const record = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
||||
const lane = isTaskLane(record.lane) ? record.lane : "backlog";
|
||||
return {
|
||||
taskPath: typeof record.task_path === "string" ? record.task_path.trim() : "",
|
||||
taskKey: typeof record.task_key === "string" ? record.task_key.trim() : "",
|
||||
title:
|
||||
typeof record.title === "string" && record.title.trim()
|
||||
? record.title.trim()
|
||||
: fallbackTitle || "(Untitled task)",
|
||||
lane,
|
||||
created: typeof record.created === "string" ? record.created.trim() : "",
|
||||
updated: typeof record.updated === "string" ? record.updated.trim() : "",
|
||||
due: typeof record.due === "string" ? record.due.trim() : "",
|
||||
tags: normalizeTags(record.tags),
|
||||
body: typeof record.body === "string" ? record.body : "",
|
||||
metadata: normalizeMetadata(record.metadata),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTaskFromPayload(raw, fallback) {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return fallback;
|
||||
return {
|
||||
taskPath: typeof raw.path === "string" ? raw.path.trim() : fallback.taskPath,
|
||||
taskKey: fallback.taskKey,
|
||||
title:
|
||||
typeof raw.title === "string" && raw.title.trim()
|
||||
? raw.title.trim()
|
||||
: fallback.title,
|
||||
lane: isTaskLane(raw.lane) ? raw.lane : fallback.lane,
|
||||
created: typeof raw.created === "string" ? raw.created.trim() : fallback.created,
|
||||
updated: typeof raw.updated === "string" ? raw.updated.trim() : fallback.updated,
|
||||
due: typeof raw.due === "string" ? raw.due.trim() : fallback.due,
|
||||
tags: normalizeTags(raw.tags),
|
||||
body: typeof raw.body === "string" ? raw.body : fallback.body,
|
||||
metadata: normalizeMetadata(raw.metadata),
|
||||
};
|
||||
}
|
||||
|
||||
function parseToolPayload(result) {
|
||||
if (result && typeof result === "object" && result.parsed && typeof result.parsed === "object") {
|
||||
return result.parsed;
|
||||
}
|
||||
const raw = typeof result?.content === "string" ? result.content : "";
|
||||
if (!raw.trim()) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function dueScore(hoursUntilDue) {
|
||||
if (hoursUntilDue <= 0) return 100;
|
||||
if (hoursUntilDue <= 6) return 96;
|
||||
if (hoursUntilDue <= 24) return 92;
|
||||
if (hoursUntilDue <= 72) return 82;
|
||||
if (hoursUntilDue <= 168) return 72;
|
||||
return 62;
|
||||
}
|
||||
|
||||
function ageScore(ageDays) {
|
||||
if (ageDays >= 30) return 80;
|
||||
if (ageDays >= 21) return 76;
|
||||
if (ageDays >= 14) return 72;
|
||||
if (ageDays >= 7) return 68;
|
||||
if (ageDays >= 3) return 62;
|
||||
if (ageDays >= 1) return 58;
|
||||
return 54;
|
||||
}
|
||||
|
||||
function computeTaskScore(task) {
|
||||
const now = Date.now();
|
||||
const rawDue = task.due ? (task.due.includes("T") ? task.due : `${task.due}T12:00:00`) : "";
|
||||
const dueMs = rawDue ? new Date(rawDue).getTime() : Number.NaN;
|
||||
let score = 54;
|
||||
if (Number.isFinite(dueMs)) {
|
||||
score = dueScore((dueMs - now) / (60 * 60 * 1000));
|
||||
} else {
|
||||
const createdMs = task.created ? new Date(task.created).getTime() : Number.NaN;
|
||||
if (Number.isFinite(createdMs)) {
|
||||
score = ageScore(Math.max(0, (now - createdMs) / (24 * 60 * 60 * 1000)));
|
||||
}
|
||||
}
|
||||
if (task.lane === "committed") return Math.min(100, score + 1);
|
||||
if (task.lane === "blocked") return Math.min(100, score + 4);
|
||||
if (task.lane === "in-progress") return Math.min(100, score + 2);
|
||||
return score;
|
||||
}
|
||||
|
||||
function summarizeTaskBody(task) {
|
||||
const trimmed = String(task.body || "").trim();
|
||||
if (!trimmed || /^##\s+Imported\b/i.test(trimmed)) return "";
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function renderTaskBodyMarkdown(host, body) {
|
||||
if (!body) return "";
|
||||
return body
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return '<span class="task-card-ui__md-break" aria-hidden="true"></span>';
|
||||
|
||||
let className = "task-card-ui__md-line";
|
||||
let content = trimmed;
|
||||
let prefix = "";
|
||||
|
||||
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (headingMatch) {
|
||||
className += " task-card-ui__md-line--heading";
|
||||
content = headingMatch[2];
|
||||
} else if (/^[-*]\s+/.test(trimmed)) {
|
||||
className += " task-card-ui__md-line--bullet";
|
||||
content = trimmed.replace(/^[-*]\s+/, "");
|
||||
prefix = "\u2022 ";
|
||||
} else if (/^\d+\.\s+/.test(trimmed)) {
|
||||
className += " task-card-ui__md-line--bullet";
|
||||
content = trimmed.replace(/^\d+\.\s+/, "");
|
||||
prefix = "\u2022 ";
|
||||
} else if (/^>\s+/.test(trimmed)) {
|
||||
className += " task-card-ui__md-line--quote";
|
||||
content = trimmed.replace(/^>\s+/, "");
|
||||
prefix = "> ";
|
||||
}
|
||||
|
||||
const html = host.renderMarkdown(content, { inline: true });
|
||||
return `<span class="${className}">${
|
||||
prefix ? `<span class="task-card-ui__md-prefix">${prefix}</span>` : ""
|
||||
}${html}</span>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function formatTaskDue(task) {
|
||||
if (!task.due) return "";
|
||||
const raw = task.due.includes("T") ? task.due : `${task.due}T00:00:00`;
|
||||
const parsed = new Date(raw);
|
||||
if (Number.isNaN(parsed.getTime())) return task.due;
|
||||
if (task.due.includes("T")) {
|
||||
const label = parsed.toLocaleString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return label.replace(/\s([AP]M)$/i, "$1");
|
||||
}
|
||||
return parsed.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function taskMoveOptions(lane) {
|
||||
return TASK_LANES.filter((targetLane) => targetLane !== lane).map((targetLane) => ({
|
||||
lane: targetLane,
|
||||
label: TASK_ACTION_LABELS[targetLane],
|
||||
}));
|
||||
}
|
||||
|
||||
function taskLiveContent(task, errorText) {
|
||||
return {
|
||||
kind: "file_task",
|
||||
exists: true,
|
||||
task_path: task.taskPath || null,
|
||||
task_key: task.taskKey || null,
|
||||
title: task.title || null,
|
||||
lane: task.lane,
|
||||
created: task.created || null,
|
||||
updated: task.updated || null,
|
||||
due: task.due || null,
|
||||
tags: task.tags,
|
||||
metadata: task.metadata,
|
||||
score: computeTaskScore(task),
|
||||
status: task.lane,
|
||||
error: errorText || null,
|
||||
};
|
||||
}
|
||||
|
||||
function autosizeEditor(editor) {
|
||||
editor.style.height = "0px";
|
||||
editor.style.height = `${Math.max(editor.scrollHeight, 20)}px`;
|
||||
}
|
||||
|
||||
export function mount({ root, item, state, host }) {
|
||||
const cardEl = root.querySelector(".task-card-ui");
|
||||
const laneToggleEl = root.querySelector(".task-card-ui__lane-button");
|
||||
const laneWrapEl = root.querySelector(".task-card-ui__lane-wrap");
|
||||
const laneMenuEl = root.querySelector(".task-card-ui__lane-menu");
|
||||
const statusEl = root.querySelector(".task-card-ui__status");
|
||||
const titleEl = root.querySelector(".task-card-ui__title-slot");
|
||||
const tagsEl = root.querySelector(".task-card-ui__tags");
|
||||
const bodyEl = root.querySelector(".task-card-ui__body-slot");
|
||||
const metaEl = root.querySelector(".task-card-ui__meta");
|
||||
const dueEl = root.querySelector(".task-card-ui__chip");
|
||||
|
||||
if (
|
||||
!(cardEl instanceof HTMLElement) ||
|
||||
!(laneToggleEl instanceof HTMLButtonElement) ||
|
||||
!(laneWrapEl instanceof HTMLElement) ||
|
||||
!(laneMenuEl instanceof HTMLElement) ||
|
||||
!(statusEl instanceof HTMLElement) ||
|
||||
!(titleEl instanceof HTMLElement) ||
|
||||
!(tagsEl instanceof HTMLElement) ||
|
||||
!(bodyEl instanceof HTMLElement) ||
|
||||
!(metaEl instanceof HTMLElement) ||
|
||||
!(dueEl instanceof HTMLElement)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let task = normalizeTask(state, item.title);
|
||||
let busy = false;
|
||||
let errorText = "";
|
||||
let statusLabel = "";
|
||||
let statusKind = "neutral";
|
||||
let laneMenuOpen = false;
|
||||
let editingField = null;
|
||||
let holdTimer = null;
|
||||
|
||||
const clearHoldTimer = () => {
|
||||
if (holdTimer !== null) {
|
||||
window.clearTimeout(holdTimer);
|
||||
holdTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const setStatus = (label, kind) => {
|
||||
statusLabel = label || "";
|
||||
statusKind = kind || "neutral";
|
||||
statusEl.textContent = statusLabel;
|
||||
statusEl.className = `task-card-ui__status${statusKind === "error" ? " is-error" : ""}`;
|
||||
};
|
||||
|
||||
const publishLiveContent = () => {
|
||||
host.setLiveContent(taskLiveContent(task, errorText));
|
||||
};
|
||||
|
||||
const closeLaneMenu = () => {
|
||||
laneMenuOpen = false;
|
||||
laneToggleEl.setAttribute("aria-expanded", "false");
|
||||
laneMenuEl.style.display = "none";
|
||||
};
|
||||
|
||||
const openLaneMenu = () => {
|
||||
if (busy || !task.taskPath) return;
|
||||
laneMenuOpen = true;
|
||||
laneToggleEl.setAttribute("aria-expanded", "true");
|
||||
laneMenuEl.style.display = "flex";
|
||||
};
|
||||
|
||||
const refreshFeed = () => {
|
||||
closeLaneMenu();
|
||||
host.requestFeedRefresh();
|
||||
};
|
||||
|
||||
const setBusy = (nextBusy) => {
|
||||
busy = !!nextBusy;
|
||||
laneToggleEl.disabled = busy || !task.taskPath;
|
||||
titleEl.style.pointerEvents = busy ? "none" : "";
|
||||
bodyEl.style.pointerEvents = busy ? "none" : "";
|
||||
tagsEl
|
||||
.querySelectorAll("button")
|
||||
.forEach((button) => (button.disabled = busy || (!task.taskPath && button.dataset.role !== "noop")));
|
||||
laneMenuEl.querySelectorAll("button").forEach((button) => {
|
||||
button.disabled = busy;
|
||||
});
|
||||
};
|
||||
|
||||
const runBusyAction = async (action) => {
|
||||
setBusy(true);
|
||||
errorText = "";
|
||||
setStatus("Saving", "neutral");
|
||||
try {
|
||||
await action();
|
||||
setStatus("", "neutral");
|
||||
} catch (error) {
|
||||
console.error("Task card action failed", error);
|
||||
errorText = error instanceof Error ? error.message : String(error);
|
||||
setStatus("Unavailable", "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
render();
|
||||
}
|
||||
};
|
||||
|
||||
const callTaskBoard = async (argumentsValue) => {
|
||||
const result = await host.callTool("task_board", argumentsValue);
|
||||
const payload = parseToolPayload(result);
|
||||
if (payload && typeof payload.error === "string" && payload.error.trim()) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
|
||||
const moveTask = async (lane) =>
|
||||
runBusyAction(async () => {
|
||||
const payload = await callTaskBoard({ action: "move", task: task.taskPath, lane });
|
||||
const nextTask = normalizeTaskFromPayload(payload?.task, {
|
||||
...task,
|
||||
lane,
|
||||
taskPath: typeof payload?.task_path === "string" ? payload.task_path.trim() : task.taskPath,
|
||||
updated: new Date().toISOString(),
|
||||
});
|
||||
task = nextTask;
|
||||
refreshFeed();
|
||||
});
|
||||
|
||||
const editField = async (field, rawValue) => {
|
||||
const nextValue = rawValue.trim();
|
||||
const currentValue = field === "title" ? task.title : task.body;
|
||||
if (field === "title" && !nextValue) return false;
|
||||
if (nextValue === currentValue) {
|
||||
editingField = null;
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
await runBusyAction(async () => {
|
||||
const payload = await callTaskBoard({
|
||||
action: "edit",
|
||||
task: task.taskPath,
|
||||
...(field === "title" ? { title: nextValue } : { description: nextValue }),
|
||||
});
|
||||
task = normalizeTaskFromPayload(payload?.task, {
|
||||
...task,
|
||||
...(field === "title" ? { title: nextValue } : { body: nextValue }),
|
||||
});
|
||||
editingField = null;
|
||||
refreshFeed();
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const addTag = async () => {
|
||||
const raw = window.prompt("Add tag to task", "");
|
||||
const tag = raw == null ? "" : normalizeTag(raw);
|
||||
if (!tag) return;
|
||||
await runBusyAction(async () => {
|
||||
const payload = await callTaskBoard({
|
||||
action: "add_tag",
|
||||
task: task.taskPath,
|
||||
tags: [tag],
|
||||
});
|
||||
task = normalizeTaskFromPayload(payload?.task, {
|
||||
...task,
|
||||
tags: Array.from(new Set([...task.tags, tag])),
|
||||
});
|
||||
refreshFeed();
|
||||
});
|
||||
};
|
||||
|
||||
const removeTag = async (tag) =>
|
||||
runBusyAction(async () => {
|
||||
const payload = await callTaskBoard({
|
||||
action: "remove_tag",
|
||||
task: task.taskPath,
|
||||
tags: [tag],
|
||||
});
|
||||
task = normalizeTaskFromPayload(payload?.task, {
|
||||
...task,
|
||||
tags: task.tags.filter((value) => value !== tag),
|
||||
});
|
||||
refreshFeed();
|
||||
});
|
||||
|
||||
const beginTitleEdit = () => {
|
||||
if (!task.taskPath || busy || editingField) return;
|
||||
closeLaneMenu();
|
||||
editingField = "title";
|
||||
render();
|
||||
};
|
||||
|
||||
const beginBodyEdit = () => {
|
||||
if (!task.taskPath || busy || editingField) return;
|
||||
closeLaneMenu();
|
||||
editingField = "body";
|
||||
render();
|
||||
};
|
||||
|
||||
const renderInlineEditor = (targetEl, field, value, placeholder) => {
|
||||
targetEl.innerHTML = "";
|
||||
const editor = document.createElement("textarea");
|
||||
editor.className = `${field === "title" ? "task-card-ui__title" : "task-card-ui__body"} task-card-ui__editor`;
|
||||
editor.rows = field === "title" ? 1 : Math.max(1, value.split("\n").length);
|
||||
editor.value = value;
|
||||
if (placeholder) editor.placeholder = placeholder;
|
||||
editor.disabled = busy;
|
||||
targetEl.appendChild(editor);
|
||||
autosizeEditor(editor);
|
||||
|
||||
const cancel = () => {
|
||||
editingField = null;
|
||||
render();
|
||||
};
|
||||
|
||||
editor.addEventListener("input", () => {
|
||||
autosizeEditor(editor);
|
||||
});
|
||||
|
||||
editor.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
if (field === "title" && event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
editor.blur();
|
||||
return;
|
||||
}
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
editor.blur();
|
||||
}
|
||||
});
|
||||
|
||||
editor.addEventListener("blur", () => {
|
||||
if (editingField !== field) return;
|
||||
void editField(field, editor.value);
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
editor.focus();
|
||||
const end = editor.value.length;
|
||||
editor.setSelectionRange(end, end);
|
||||
});
|
||||
};
|
||||
|
||||
const renderTags = () => {
|
||||
tagsEl.innerHTML = "";
|
||||
task.tags.forEach((tag) => {
|
||||
const button = document.createElement("button");
|
||||
button.className = "task-card-ui__tag";
|
||||
button.type = "button";
|
||||
button.textContent = tag;
|
||||
button.title = `Hold to remove ${tag}`;
|
||||
button.disabled = busy;
|
||||
button.addEventListener("pointerdown", (event) => {
|
||||
if (event.pointerType === "mouse" && event.button !== 0) return;
|
||||
clearHoldTimer();
|
||||
button.classList.add("is-holding");
|
||||
holdTimer = window.setTimeout(() => {
|
||||
holdTimer = null;
|
||||
button.classList.remove("is-holding");
|
||||
if (window.confirm(`Remove ${tag} from this task?`)) {
|
||||
void removeTag(tag);
|
||||
}
|
||||
}, 650);
|
||||
});
|
||||
["pointerup", "pointerleave", "pointercancel"].forEach((eventName) => {
|
||||
button.addEventListener(eventName, () => {
|
||||
clearHoldTimer();
|
||||
button.classList.remove("is-holding");
|
||||
});
|
||||
});
|
||||
button.addEventListener("contextmenu", (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
tagsEl.appendChild(button);
|
||||
});
|
||||
|
||||
const addButton = document.createElement("button");
|
||||
addButton.className = "task-card-ui__tag task-card-ui__tag--action";
|
||||
addButton.type = "button";
|
||||
addButton.textContent = "+";
|
||||
addButton.title = "Add tag";
|
||||
addButton.disabled = busy || !task.taskPath;
|
||||
addButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void addTag();
|
||||
});
|
||||
tagsEl.appendChild(addButton);
|
||||
};
|
||||
|
||||
const renderLaneMenu = () => {
|
||||
laneMenuEl.innerHTML = "";
|
||||
if (!task.taskPath) {
|
||||
closeLaneMenu();
|
||||
return;
|
||||
}
|
||||
taskMoveOptions(task.lane).forEach((option) => {
|
||||
const button = document.createElement("button");
|
||||
button.className = "task-card-ui__lane-menu-item";
|
||||
button.type = "button";
|
||||
button.textContent = option.label;
|
||||
button.disabled = busy;
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void moveTask(option.lane);
|
||||
});
|
||||
laneMenuEl.appendChild(button);
|
||||
});
|
||||
laneMenuEl.style.display = laneMenuOpen ? "flex" : "none";
|
||||
};
|
||||
|
||||
const applyTheme = () => {
|
||||
const theme = TASK_LANE_THEMES[task.lane] || TASK_LANE_THEMES.backlog;
|
||||
cardEl.style.setProperty("--task-accent", theme.accent);
|
||||
cardEl.style.setProperty("--task-accent-soft", theme.accentSoft);
|
||||
cardEl.style.setProperty("--task-muted", theme.muted);
|
||||
cardEl.style.setProperty("--task-button-ink", theme.buttonInk);
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
applyTheme();
|
||||
laneToggleEl.textContent = "";
|
||||
const laneLabelEl = document.createElement("span");
|
||||
laneLabelEl.className = "task-card-ui__lane";
|
||||
laneLabelEl.textContent = TASK_LANE_LABELS[task.lane] || "Task";
|
||||
const caretEl = document.createElement("span");
|
||||
caretEl.className = `task-card-ui__lane-caret${laneMenuOpen ? " open" : ""}`;
|
||||
caretEl.textContent = "▾";
|
||||
laneToggleEl.append(laneLabelEl, caretEl);
|
||||
laneToggleEl.disabled = busy || !task.taskPath;
|
||||
laneToggleEl.setAttribute("aria-expanded", laneMenuOpen ? "true" : "false");
|
||||
|
||||
setStatus(statusLabel, statusKind);
|
||||
|
||||
if (editingField === "title") {
|
||||
renderInlineEditor(titleEl, "title", task.title, "");
|
||||
} else {
|
||||
titleEl.innerHTML = "";
|
||||
const button = document.createElement("button");
|
||||
button.className = "task-card-ui__title task-card-ui__text-button";
|
||||
button.type = "button";
|
||||
button.disabled = busy || !task.taskPath;
|
||||
button.textContent = task.title || "(Untitled task)";
|
||||
button.addEventListener("click", beginTitleEdit);
|
||||
titleEl.appendChild(button);
|
||||
}
|
||||
|
||||
const bodySummary = summarizeTaskBody(task);
|
||||
if (editingField === "body") {
|
||||
renderInlineEditor(bodyEl, "body", task.body, "Add description");
|
||||
} else {
|
||||
bodyEl.innerHTML = "";
|
||||
const button = document.createElement("button");
|
||||
button.className = `task-card-ui__body task-card-ui__text-button task-card-ui__body-markdown${
|
||||
bodySummary ? "" : " is-placeholder"
|
||||
}`;
|
||||
button.type = "button";
|
||||
button.disabled = busy || !task.taskPath;
|
||||
const inner = document.createElement("span");
|
||||
inner.className = "task-card-ui__body-markdown-inner";
|
||||
inner.innerHTML = bodySummary ? renderTaskBodyMarkdown(host, bodySummary) : "Add description";
|
||||
button.appendChild(inner);
|
||||
button.addEventListener("click", beginBodyEdit);
|
||||
bodyEl.appendChild(button);
|
||||
}
|
||||
|
||||
renderTags();
|
||||
renderLaneMenu();
|
||||
|
||||
const dueText = formatTaskDue(task);
|
||||
dueEl.textContent = dueText;
|
||||
metaEl.style.display = dueText ? "flex" : "none";
|
||||
|
||||
publishLiveContent();
|
||||
setBusy(busy);
|
||||
};
|
||||
|
||||
const handleLaneToggle = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (laneMenuOpen) closeLaneMenu();
|
||||
else openLaneMenu();
|
||||
render();
|
||||
};
|
||||
|
||||
const handleDocumentPointerDown = (event) => {
|
||||
if (!laneMenuOpen) return;
|
||||
if (!(event.target instanceof Node)) return;
|
||||
if (laneWrapEl.contains(event.target)) return;
|
||||
closeLaneMenu();
|
||||
render();
|
||||
};
|
||||
|
||||
const handleEscape = (event) => {
|
||||
if (event.key !== "Escape" || !laneMenuOpen) return;
|
||||
closeLaneMenu();
|
||||
render();
|
||||
};
|
||||
|
||||
laneToggleEl.addEventListener("click", handleLaneToggle);
|
||||
document.addEventListener("pointerdown", handleDocumentPointerDown);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
|
||||
render();
|
||||
|
||||
return {
|
||||
update({ item: nextItem, state: nextState }) {
|
||||
task = normalizeTask(nextState, nextItem.title);
|
||||
errorText = "";
|
||||
statusLabel = "";
|
||||
statusKind = "neutral";
|
||||
laneMenuOpen = false;
|
||||
editingField = null;
|
||||
render();
|
||||
},
|
||||
destroy() {
|
||||
clearHoldTimer();
|
||||
document.removeEventListener("pointerdown", handleDocumentPointerDown);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
laneToggleEl.removeEventListener("click", handleLaneToggle);
|
||||
host.setRefreshHandler(null);
|
||||
host.setLiveContent(null);
|
||||
host.clearSelection();
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue