Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ <h1>TaskForge</h1>
autocomplete="off"
required
/>
<textarea
id="task-description"
name="description"
placeholder="Add notes with markdown"
rows="3"
></textarea>
<button type="submit">Add</button>
</form>

Expand Down
18 changes: 16 additions & 2 deletions src/app.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { load, save } from "./storage.js";
import { renderMarkdown } from "./markdown.js";
import { createTask, toggleTask, removeTask } from "./tasks.js";

const form = document.getElementById("task-form");
const input = document.getElementById("task-input");
const description = document.getElementById("task-description");
const list = document.getElementById("task-list");
const emptyState = document.getElementById("empty-state");

Expand All @@ -29,9 +31,20 @@ function render() {
render();
});

const content = document.createElement("div");
content.className = "task-content";

const label = document.createElement("span");
label.className = "task-title";
label.textContent = task.title;
content.appendChild(label);

if (task.description) {
const taskDescription = document.createElement("div");
taskDescription.className = "task-description";
taskDescription.appendChild(renderMarkdown(task.description));
content.appendChild(taskDescription);
}

const del = document.createElement("button");
del.type = "button";
Expand All @@ -44,7 +57,7 @@ function render() {
render();
});

li.append(checkbox, label, del);
li.append(checkbox, content, del);
list.appendChild(li);
}
}
Expand All @@ -53,9 +66,10 @@ form.addEventListener("submit", (e) => {
e.preventDefault();
const title = input.value.trim();
if (!title) return;
tasks = [createTask(title), ...tasks];
tasks = [createTask(title, description.value), ...tasks];
save(tasks);
input.value = "";
description.value = "";
input.focus();
render();
});
Expand Down
84 changes: 84 additions & 0 deletions src/markdown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
const TOKEN_PATTERN = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\))/g;
const SAFE_LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);

export function renderMarkdown(markdown) {
const fragment = document.createDocumentFragment();
const blocks = String(markdown || "")
.replace(/\r\n?/g, "\n")
.split(/\n{2,}/)
.map((block) => block.trim())
.filter(Boolean);

for (const block of blocks) {
const paragraph = document.createElement("p");
appendInlineMarkdown(paragraph, block.replace(/\n/g, " "));
fragment.appendChild(paragraph);
}

return fragment;
}

function appendInlineMarkdown(parent, text) {
let index = 0;
for (const match of text.matchAll(TOKEN_PATTERN)) {
if (match.index > index) {
parent.append(document.createTextNode(text.slice(index, match.index)));
}
parent.append(createInlineNode(match[0]));
index = match.index + match[0].length;
}

if (index < text.length) {
parent.append(document.createTextNode(text.slice(index)));
}
}

function createInlineNode(token) {
if (token.startsWith("`")) {
const code = document.createElement("code");
code.textContent = token.slice(1, -1);
return code;
}

if (token.startsWith("**")) {
const strong = document.createElement("strong");
strong.textContent = token.slice(2, -2);
return strong;
}

if (token.startsWith("*")) {
const emphasis = document.createElement("em");
emphasis.textContent = token.slice(1, -1);
return emphasis;
}

const linkMatch = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
if (linkMatch) {
return createSafeLink(linkMatch[1], linkMatch[2]);
}

return document.createTextNode(token);
}

function createSafeLink(label, rawUrl) {
const anchor = document.createElement("a");
anchor.textContent = label;
anchor.target = "_blank";
anchor.rel = "noopener noreferrer";

const url = safeUrl(rawUrl);
if (url) {
anchor.href = url;
}

return anchor;
}

function safeUrl(rawUrl) {
try {
const url = new URL(rawUrl, window.location.href);
return SAFE_LINK_PROTOCOLS.has(url.protocol) ? url.href : "";
} catch {
return "";
}
}
3 changes: 2 additions & 1 deletion src/tasks.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
export function createTask(title) {
export function createTask(title, description = "") {
return {
id: cryptoRandomId(),
title: title.trim(),
description: description.trim(),
done: false,
createdAt: Date.now(),
};
Expand Down
54 changes: 48 additions & 6 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,15 @@ body {
}

.task-form {
display: flex;
display: grid;
grid-template-columns: 1fr auto;
gap: 0.5rem;
margin-bottom: 1.25rem;
}

.task-form input {
flex: 1;
.task-form input,
.task-form textarea {
width: 100%;
padding: 0.5rem 0.75rem;
font: inherit;
color: inherit;
Expand All @@ -62,13 +64,21 @@ body {
border-radius: var(--radius);
}

.task-form input:focus {
.task-form textarea {
grid-column: 1 / -1;
min-height: 5rem;
resize: vertical;
}

.task-form input:focus,
.task-form textarea:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
border-color: var(--accent);
}

.task-form button {
align-self: start;
padding: 0.5rem 1rem;
font: inherit;
color: var(--accent-fg);
Expand Down Expand Up @@ -100,16 +110,48 @@ body {
border-radius: var(--radius);
}

.task-item.done .task-title {
.task-item.done .task-content {
text-decoration: line-through;
color: var(--done);
}

.task-title {
.task-content {
flex: 1;
min-width: 0;
}

.task-title {
display: block;
font-weight: 600;
word-break: break-word;
}

.task-description {
margin-top: 0.35rem;
color: var(--muted);
font-size: 0.95rem;
word-break: break-word;
}

.task-description p {
margin: 0.25rem 0 0;
}

.task-description code {
padding: 0.1rem 0.25rem;
color: var(--fg);
background: #f6f8fa;
border: 1px solid var(--border);
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono",
monospace;
font-size: 0.9em;
}

.task-description a {
color: var(--accent);
}

.task-delete {
appearance: none;
background: transparent;
Expand Down