-
Customer Segment
-
+
+
+
+
+
+
-
-
-
-
-
Revenue Streams
-
+
+
+
+
+
+
+
Lean Canvas
+
Tip: touch/click content to edit inline. Hover list items to remove them.
+
+
+ schedule Last edit: --
+
+
+
+
+
+
+
+ Problem
+ report_problem
+
+
+
+
+ Existing Alternatives
+
+
+
+
+
+
+
+
+ star
+
+ Unique Value Proposition
+
+
+
+
+
+
+
+
+
+ Unfair Advantage
+ workspace_premium
+
+
+
+
+
+
+
+
+
+ Customer Segments
+ person_search
+
+
+
+
+
+
+
+
+
+
+ payments
+ Cost Structure
+
+
+
+
+
+ monetization_on
+ Revenue Streams
+
+
+
+
-
-
-
-
+
+
+
+
diff --git a/script.js b/script.js
index 2376a2e..8ac1d4d 100644
--- a/script.js
+++ b/script.js
@@ -1,166 +1,709 @@
-console.log("script.js geladen.");
-
-document.addEventListener("DOMContentLoaded", () => {
- console.log("DOMContentLoaded-Ereignis empfangen.");
-
- // PDF Export Button initialisieren
- const exportBtn = document.getElementById("exportPDF");
- if (exportBtn) {
- console.log("PDF Export Button gefunden.");
- exportBtn.addEventListener("click", () => {
- console.log("PDF Export Button geklickt. Öffne window.print().");
- window.print();
- });
- } else {
- console.warn("PDF Export Button nicht gefunden.");
+const STORAGE_KEY = "mdcanvas_state_v2";
+
+const initialState = {
+ title: "Lean Canvas",
+ problem: [],
+ existingAlternatives: [],
+ solution: "",
+ observation: "Users prefer conversational UX over dashboards.",
+ keyMetrics: [],
+ uniqueValueProposition: "",
+ highLevelConcept: "Personal digital intelligent butler",
+ unfairAdvantage: [],
+ channels: [],
+ customerSegments: [],
+ earlyAdopters: [],
+ costStructure: [],
+ revenueStreams: [],
+ todo: [],
+};
+
+const headerMap = {
+ "lean canvas": "title",
+ problem: "problem",
+ "existing alternatives": "existingAlternatives",
+ solution: "solution",
+ observation: "observation",
+ "key metrics": "keyMetrics",
+ "unique value proposition": "uniqueValueProposition",
+ "high-level concept": "highLevelConcept",
+ "high level concept": "highLevelConcept",
+ "unfair advantage": "unfairAdvantage",
+ channels: "channels",
+ "customer segment": "customerSegments",
+ "customer segments": "customerSegments",
+ "early adopters": "earlyAdopters",
+ "cost structure": "costStructure",
+ "revenue streams": "revenueStreams",
+ todo: "todo",
+};
+
+const sectionTypes = {
+ title: "text",
+ problem: "list",
+ existingAlternatives: "list",
+ solution: "text",
+ observation: "text",
+ keyMetrics: "list",
+ uniqueValueProposition: "text",
+ highLevelConcept: "text",
+ unfairAdvantage: "list",
+ channels: "list",
+ customerSegments: "list",
+ earlyAdopters: "list",
+ costStructure: "list",
+ revenueStreams: "list",
+ todo: "list",
+};
+
+let state = structuredClone(initialState);
+let lastEditAt = null;
+
+document.addEventListener("DOMContentLoaded", async () => {
+ bindActions();
+ await loadInitialState();
+ render();
+ setupInlineTextEditing();
+ setInterval(updateLastEditLabel, 30000);
+});
+
+function bindActions() {
+ const importBtn = document.getElementById("importBtn");
+ const saveBtn = document.getElementById("saveBtn");
+ const saveMenuWrap = document.getElementById("saveMenuWrap");
+ const saveMenu = document.getElementById("saveMenu");
+ const saveMarkdownBtn = document.getElementById("saveMarkdownBtn");
+ const savePdfBtn = document.getElementById("savePdfBtn");
+ const createBtn = document.getElementById("createBtn");
+ const importInput = document.getElementById("importFileInput");
+
+ importBtn?.addEventListener("click", () => importInput?.click());
+
+ importInput?.addEventListener("change", async (event) => {
+ const file = event.target.files?.[0];
+ if (!file) {
+ return;
+ }
+
+ const content = await file.text();
+ const imported = parseMarkdown(content);
+ state = mergeWithDefaults(imported);
+ markEdited("Markdown imported");
+ render();
+ importInput.value = "";
+ });
+
+ saveBtn?.addEventListener("click", (event) => {
+ event.stopPropagation();
+ toggleSaveMenu();
+ });
+
+ saveMarkdownBtn?.addEventListener("click", () => {
+ const markdown = stateToMarkdown(state);
+ downloadFile("canvas.md", markdown);
+ persistState();
+ hideSaveMenu();
+ showToast("Canvas saved as canvas.md");
+ });
+
+ savePdfBtn?.addEventListener("click", () => {
+ hideSaveMenu();
+ saveAsPdf();
+ });
+
+ document.addEventListener("click", (event) => {
+ if (!saveMenuWrap?.contains(event.target)) {
+ hideSaveMenu();
+ }
+ });
+
+ document.addEventListener("keydown", (event) => {
+ if (event.key === "Escape") {
+ hideSaveMenu();
+ }
+ });
+
+ createBtn?.addEventListener("click", () => {
+ const confirmed = window.confirm(
+ "Create a new canvas? Current content will be replaced.",
+ );
+ if (!confirmed) {
+ return;
+ }
+
+ state = structuredClone(initialState);
+ markEdited("New canvas created");
+ render();
+ });
+
+ function toggleSaveMenu() {
+ if (!saveMenu) {
+ return;
+ }
+ saveMenu.classList.toggle("hidden");
}
- // Markdown laden und parsen
- console.log("Starte Fetch für canvas.md...");
- fetch('canvas.md')
- .then(response => {
- console.log("Fetch-Antwort empfangen.");
- if (!response.ok) {
- throw new Error("Fehler beim Laden der Markdown-Datei.");
+ function hideSaveMenu() {
+ saveMenu?.classList.add("hidden");
+ }
+}
+
+function saveAsPdf() {
+ document.body.classList.add("print-canvas-mode");
+ window.addEventListener(
+ "afterprint",
+ () => {
+ document.body.classList.remove("print-canvas-mode");
+ },
+ { once: true },
+ );
+ window.print();
+}
+
+function setupInlineTextEditing() {
+ const bindings = [
+ { id: "canvasTitle", key: "title" },
+ { id: "solutionText", key: "solution" },
+ { id: "observationText", key: "observation" },
+ { id: "uvpText", key: "uniqueValueProposition" },
+ { id: "highLevelConceptText", key: "highLevelConcept" },
+ ];
+
+ bindings.forEach(({ id, key }) => {
+ const element = document.getElementById(id);
+ if (!element || element.dataset.inlineBound === "true") {
+ return;
+ }
+
+ element.contentEditable = "true";
+ element.spellcheck = true;
+ element.classList.add("editable-text");
+ element.title = "Edit directly";
+
+ element.addEventListener("blur", () => {
+ const value = element.innerText.replace(/\s+/g, " ").trim();
+ if ((state[key] || "") === value) {
+ return;
}
- return response.text();
- })
- .then(mdText => {
- console.log("Markdown-Inhalt geladen:");
- console.log(mdText);
- parseMarkdown(mdText);
- })
- .catch(error => {
- console.error("Fehler beim Laden/parsen der Markdown-Datei:", error);
+
+ state[key] = value;
+ markEdited("Text updated");
+ render();
});
-});
+
+ element.dataset.inlineBound = "true";
+ });
+}
+
+async function loadInitialState() {
+ const saved = localStorage.getItem(STORAGE_KEY);
+ if (saved) {
+ try {
+ const parsed = JSON.parse(saved);
+ state = mergeWithDefaults(parsed);
+ lastEditAt = Date.now();
+ return;
+ } catch {
+ localStorage.removeItem(STORAGE_KEY);
+ }
+ }
+
+ try {
+ const response = await fetch("canvas.md");
+ if (!response.ok) {
+ throw new Error("Unable to read canvas.md");
+ }
+
+ const text = await response.text();
+ state = mergeWithDefaults(parseMarkdown(text));
+ lastEditAt = Date.now();
+ persistState();
+ } catch {
+ state = structuredClone(initialState);
+ lastEditAt = Date.now();
+ }
+}
+
+function mergeWithDefaults(data) {
+ return {
+ ...structuredClone(initialState),
+ ...data,
+ problem: normalizeList(data.problem),
+ existingAlternatives: normalizeList(data.existingAlternatives),
+ keyMetrics: normalizeList(data.keyMetrics),
+ unfairAdvantage: normalizeList(data.unfairAdvantage),
+ channels: normalizeList(data.channels),
+ customerSegments: normalizeList(data.customerSegments),
+ earlyAdopters: normalizeList(data.earlyAdopters),
+ costStructure: normalizeList(data.costStructure),
+ revenueStreams: normalizeList(data.revenueStreams),
+ todo: normalizeList(data.todo),
+ };
+}
function parseMarkdown(mdText) {
- console.log("Starte Parsing der Markdown-Datei...");
- const lines = mdText.split('\n');
- let currentSection = null;
- const sectionContent = {};
-
- lines.forEach((line, index) => {
- line = line.trim();
- console.log(`Zeile ${index + 1}: "${line}"`);
- if (line.startsWith("# ")) {
- // Neue Überschrift gefunden
- const header = line.replace("# ", "").trim();
- console.log(`Überschrift erkannt: "${header}"`);
- if (sectionMapping.hasOwnProperty(header)) {
- currentSection = sectionMapping[header];
- console.log(`Mapping gefunden. currentSection gesetzt auf: "${currentSection}"`);
- sectionContent[currentSection] = (currentSection === "lean-canvas") ? "" : [];
- } else {
- console.log(`Kein Mapping für Überschrift: "${header}". Setze currentSection auf null.`);
- currentSection = null;
- }
- } else if (currentSection) {
- if (line !== "") {
- if (currentSection === "lean-canvas") {
- sectionContent[currentSection] += line + " ";
- console.log(`Anhängen an lean-canvas: "${line}"`);
- } else if (/^[-*]\s+/.test(line)) {
- const item = line.replace(/^[-*]\s+/, "").trim();
- sectionContent[currentSection].push(item);
- console.log(`Listeneintrag erkannt in Section "${currentSection}": "${item}"`);
- } else {
- sectionContent[currentSection] += line + " ";
- console.log(`Anhängen an Section "${currentSection}" (kein Listenpunkt): "${line}"`);
- }
+ const parsed = {};
+ const lines = mdText.split(/\r?\n/);
+ let currentKey = null;
+
+ for (const rawLine of lines) {
+ const line = rawLine.trim();
+
+ if (!line) {
+ continue;
+ }
+
+ const headingMatch = line.match(/^#{1,2}\s+(.+)$/);
+ if (headingMatch) {
+ const heading = normalizeHeader(headingMatch[1]);
+ currentKey = headerMap[heading] || null;
+
+ if (!currentKey) {
+ continue;
}
+
+ parsed[currentKey] = sectionTypes[currentKey] === "list" ? [] : "";
+ continue;
}
- });
- console.log("Parsing abgeschlossen. Ergebnis:", sectionContent);
-
- // Inhalte in die entsprechenden Container einfügen
- for (const section in sectionContent) {
- if (section === "lean-canvas") {
- const leanHeader = document.querySelector("#lean-canvas-header h1");
- const leanText = sectionContent[section].trim();
- const newHeaderText = leanText ? `Lean Canvas - ${leanText}` : "Lean Canvas";
- leanHeader.textContent = newHeaderText;
- document.title = newHeaderText;
- console.log(`Lean Canvas Header gesetzt: "${newHeaderText}"`);
- } else if (section === "todo") {
- // Verarbeitung der Todo-Sektion erfolgt später
+ if (!currentKey) {
continue;
- } else {
- const container = document.querySelector(`#${section} .content`);
- if (container) {
- if (Array.isArray(sectionContent[section]) && sectionContent[section].length) {
- const ul = document.createElement("ul");
- sectionContent[section].forEach(item => {
- const li = document.createElement("li");
- li.textContent = item;
- ul.appendChild(li);
- console.log(`Erstelle
in Section "${section}": "${item}"`);
- });
- container.innerHTML = ""; // Vorherigen Inhalt löschen
- container.appendChild(ul);
- console.log(`Liste in Container "#${section} .content" eingefügt.`);
- } else {
- container.textContent = sectionContent[section].trim();
- console.log(`Fließtext in Container "#${section} .content" gesetzt: "${sectionContent[section].trim()}"`);
- }
- } else {
- console.warn(`Kein Container gefunden für Section "${section}"`);
+ }
+
+ if (sectionTypes[currentKey] === "list") {
+ const bulletMatch = line.match(/^[-*]\s+(.+)$/);
+ const value = bulletMatch ? bulletMatch[1].trim() : line;
+ if (value) {
+ parsed[currentKey].push(value);
}
+ } else {
+ parsed[currentKey] = `${parsed[currentKey]} ${line}`.trim();
}
}
- // Todo-Bereich erstellen, falls die Markdown-Sektion vorhanden ist
- if (sectionContent.hasOwnProperty("todo") && sectionContent["todo"]) {
- const todoData = sectionContent["todo"];
- console.log("Todo-Sektion gefunden:", todoData);
- const todoContainer = document.createElement("div");
- todoContainer.id = "todo-container";
- const todoHeader = document.createElement("h2");
- todoHeader.textContent = "Todo";
- todoHeader.addEventListener("click", () => {
- const currentDisplay = todoContent.style.display;
- todoContent.style.display = (currentDisplay === "none") ? "" : "none";
- console.log(`Todo-Bereich umgeschaltet. Neuer Display-Status: "${todoContent.style.display}"`);
- });
- const todoContent = document.createElement("div");
- todoContent.classList.add("todo-content");
- if (Array.isArray(todoData) && todoData.length) {
- const ul = document.createElement("ul");
- todoData.forEach(item => {
- const li = document.createElement("li");
- li.textContent = item;
- ul.appendChild(li);
- console.log(`Erstelle im Todo-Bereich: "${item}"`);
- });
- todoContent.appendChild(ul);
- } else {
- todoContent.textContent = todoData.trim();
- console.log(`Setze Fließtext im Todo-Bereich: "${todoData.trim()}"`);
- }
- todoContent.style.display = "";
- todoContainer.appendChild(todoHeader);
- todoContainer.appendChild(todoContent);
- const canvas = document.querySelector(".canvas");
- canvas.parentNode.insertBefore(todoContainer, canvas.nextSibling);
- console.log("Todo-Bereich wurde unterhalb des Canvas eingefügt.");
+ return parsed;
+}
+
+function normalizeHeader(header) {
+ return header
+ .toLowerCase()
+ .replace(/[^a-z0-9\s-]/g, "")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+function normalizeList(list) {
+ if (!Array.isArray(list)) {
+ return [];
+ }
+
+ return list.map((item) => String(item).trim()).filter(Boolean);
+}
+
+function render() {
+ const canvasTitle = document.getElementById("canvasTitle");
+ if (canvasTitle) {
+ canvasTitle.innerHTML = formatInlineMarkdown(state.title || "Lean Canvas");
+ }
+ document.title = `${state.title || "Lean Canvas"} | Local-First Workspace`;
+
+ renderDotList("problemList", state.problem);
+ renderTagList(
+ "existingAlternativesTags",
+ state.existingAlternatives,
+ "px-2 py-1 bg-secondary-fixed-dim text-on-secondary-fixed text-[11px] rounded-sm font-medium",
+ );
+ renderText("solutionText", state.solution);
+ renderText("observationText", state.observation);
+ renderIconList("keyMetricsList", state.keyMetrics, "query_stats");
+ renderText("uvpText", state.uniqueValueProposition);
+ renderText("highLevelConceptText", state.highLevelConcept);
+ renderCheckList("unfairAdvantageList", state.unfairAdvantage);
+ renderChannels("channelsList", state.channels);
+ renderCustomerCards("customerSegmentsCards", state.customerSegments);
+ renderTagList(
+ "earlyAdoptersTags",
+ state.earlyAdopters,
+ "text-xs font-medium px-2 py-1 bg-secondary-container text-on-secondary-container rounded-sm w-max",
+ );
+ renderCostStructure("costStructureList", state.costStructure);
+ renderRevenueCards("revenueStreamsCards", state.revenueStreams);
+
+ persistState();
+ updateLastEditLabel();
+}
+
+function renderText(elementId, value) {
+ const element = document.getElementById(elementId);
+ if (element) {
+ element.innerHTML = formatInlineMarkdown(value || "");
+ }
+}
+
+function renderDotList(elementId, items) {
+ const element = document.getElementById(elementId);
+ if (!element) {
+ return;
+ }
+
+ element.innerHTML = "";
+ for (const [index, item] of items.entries()) {
+ const li = document.createElement("li");
+ li.className = "group relative flex items-start gap-2 pr-8";
+ li.innerHTML = `${formatInlineMarkdown(item)}`;
+ element.appendChild(li);
+ }
+
+ appendAddButton(element, "problem");
+}
+
+function renderIconList(elementId, items, icon) {
+ const element = document.getElementById(elementId);
+ if (!element) {
+ return;
+ }
+
+ element.innerHTML = "";
+ const key = elementId === "keyMetricsList" ? "keyMetrics" : "channels";
+ for (const [index, item] of items.entries()) {
+ const li = document.createElement("li");
+ li.className = "group relative flex items-start gap-2 pr-8";
+ li.innerHTML = `${icon}${formatInlineMarkdown(item)}`;
+ element.appendChild(li);
+ }
+
+ appendAddButton(element, key);
+}
+
+function renderCheckList(elementId, items) {
+ const element = document.getElementById(elementId);
+ if (!element) {
+ return;
+ }
+
+ element.innerHTML = "";
+ for (const [index, item] of items.entries()) {
+ const li = document.createElement("li");
+ li.className = "group relative flex items-start gap-2 pr-8";
+ li.innerHTML = `check_circle${formatInlineMarkdown(item)}`;
+ element.appendChild(li);
+ }
+
+ appendAddButton(element, "unfairAdvantage");
+}
+
+function renderTagList(elementId, items, className) {
+ const element = document.getElementById(elementId);
+ if (!element) {
+ return;
+ }
+
+ element.innerHTML = "";
+ const key =
+ elementId === "existingAlternativesTags"
+ ? "existingAlternatives"
+ : "earlyAdopters";
+ for (const [index, item] of items.entries()) {
+ const wrapper = document.createElement("div");
+ wrapper.className =
+ "group relative inline-flex items-start pr-6 max-w-full";
+ wrapper.innerHTML = `${formatInlineMarkdown(item)}`;
+ element.appendChild(wrapper);
+ }
+
+ appendAddButton(element, key);
+}
+
+function renderChannels(elementId, items) {
+ const element = document.getElementById(elementId);
+ if (!element) {
+ return;
+ }
+
+ element.innerHTML = "";
+ items.forEach((item, index) => {
+ const row = document.createElement("div");
+ row.className = "group relative pr-8";
+ const textClass = "text-[11px] text-on-surface-variant";
+ row.innerHTML = `${formatInlineMarkdown(item)}
`;
+ element.appendChild(row);
+ });
+
+ appendAddButton(element, "channels");
+}
+
+function renderCustomerCards(elementId, items) {
+ const element = document.getElementById(elementId);
+ if (!element) {
+ return;
+ }
+
+ element.innerHTML = "";
+ for (const [index, item] of items.entries()) {
+ const wrapper = document.createElement("div");
+ wrapper.className = "group relative pr-8";
+ wrapper.innerHTML = `${formatInlineMarkdown(item)}
`;
+ element.appendChild(wrapper);
+ }
+
+ appendAddButton(element, "customerSegments");
+}
+
+function renderCostStructure(elementId, items) {
+ const element = document.getElementById(elementId);
+ if (!element) {
+ return;
+ }
+
+ element.innerHTML = "";
+ for (const [index, item] of items.entries()) {
+ const div = document.createElement("div");
+ div.className =
+ "group relative flex items-start gap-3 pr-8 pb-2 border-b border-surface-container";
+ div.innerHTML = `${formatInlineMarkdown(item)}`;
+ element.appendChild(div);
+ }
+
+ appendAddButton(element, "costStructure");
+}
+
+function renderRevenueCards(elementId, items) {
+ const element = document.getElementById(elementId);
+ if (!element) {
+ return;
+ }
+
+ const tones = [
+ "bg-primary-container/30 text-on-primary-container",
+ "bg-secondary-container/30 text-on-secondary-container",
+ "bg-tertiary-container/30 text-on-tertiary-container",
+ ];
+
+ element.innerHTML = "";
+ items.forEach((item, index) => {
+ const wrapper = document.createElement("div");
+ wrapper.className = "group relative flex-1 min-w-[140px] pr-8";
+ wrapper.innerHTML = `${formatInlineMarkdown(item)}
`;
+ element.appendChild(wrapper);
+ });
+
+ appendAddButton(element, "revenueStreams");
+}
+
+function appendAddButton(container, key) {
+ const addButton = document.createElement("button");
+ addButton.className = "list-add-btn";
+ addButton.type = "button";
+ addButton.dataset.key = key;
+ addButton.textContent = "+ Add";
+ addButton.addEventListener("click", () => {
+ state[key] = [...(state[key] || []), "New item"];
+ markEdited("Item added");
+ render();
+ });
+ container.appendChild(addButton);
+}
+
+document.addEventListener("click", (event) => {
+ const removeBtn = event.target.closest(".list-item-remove");
+ if (removeBtn) {
+ const key = removeBtn.dataset.key;
+ const index = Number(removeBtn.dataset.index);
+ if (!key || Number.isNaN(index)) {
+ return;
+ }
+
+ state[key] = (state[key] || []).filter(
+ (_, itemIndex) => itemIndex !== index,
+ );
+ markEdited("Item removed");
+ render();
+ return;
+ }
+
+ const textElement = event.target.closest(".list-item-text");
+ if (!textElement || textElement.isContentEditable) {
+ return;
+ }
+
+ textElement.contentEditable = "true";
+ textElement.classList.add("is-editing-inline");
+ placeCaretAtEnd(textElement);
+});
+
+document.addEventListener(
+ "blur",
+ (event) => {
+ const textElement = event.target.closest(".list-item-text");
+ if (!textElement || !textElement.isContentEditable) {
+ return;
+ }
+
+ const key = textElement.dataset.key;
+ const index = Number(textElement.dataset.index);
+ if (!key || Number.isNaN(index)) {
+ return;
+ }
+
+ const value = textElement.innerText.replace(/\s+/g, " ").trim();
+ textElement.contentEditable = "false";
+ textElement.classList.remove("is-editing-inline");
+
+ if (!value) {
+ state[key] = (state[key] || []).filter(
+ (_, itemIndex) => itemIndex !== index,
+ );
+ markEdited("Empty item removed");
+ render();
+ return;
+ }
+
+ if (state[key]?.[index] === value) {
+ return;
+ }
+
+ state[key][index] = value;
+ markEdited("Item updated");
+ render();
+ },
+ true,
+);
+
+document.addEventListener("keydown", (event) => {
+ const textElement = event.target.closest(".list-item-text");
+ if (!textElement || !textElement.isContentEditable) {
+ return;
+ }
+
+ if (event.key === "Enter") {
+ event.preventDefault();
+ textElement.blur();
+ }
+});
+
+function placeCaretAtEnd(element) {
+ element.focus();
+ const selection = window.getSelection();
+ if (!selection) {
+ return;
+ }
+
+ const range = document.createRange();
+ range.selectNodeContents(element);
+ range.collapse(false);
+ selection.removeAllRanges();
+ selection.addRange(range);
+}
+
+function stateToMarkdown(current) {
+ const blocks = [
+ ["Lean Canvas", current.title],
+ ["Problem", current.problem],
+ ["Existing Alternatives", current.existingAlternatives],
+ ["Solution", current.solution],
+ ["Observation", current.observation],
+ ["Key Metrics", current.keyMetrics],
+ ["Unique Value Proposition", current.uniqueValueProposition],
+ ["High-Level Concept", current.highLevelConcept],
+ ["Unfair Advantage", current.unfairAdvantage],
+ ["Channels", current.channels],
+ ["Customer Segments", current.customerSegments],
+ ["Early Adopters", current.earlyAdopters],
+ ["Cost Structure", current.costStructure],
+ ["Revenue Streams", current.revenueStreams],
+ ["Todo", current.todo],
+ ];
+
+ return (
+ blocks
+ .map(([header, content]) => {
+ if (Array.isArray(content)) {
+ const list = content.length
+ ? content.map((item) => `- ${item}`).join("\n")
+ : "-";
+ return `# ${header}\n${list}`;
+ }
+ return `# ${header}\n${content || ""}`;
+ })
+ .join("\n\n")
+ .trim() + "\n"
+ );
+}
+
+function persistState() {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
+}
+
+function markEdited(message) {
+ lastEditAt = Date.now();
+ showToast(message);
+}
+
+function updateLastEditLabel() {
+ const label = document.getElementById("lastEditLabel");
+ if (!label) {
+ return;
+ }
+
+ if (!lastEditAt) {
+ label.textContent = "Last edit: --";
+ return;
+ }
+
+ const minutes = Math.floor((Date.now() - lastEditAt) / 60000);
+ if (minutes <= 0) {
+ label.textContent = "Last edit: just now";
+ } else if (minutes === 1) {
+ label.textContent = "Last edit: 1 min ago";
} else {
- console.log("Keine Todo-Sektion gefunden.");
- }
-}
-
-const sectionMapping = {
- "Lean Canvas": "lean-canvas",
- "Problem": "problem",
- "Solution": "solution",
- "Key Metrics": "key-metrics",
- "Unique Value Proposition": "unique-value-proposition",
- "Unfair Advantage": "unfair-advantage",
- "Channels": "channels",
- "Customer Segment": "customer-segment",
- "Cost Structure": "cost-structure",
- "Revenue Streams": "revenue-streams",
- "Todo": "todo"
-};
+ label.textContent = `Last edit: ${minutes} mins ago`;
+ }
+}
+
+function showToast(message) {
+ const toast = document.getElementById("toast");
+ if (!toast) {
+ return;
+ }
+
+ toast.textContent = message;
+ toast.classList.add("opacity-100");
+ toast.classList.remove("opacity-0");
+ window.setTimeout(() => {
+ toast.classList.add("opacity-0");
+ toast.classList.remove("opacity-100");
+ }, 1600);
+}
+function downloadFile(filename, text) {
+ const blob = new Blob([text], { type: "text/markdown;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ URL.revokeObjectURL(url);
+}
+
+function escapeHtml(value) {
+ return value
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+function formatInlineMarkdown(value) {
+ const safe = escapeHtml(String(value || ""));
+
+ return safe
+ .replace(/\*\*\*([^*]+?)\*\*\*/g, "$1")
+ .replace(/___([^_]+?)___/g, "$1")
+ .replace(/\*\*([^*]+?)\*\*/g, "$1")
+ .replace(/__([^_]+?)__/g, "$1")
+ .replace(/\*([^*]+?)\*/g, "$1")
+ .replace(/_([^_]+?)_/g, "$1");
+}
diff --git a/style.css b/style.css
index 579f8af..68ccc0f 100644
--- a/style.css
+++ b/style.css
@@ -1,128 +1,329 @@
-/* Grundstyles */
-body {
- font-family: Arial, sans-serif;
- margin: 0;
- padding: 20px;
- background: #f5f5f5;
+.material-symbols-outlined {
+ font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24;
}
-h1, h2 {
- margin-top: 0;
+body {
+ font-family: "Inter", sans-serif;
}
-/* Export Button */
-#export-container {
- text-align: right;
- margin-bottom: 10px;
+.pb-safe {
+ padding-bottom: env(safe-area-inset-bottom);
}
-#exportPDF {
- padding: 8px 16px;
- font-size: 14px;
- cursor: pointer;
+.section-edit-trigger {
+ transition: color 0.2s ease, opacity 0.2s ease;
}
-/* Header für Lean Canvas */
-#lean-canvas-header {
- margin-bottom: 20px;
- text-align: center;
+.section-edit-trigger:hover {
+ opacity: 0.85;
}
-/* Grid-Layout für den Lean Canvas */
-.canvas {
- display: grid;
- grid-template-columns: repeat(5, 1fr);
- grid-template-rows: auto auto auto;
- grid-gap: 10px;
- grid-template-areas:
- "problem solution unique-value-proposition unfair-advantage customer-segment"
- "problem key-metrics unique-value-proposition channels customer-segment"
- "cost-structure cost-structure cost-structure revenue-streams revenue-streams";
- background: #fff;
- padding: 10px;
- border: 2px solid #333;
+.section-edit-trigger::after {
+ content: " ✎";
+ font-size: 10px;
+ opacity: 0.6;
+ vertical-align: middle;
}
-/* Positionierung der einzelnen Bereiche */
-#problem {
- grid-area: problem;
+#toast {
+ will-change: opacity;
}
-#solution {
- grid-area: solution;
+section,
+section * {
+ box-sizing: border-box;
}
-#unique-value-proposition {
- grid-area: unique-value-proposition;
+#costStructureList,
+#revenueStreamsCards,
+#channelsList,
+#keyMetricsList,
+#existingAlternativesTags,
+#earlyAdoptersTags {
+ overflow-wrap: anywhere;
}
-#unfair-advantage {
- grid-area: unfair-advantage;
+.editable-text {
+ cursor: text;
+ border-radius: 6px;
+ padding: 2px 4px;
+ margin: -2px -4px;
+ outline: none;
}
-#customer-segment {
- grid-area: customer-segment;
+.editable-text:focus {
+ background: rgba(66, 96, 134, 0.08);
+ box-shadow: 0 0 0 2px rgba(66, 96, 134, 0.2);
}
-#key-metrics {
- grid-area: key-metrics;
+.list-item-text {
+ cursor: text;
+ outline: none;
}
-#channels {
- grid-area: channels;
+.list-item-text.is-editing-inline {
+ background: rgba(66, 96, 134, 0.08);
+ border-radius: 4px;
+ box-shadow: 0 0 0 2px rgba(66, 96, 134, 0.2);
}
-#cost-structure {
- grid-area: cost-structure;
+.list-item-remove {
+ position: absolute;
+ right: 0;
+ top: 0;
+ width: 22px;
+ height: 22px;
+ border-radius: 999px;
+ border: 1px solid #e2e8f0;
+ background: #ffffff;
+ color: #9f403d;
+ font-size: 14px;
+ line-height: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.15s ease;
}
-#revenue-streams {
- grid-area: revenue-streams;
+.group:hover > .list-item-remove,
+.group:focus-within > .list-item-remove {
+ opacity: 1;
+ pointer-events: auto;
}
-/* Stil für die Inhalte */
-.canvas-item {
- border: 1px solid #ccc;
- padding: 10px;
- background: #fafafa;
- overflow: auto;
+.list-add-btn {
+ margin-top: 8px;
+ padding: 2px 8px;
+ border-radius: 999px;
+ border: 1px dashed #81b5f6;
+ color: #355379;
+ font-size: 11px;
+ font-weight: 600;
+ background: rgba(211, 228, 255, 0.35);
}
-.content ul {
- padding-left: 20px;
- margin: 0;
+.list-add-btn:hover {
+ background: rgba(211, 228, 255, 0.55);
}
-/* Todo Bereich */
-#todo-container {
- margin-top: 20px;
- border: 1px solid #ccc;
- padding: 10px;
- background: #fafafa;
-}
-#todo-container h2 {
- margin: 0 0 10px 0;
- cursor: pointer;
-}
+@media print {
+ @page {
+ size: A4 landscape;
+ margin: 7mm;
+ }
-/* Print Styles: DIN A4 im Querformat */
-@page {
- size: A4 landscape;
- margin: 10mm;
-}
+ html,
+ body {
+ font-size: 11pt !important;
+ color: #1f2937 !important;
+ -webkit-print-color-adjust: exact;
+ print-color-adjust: exact;
+ }
-@media print {
body {
- background: none;
- padding: 0;
+ background: #ffffff !important;
+ }
+
+ body > header,
+ nav,
+ #toast,
+ #saveMenu,
+ .flex.items-center.gap-1:has(#lastEditLabel),
+ .list-item-remove,
+ .list-add-btn {
+ display: none !important;
+ }
+
+ main {
+ padding: 0 !important;
+ }
+
+ .max-w-7xl {
+ max-width: 100% !important;
+ }
+
+ .mb-8,
+ .mt-4,
+ .gap-4,
+ .p-6,
+ .p-5,
+ .p-4 {
+ margin-top: 0 !important;
+ margin-bottom: 0 !important;
+ gap: 2.5mm !important;
+ padding: 2.5mm !important;
+ }
+
+ #canvasTitle + p {
+ display: none !important;
+ }
+
+ section,
+ li,
+ .group,
+ #revenueStreamsCards > div,
+ #costStructureList > div,
+ #canvasBottomGrid {
+ break-inside: avoid;
+ page-break-inside: avoid;
+ }
+
+ #canvasMainGrid {
+ display: grid !important;
+ grid-template-columns: repeat(5, minmax(0, 1fr)) !important;
+ grid-template-rows: auto auto !important;
+ gap: 2.5mm !important;
+ padding: 2.5mm !important;
+ }
+
+ #canvasMainGrid > :nth-child(1) {
+ grid-column: 1;
+ grid-row: 1 / span 2;
+ }
+
+ #canvasMainGrid > :nth-child(2) {
+ grid-column: 2;
+ grid-row: 1 / span 2;
+ }
+
+ #canvasMainGrid > :nth-child(3) {
+ grid-column: 3;
+ grid-row: 1 / span 2;
+ }
+
+ #canvasMainGrid > :nth-child(4) {
+ grid-column: 4;
+ grid-row: 1 / span 2;
+ }
+
+ #canvasMainGrid > :nth-child(5) {
+ grid-column: 5;
+ grid-row: 1 / span 2;
+ }
+
+ #canvasBottomGrid {
+ display: grid !important;
+ grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
+ gap: 2.5mm !important;
+ margin-top: 2.5mm !important;
+ }
+
+ .min-h-screen {
+ min-height: auto !important;
+ }
+
+ .h-40,
+ .overflow-y-auto {
+ height: auto !important;
+ max-height: none !important;
+ overflow: visible !important;
+ }
+
+ #problemList,
+ #keyMetricsList,
+ #unfairAdvantageList,
+ #channelsList,
+ #customerSegmentsCards,
+ #earlyAdoptersTags,
+ #existingAlternativesTags,
+ #costStructureList,
+ #revenueStreamsCards {
+ gap: 1.2mm !important;
}
- #export-container, header {
- display: none;
+
+ .shadow-sm,
+ .shadow-xl,
+ .hover\:shadow-md {
+ box-shadow: none !important;
+ }
+
+ section {
+ background: #ffffff !important;
+ color: #1f2937 !important;
+ border: 1px solid #cbd5e1 !important;
+ border-left-width: 3px !important;
+ }
+
+ .bg-primary,
+ .bg-primary-container,
+ .text-on-primary,
+ .text-on-primary,
+ .bg-primary-container\/30,
+ .bg-secondary-container\/30,
+ .bg-tertiary-container\/30,
+ .bg-surface-container,
+ .bg-surface-container-low,
+ .bg-surface-container-lowest,
+ .bg-surface-container-low\/50 {
+ background: #ffffff !important;
+ color: #1f2937 !important;
+ }
+
+ h3[data-key="uniqueValueProposition"],
+ h4[data-key="highLevelConcept"] {
+ color: #1f2937 !important;
+ opacity: 1 !important;
+ }
+
+ #canvasTitle {
+ font-size: 17pt !important;
+ line-height: 1.1 !important;
+ margin-bottom: 1mm !important;
}
- .canvas {
- border: none;
- padding: 0;
- margin: 0;
+
+ #lastEditLabel {
+ font-size: 9pt !important;
+ }
+
+ .text-\[10px\],
+ .text-\[11px\],
+ .text-xs,
+ .text-sm {
+ font-size: 7.6pt !important;
+ line-height: 1.22 !important;
+ }
+
+ .text-lg,
+ .text-base {
+ font-size: 8.4pt !important;
+ line-height: 1.22 !important;
+ }
+
+ .text-3xl {
+ font-size: 17pt !important;
+ line-height: 1.1 !important;
+ }
+
+ p,
+ li,
+ span,
+ h3,
+ h4 {
+ orphans: 2;
+ widows: 2;
+ }
+
+ .-rotate-1 {
+ transform: none !important;
+ }
+
+ #canvasMainGrid,
+ #canvasBottomGrid {
+ page-break-before: auto !important;
+ page-break-after: auto !important;
+ }
+
+ .section-edit-trigger::after {
+ content: "";
+ }
+
+ .editable-text,
+ .list-item-text,
+ .list-item-text.is-editing-inline {
+ background: transparent !important;
+ box-shadow: none !important;
}
}
diff --git a/todo.md b/todo.md
deleted file mode 100644
index 6d66a34..0000000
--- a/todo.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Todo
-- show icons on each chapter.
-- make a icon-toggle-swith: on/off with a gear-symbol. How to make it persistant?
-- like vim, mode-line comments: mdc: icon:on
-- check next.md