@sirux/md-press 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -17
- package/bin/md-press.js +111 -30
- package/package.json +9 -5
- package/src/board.js +273 -0
- package/src/build.js +221 -48
- package/src/frontmatter.js +18 -10
- package/src/index.js +10 -0
- package/src/serve.js +84 -10
- package/src/tasks.js +43 -20
- package/src/template/board.css +79 -0
- package/src/template/board.js +236 -0
- package/src/template/page.css +52 -20
- package/src/template/page.js +51 -7
package/src/tasks.js
CHANGED
|
@@ -4,48 +4,72 @@ written back to the exact line it came from. It scans line by line in
|
|
|
4
4
|
document order, which is the order the page renders checkboxes, and skips
|
|
5
5
|
frontmatter and fenced code, where "- [ ]" is only text.
|
|
6
6
|
|
|
7
|
+
The task pattern follows marked, which renders the page: the brackets must be
|
|
8
|
+
followed by a space and then some text, so a bare "- [ ]" is not a task on
|
|
9
|
+
the page and is not one here either. A fence may be indented any amount, so
|
|
10
|
+
code inside nested lists is skipped too.
|
|
11
|
+
|
|
7
12
|
This scanner is deliberately simple, so it is paired with a safety check:
|
|
8
13
|
the server only allows writing when the number of tasks found here equals the
|
|
9
14
|
number of checkboxes the page rendered. Any layout the scanner misreads turns
|
|
10
15
|
into a read-only page instead of an edit to the wrong line.
|
|
16
|
+
|
|
17
|
+
The board view (src/board.js) is built on the same line scan, so a card's
|
|
18
|
+
checkbox and the page's checkbox for the same line always agree.
|
|
11
19
|
*/
|
|
12
20
|
|
|
13
|
-
const { frontmatterLength } = require("./frontmatter.js");
|
|
21
|
+
const { frontmatterLength, byteOrderMarkLength } = require("./frontmatter.js");
|
|
14
22
|
|
|
15
|
-
const taskLinePattern = /^((?:[ \t]*>)*[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]+\[)([ xX])(\](
|
|
16
|
-
const fenceOpenPattern = /^(?:[ \t]*>)*[ \t]
|
|
23
|
+
const taskLinePattern = /^((?:[ \t]*>)*[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]+\[)([ xX])(\] (?=[ \t]*\S))/;
|
|
24
|
+
const fenceOpenPattern = /^(?:[ \t]*>)*[ \t]*(`{3,}|~{3,})/;
|
|
17
25
|
|
|
18
26
|
/*
|
|
19
|
-
|
|
20
|
-
start of the whole file, frontmatter included, so they can be
|
|
27
|
+
Every line of the Markdown body as { lineIndex, text, inCode }. Line indexes
|
|
28
|
+
count from the start of the whole file, frontmatter included, so they can be
|
|
29
|
+
used to edit it. Fence lines and the lines between them are marked inCode.
|
|
30
|
+
A byte order mark on the first line is left out of that line's text.
|
|
21
31
|
*/
|
|
22
|
-
function
|
|
32
|
+
function bodyLines(sourceText) {
|
|
23
33
|
const lines = sourceText.split("\n");
|
|
24
34
|
const firstBodyLine = sourceText.slice(0, frontmatterLength(sourceText)).split("\n").length - 1;
|
|
25
|
-
const
|
|
35
|
+
const markLength = byteOrderMarkLength(sourceText);
|
|
36
|
+
const result = [];
|
|
26
37
|
let openFence = null;
|
|
27
38
|
|
|
28
39
|
for (let lineIndex = firstBodyLine; lineIndex < lines.length; lineIndex += 1) {
|
|
29
|
-
const
|
|
30
|
-
const fenceMatch =
|
|
40
|
+
const text = lineIndex === 0 ? lines[0].slice(markLength) : lines[lineIndex];
|
|
41
|
+
const fenceMatch = text.match(fenceOpenPattern);
|
|
31
42
|
if (openFence) {
|
|
32
43
|
const closes = fenceMatch
|
|
33
44
|
&& fenceMatch[1][0] === openFence[0]
|
|
34
45
|
&& fenceMatch[1].length >= openFence.length
|
|
35
|
-
&&
|
|
46
|
+
&& text.trim().replace(/^(>\s*)*/, "").replace(/[`~]/g, "") === "";
|
|
36
47
|
if (closes) openFence = null;
|
|
48
|
+
result.push({ lineIndex, text, inCode: true });
|
|
37
49
|
continue;
|
|
38
50
|
}
|
|
39
|
-
if (fenceMatch)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
51
|
+
if (fenceMatch) openFence = fenceMatch[1];
|
|
52
|
+
result.push({ lineIndex, text, inCode: Boolean(fenceMatch) });
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* Returns every task line as { lineIndex, checked }. */
|
|
58
|
+
function findTaskLines(sourceText) {
|
|
59
|
+
const tasks = [];
|
|
60
|
+
for (const line of bodyLines(sourceText)) {
|
|
61
|
+
if (line.inCode) continue;
|
|
62
|
+
const taskMatch = line.text.match(taskLinePattern);
|
|
63
|
+
if (taskMatch) tasks.push({ lineIndex: line.lineIndex, checked: taskMatch[2] !== " " });
|
|
45
64
|
}
|
|
46
65
|
return tasks;
|
|
47
66
|
}
|
|
48
67
|
|
|
68
|
+
/* Returns one task line with its bracket character set. Nothing else changes. */
|
|
69
|
+
function setTaskLineState(lineText, checked) {
|
|
70
|
+
return lineText.replace(taskLinePattern, (_fullMatch, before, _state, after) => before + (checked ? "x" : " ") + after);
|
|
71
|
+
}
|
|
72
|
+
|
|
49
73
|
/*
|
|
50
74
|
Returns the source with one task set to checked or unchecked. Only the single
|
|
51
75
|
character between the brackets changes, so spacing, line endings, and every
|
|
@@ -56,10 +80,9 @@ function setTaskState(sourceText, taskIndex, checked) {
|
|
|
56
80
|
const task = tasks[taskIndex];
|
|
57
81
|
if (!task) throw new RangeError(`No task at index ${taskIndex}`);
|
|
58
82
|
const lines = sourceText.split("\n");
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
});
|
|
83
|
+
const markLength = task.lineIndex === 0 ? byteOrderMarkLength(sourceText) : 0;
|
|
84
|
+
lines[task.lineIndex] = lines[task.lineIndex].slice(0, markLength) + setTaskLineState(lines[task.lineIndex].slice(markLength), checked);
|
|
62
85
|
return lines.join("\n");
|
|
63
86
|
}
|
|
64
87
|
|
|
65
|
-
module.exports = { findTaskLines, setTaskState };
|
|
88
|
+
module.exports = { findTaskLines, setTaskState, setTaskLineState, bodyLines, taskLinePattern };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Board layout on top of the page stylesheet. Columns sit side by side and
|
|
3
|
+
scroll sideways on narrow screens. Cards are quiet paper on the column's
|
|
4
|
+
slightly darker track, with tag colors taken from each tag's name.
|
|
5
|
+
*/
|
|
6
|
+
.board-body { display: flex; flex-direction: column; min-height: 100vh; }
|
|
7
|
+
.board-toolbar { gap: 0.4rem 1rem; padding-left: 1.25rem; padding-right: 1.25rem; }
|
|
8
|
+
.board-title { color: var(--ink); font-size: 1rem; }
|
|
9
|
+
.board-filter { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
|
10
|
+
.board-filter .tag { cursor: pointer; opacity: 0.55; }
|
|
11
|
+
.board-filter .tag:hover, .board-filter .tag.active { opacity: 1; }
|
|
12
|
+
.board-filter .tag.active { outline: 2px solid hsl(var(--hue) 50% 50%); outline-offset: 1px; }
|
|
13
|
+
.board-link { font: inherit; }
|
|
14
|
+
|
|
15
|
+
main.board {
|
|
16
|
+
flex: 1; max-width: none; margin: 0; padding: 1.25rem;
|
|
17
|
+
display: flex; align-items: flex-start; gap: 1rem;
|
|
18
|
+
overflow-x: auto; scroll-snap-type: x proximity;
|
|
19
|
+
font-family: var(--sans); font-size: 0.95rem; line-height: 1.5;
|
|
20
|
+
}
|
|
21
|
+
.column {
|
|
22
|
+
flex: 0 0 18rem; max-width: 85vw; scroll-snap-align: start;
|
|
23
|
+
background: var(--code-paper); border-radius: 8px; padding: 0.75rem;
|
|
24
|
+
}
|
|
25
|
+
.column h2 { font-size: 0.95rem; margin: 0 0.25rem 0.6rem; padding: 0; border: 0; display: flex; align-items: baseline; gap: 0.5rem; }
|
|
26
|
+
.column .count { font-weight: 400; color: var(--muted); font-size: 0.85rem; }
|
|
27
|
+
.cards { list-style: none; margin: 0; padding: 0; min-height: 2.5rem; }
|
|
28
|
+
.cards.drop-target { outline: 2px dashed var(--accent); outline-offset: 2px; border-radius: 6px; }
|
|
29
|
+
.card {
|
|
30
|
+
position: relative; margin: 0 0 0.5rem; padding: 0.6rem 0.75rem;
|
|
31
|
+
background: var(--paper); border: 1px solid var(--rule); border-radius: 6px;
|
|
32
|
+
cursor: grab; transition: box-shadow 0.15s;
|
|
33
|
+
}
|
|
34
|
+
.card:hover { box-shadow: 0 2px 8px rgb(0 0 0 / 0.08); }
|
|
35
|
+
.card.dragging { opacity: 0.4; cursor: grabbing; }
|
|
36
|
+
.card.hidden { display: none; }
|
|
37
|
+
.card.done .card-title { color: var(--muted); text-decoration: line-through; text-decoration-color: var(--rule); }
|
|
38
|
+
.card-main { display: flex; gap: 0.5rem; align-items: flex-start; cursor: pointer; padding-right: 1.5rem; }
|
|
39
|
+
.card-main input.task { flex: none; margin-top: 0.2em; }
|
|
40
|
+
.card-title code { font-size: 0.85em; }
|
|
41
|
+
.card-tags { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.4rem; }
|
|
42
|
+
.tag {
|
|
43
|
+
--hue: 200;
|
|
44
|
+
font: 500 0.72rem/1.5 var(--sans); padding: 0 0.5em; border-radius: 999px;
|
|
45
|
+
color: hsl(var(--hue) 55% 30%); background: hsl(var(--hue) 60% 90%);
|
|
46
|
+
}
|
|
47
|
+
.card-notes { margin-top: 0.4rem; font-size: 0.85rem; color: var(--muted); }
|
|
48
|
+
.card-notes > :last-child { margin-bottom: 0; }
|
|
49
|
+
.card-notes ul, .card-notes ol { padding-left: 1.2em; margin-bottom: 0.4em; }
|
|
50
|
+
.card-notes li.task-item { margin-left: -1.2em; padding-left: 1.6em; }
|
|
51
|
+
.card-notes input.task { pointer-events: none; }
|
|
52
|
+
.card-move { position: absolute; top: 0.45rem; right: 0.5rem; width: 1.4rem; height: 1.4rem; overflow: hidden; border-radius: 4px; opacity: 0; }
|
|
53
|
+
.card:hover .card-move, .card:focus-within .card-move { opacity: 1; }
|
|
54
|
+
.card-move::before { content: "→"; position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); font-size: 0.9rem; pointer-events: none; }
|
|
55
|
+
.card-move select { width: 100%; height: 100%; opacity: 0; cursor: pointer; }
|
|
56
|
+
.add-card { display: flex; gap: 0.4rem; margin-top: 0.25rem; }
|
|
57
|
+
.add-card input {
|
|
58
|
+
flex: 1; min-width: 0; font: inherit; color: var(--ink); background: transparent;
|
|
59
|
+
border: 1px solid transparent; border-radius: 6px; padding: 0.35rem 0.5rem;
|
|
60
|
+
}
|
|
61
|
+
.add-card input::placeholder { color: var(--muted); }
|
|
62
|
+
.add-card input:focus { background: var(--paper); border-color: var(--rule); outline: none; }
|
|
63
|
+
.add-card button { font: inherit; color: var(--muted); background: none; border: 1px solid var(--rule); border-radius: 6px; padding: 0.2rem 0.7rem; cursor: pointer; opacity: 0; }
|
|
64
|
+
.add-card:focus-within button { opacity: 1; }
|
|
65
|
+
.board-empty { max-width: 42rem; color: var(--muted); }
|
|
66
|
+
.board-body footer { max-width: none; margin: 0; padding: 1rem 1.25rem 1.5rem; }
|
|
67
|
+
|
|
68
|
+
@media (prefers-color-scheme: dark) {
|
|
69
|
+
.tag { color: hsl(var(--hue) 60% 80%); background: hsl(var(--hue) 35% 22%); }
|
|
70
|
+
.card:hover { box-shadow: 0 2px 8px rgb(0 0 0 / 0.4); }
|
|
71
|
+
}
|
|
72
|
+
@media (hover: none) {
|
|
73
|
+
.card-move, .add-card button { opacity: 1; }
|
|
74
|
+
}
|
|
75
|
+
@media print {
|
|
76
|
+
main.board { display: block; }
|
|
77
|
+
.column { break-inside: avoid; margin-bottom: 1rem; }
|
|
78
|
+
.card-move, .add-card { display: none; }
|
|
79
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Runs inside the board page from md-press board. Drag a card to another
|
|
3
|
+
column or position, or pick a column from its move menu, and the change is
|
|
4
|
+
written to the Markdown file. Ticking a card's box saves its state. Typing
|
|
5
|
+
in a column's "Add a card" field adds a task line to that column.
|
|
6
|
+
|
|
7
|
+
Every save carries the file version the page was built from, and the server
|
|
8
|
+
refuses it if the file changed since, so the page reloads instead of
|
|
9
|
+
overwriting someone else's edit. The page also watches the version and
|
|
10
|
+
reloads, keeping its scroll position, whenever the file changes on disk.
|
|
11
|
+
After a successful change the page reloads too, so what it shows is always
|
|
12
|
+
what the file says.
|
|
13
|
+
|
|
14
|
+
Tag filters live in this tab's session storage so they survive those reloads.
|
|
15
|
+
*/
|
|
16
|
+
(function () {
|
|
17
|
+
const live = mdPress.live;
|
|
18
|
+
const statusLabel = document.querySelector(".toolbar-status");
|
|
19
|
+
const board = document.querySelector("main.board");
|
|
20
|
+
const scrollKey = "md-press-board-scroll:" + location.pathname;
|
|
21
|
+
const filterKey = "md-press-board-filter:" + location.pathname;
|
|
22
|
+
let currentVersion = live.version;
|
|
23
|
+
let pendingSaves = 0;
|
|
24
|
+
|
|
25
|
+
function readStorage(key) {
|
|
26
|
+
try {
|
|
27
|
+
return sessionStorage.getItem(key);
|
|
28
|
+
} catch (_error) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writeStorage(key, value) {
|
|
34
|
+
try {
|
|
35
|
+
if (value === null) sessionStorage.removeItem(key);
|
|
36
|
+
else sessionStorage.setItem(key, value);
|
|
37
|
+
} catch (_error) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function setStatus(text, isError) {
|
|
43
|
+
statusLabel.textContent = text;
|
|
44
|
+
statusLabel.classList.toggle("error", Boolean(isError));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function reloadKeepingScroll() {
|
|
48
|
+
writeStorage(scrollKey, JSON.stringify({ x: board.scrollLeft, y: window.scrollY }));
|
|
49
|
+
location.reload();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const savedScroll = readStorage(scrollKey);
|
|
53
|
+
writeStorage(scrollKey, null);
|
|
54
|
+
if (savedScroll) {
|
|
55
|
+
try {
|
|
56
|
+
const { x, y } = JSON.parse(savedScroll);
|
|
57
|
+
board.scrollLeft = x;
|
|
58
|
+
window.scrollTo(0, y);
|
|
59
|
+
} catch (_error) {
|
|
60
|
+
// Nothing saved that can be used.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function save(pathName, payload) {
|
|
65
|
+
pendingSaves += 1;
|
|
66
|
+
setStatus("Saving");
|
|
67
|
+
try {
|
|
68
|
+
const response = await fetch(pathName, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "Content-Type": "application/json" },
|
|
71
|
+
body: JSON.stringify(Object.assign({ version: currentVersion }, payload)),
|
|
72
|
+
});
|
|
73
|
+
if (response.status === 409) {
|
|
74
|
+
reloadKeepingScroll();
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const result = await response.json();
|
|
78
|
+
if (!response.ok) throw new Error(result.error || ("Save failed with status " + response.status));
|
|
79
|
+
currentVersion = result.version;
|
|
80
|
+
setStatus("Saved to " + live.fileName);
|
|
81
|
+
return result;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
setStatus(error.message === "Failed to fetch" ? "Could not save. Is md-press board still running?" : error.message, true);
|
|
84
|
+
return null;
|
|
85
|
+
} finally {
|
|
86
|
+
pendingSaves -= 1;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function setupCheckboxes() {
|
|
91
|
+
document.querySelectorAll(".card > .card-main > input.task").forEach(function (checkbox) {
|
|
92
|
+
checkbox.addEventListener("change", async function () {
|
|
93
|
+
const card = checkbox.closest(".card");
|
|
94
|
+
const result = await save("/api/task", { index: Number(card.dataset.task), checked: checkbox.checked });
|
|
95
|
+
if (result) card.classList.toggle("done", checkbox.checked);
|
|
96
|
+
else checkbox.checked = !checkbox.checked;
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function positionOf(card) {
|
|
102
|
+
return { column: Number(card.parentElement.dataset.column), index: Array.from(card.parentElement.children).indexOf(card) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function moveCard(card, from, to) {
|
|
106
|
+
if (from.column === to.column && from.index === to.index) return;
|
|
107
|
+
const result = await save("/api/board", { action: "move", from: from, to: to });
|
|
108
|
+
if (result) reloadKeepingScroll();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function setupDragAndDrop() {
|
|
112
|
+
let dragged = null;
|
|
113
|
+
let origin = null;
|
|
114
|
+
|
|
115
|
+
document.querySelectorAll(".card").forEach(function (card) {
|
|
116
|
+
card.addEventListener("dragstart", function (event) {
|
|
117
|
+
dragged = card;
|
|
118
|
+
origin = { column: Number(card.dataset.column), index: Number(card.dataset.index) };
|
|
119
|
+
card.classList.add("dragging");
|
|
120
|
+
event.dataTransfer.effectAllowed = "move";
|
|
121
|
+
event.dataTransfer.setData("text/plain", card.querySelector(".card-title").textContent);
|
|
122
|
+
});
|
|
123
|
+
card.addEventListener("dragend", function () {
|
|
124
|
+
card.classList.remove("dragging");
|
|
125
|
+
document.querySelectorAll(".cards.drop-target").forEach(function (list) { list.classList.remove("drop-target"); });
|
|
126
|
+
dragged = null;
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
document.querySelectorAll(".cards").forEach(function (list) {
|
|
131
|
+
list.addEventListener("dragover", function (event) {
|
|
132
|
+
if (!dragged) return;
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
event.dataTransfer.dropEffect = "move";
|
|
135
|
+
list.classList.add("drop-target");
|
|
136
|
+
const cards = Array.from(list.querySelectorAll(":scope > .card:not(.dragging)"));
|
|
137
|
+
const next = cards.find(function (card) {
|
|
138
|
+
const box = card.getBoundingClientRect();
|
|
139
|
+
return event.clientY < box.top + box.height / 2;
|
|
140
|
+
});
|
|
141
|
+
if (next) list.insertBefore(dragged, next);
|
|
142
|
+
else list.appendChild(dragged);
|
|
143
|
+
});
|
|
144
|
+
list.addEventListener("dragleave", function (event) {
|
|
145
|
+
if (!list.contains(event.relatedTarget)) list.classList.remove("drop-target");
|
|
146
|
+
});
|
|
147
|
+
list.addEventListener("drop", function (event) {
|
|
148
|
+
if (!dragged) return;
|
|
149
|
+
event.preventDefault();
|
|
150
|
+
list.classList.remove("drop-target");
|
|
151
|
+
moveCard(dragged, origin, positionOf(dragged));
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function setupMoveMenus() {
|
|
157
|
+
document.querySelectorAll(".card-move select").forEach(function (select) {
|
|
158
|
+
select.addEventListener("change", function () {
|
|
159
|
+
if (select.value === "") return;
|
|
160
|
+
const from = { column: Number(select.dataset.column), index: Number(select.dataset.index) };
|
|
161
|
+
moveCard(select.closest(".card"), from, { column: Number(select.value), index: Infinity });
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function setupAddForms() {
|
|
167
|
+
document.querySelectorAll(".add-card").forEach(function (form) {
|
|
168
|
+
form.addEventListener("submit", async function (event) {
|
|
169
|
+
event.preventDefault();
|
|
170
|
+
const input = form.querySelector("input");
|
|
171
|
+
const text = input.value.trim();
|
|
172
|
+
if (!text) return;
|
|
173
|
+
const result = await save("/api/board", { action: "add", column: Number(form.dataset.column), text: text });
|
|
174
|
+
if (result) {
|
|
175
|
+
input.value = "";
|
|
176
|
+
reloadKeepingScroll();
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function setupTagFilter() {
|
|
183
|
+
const chips = Array.from(document.querySelectorAll(".board-filter .tag"));
|
|
184
|
+
if (chips.length === 0) return;
|
|
185
|
+
let active = [];
|
|
186
|
+
try {
|
|
187
|
+
active = JSON.parse(readStorage(filterKey)) || [];
|
|
188
|
+
} catch (_error) {
|
|
189
|
+
active = [];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function apply() {
|
|
193
|
+
chips.forEach(function (chip) { chip.classList.toggle("active", active.indexOf(chip.textContent) !== -1); });
|
|
194
|
+
document.querySelectorAll(".card").forEach(function (card) {
|
|
195
|
+
const tags = card.dataset.tags.split(" ");
|
|
196
|
+
card.classList.toggle("hidden", active.some(function (tag) { return tags.indexOf(tag) === -1; }));
|
|
197
|
+
});
|
|
198
|
+
writeStorage(filterKey, active.length ? JSON.stringify(active) : null);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
chips.forEach(function (chip) {
|
|
202
|
+
chip.setAttribute("role", "button");
|
|
203
|
+
chip.tabIndex = 0;
|
|
204
|
+
function toggle() {
|
|
205
|
+
const tag = chip.textContent;
|
|
206
|
+
active = active.indexOf(tag) === -1 ? active.concat(tag) : active.filter(function (item) { return item !== tag; });
|
|
207
|
+
apply();
|
|
208
|
+
}
|
|
209
|
+
chip.addEventListener("click", toggle);
|
|
210
|
+
chip.addEventListener("keydown", function (event) {
|
|
211
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
212
|
+
event.preventDefault();
|
|
213
|
+
toggle();
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
apply();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
setInterval(async function () {
|
|
221
|
+
if (pendingSaves > 0) return;
|
|
222
|
+
try {
|
|
223
|
+
const response = await fetch("/api/version", { cache: "no-store" });
|
|
224
|
+
const { version } = await response.json();
|
|
225
|
+
if (version !== currentVersion) reloadKeepingScroll();
|
|
226
|
+
} catch (_error) {
|
|
227
|
+
setStatus("Server stopped. Run md-press board again to keep saving.", true);
|
|
228
|
+
}
|
|
229
|
+
}, 1500);
|
|
230
|
+
|
|
231
|
+
setupCheckboxes();
|
|
232
|
+
setupDragAndDrop();
|
|
233
|
+
setupMoveMenus();
|
|
234
|
+
setupAddForms();
|
|
235
|
+
setupTagFilter();
|
|
236
|
+
})();
|
package/src/template/page.css
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
One quiet reading page. Cool paper and slate ink, a single deep teal accent,
|
|
3
3
|
serif body for long reading and system sans for structure. The toolbar, with
|
|
4
4
|
checklist progress and live status, is the only element that sits on top of
|
|
5
|
-
the page.
|
|
5
|
+
the page. Callouts and code use the same small palette as the highlighter.
|
|
6
6
|
*/
|
|
7
7
|
:root {
|
|
8
8
|
--paper: #f6f7f9;
|
|
@@ -13,6 +13,11 @@ the page.
|
|
|
13
13
|
--code-paper: #eceff4;
|
|
14
14
|
--mark: #d7eeee;
|
|
15
15
|
--danger: #b3261e;
|
|
16
|
+
--blue: #0b5cad;
|
|
17
|
+
--green: #2e7d32;
|
|
18
|
+
--purple: #7a3fb0;
|
|
19
|
+
--orange: #b25b00;
|
|
20
|
+
--red: #b3261e;
|
|
16
21
|
--serif: Charter, "Bitstream Charter", "Iowan Old Style", "Sitka Text", Cambria, Georgia, serif;
|
|
17
22
|
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
18
23
|
--mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
@@ -31,6 +36,11 @@ the page.
|
|
|
31
36
|
--code-paper: #1c2029;
|
|
32
37
|
--mark: #1d3a3c;
|
|
33
38
|
--danger: #f28b82;
|
|
39
|
+
--blue: #7fb8f5;
|
|
40
|
+
--green: #8fd694;
|
|
41
|
+
--purple: #c79bf2;
|
|
42
|
+
--orange: #f0b36b;
|
|
43
|
+
--red: #f28b82;
|
|
34
44
|
}
|
|
35
45
|
}
|
|
36
46
|
*, *::before, *::after { box-sizing: inherit; }
|
|
@@ -41,9 +51,9 @@ body {
|
|
|
41
51
|
font: 1.0625rem/1.7 var(--serif);
|
|
42
52
|
-webkit-text-size-adjust: 100%;
|
|
43
53
|
}
|
|
44
|
-
main { max-width: 42rem; margin: 0 auto; padding: 3.5rem 1.25rem 2rem; }
|
|
54
|
+
main { max-width: 42rem; margin: 0 auto; padding: 3.5rem 1.25rem 2rem; overflow-wrap: break-word; }
|
|
45
55
|
|
|
46
|
-
h1, h2, h3, h4, h5, h6 { font-family: var(--sans); line-height: 1.25; margin: 2.2em 0 0.6em; letter-spacing: -0.01em; }
|
|
56
|
+
h1, h2, h3, h4, h5, h6 { font-family: var(--sans); line-height: 1.25; margin: 2.2em 0 0.6em; letter-spacing: -0.01em; scroll-margin-top: 3.25rem; }
|
|
47
57
|
h1 { font-size: 2.1rem; margin-top: 0; font-weight: 700; }
|
|
48
58
|
h2 { font-size: 1.45rem; padding-bottom: 0.3em; border-bottom: 1px solid var(--rule); }
|
|
49
59
|
h3 { font-size: 1.15rem; }
|
|
@@ -57,25 +67,52 @@ strong { font-weight: 700; }
|
|
|
57
67
|
mark { background: var(--mark); color: inherit; padding: 0 0.15em; }
|
|
58
68
|
hr { border: 0; border-top: 1px solid var(--rule); margin: 2.5em 0; }
|
|
59
69
|
img { max-width: 100%; height: auto; }
|
|
70
|
+
summary { cursor: pointer; }
|
|
60
71
|
|
|
61
72
|
ul, ol { padding-left: 1.4em; }
|
|
62
73
|
li + li { margin-top: 0.25em; }
|
|
63
|
-
li
|
|
64
|
-
|
|
74
|
+
li.task-item { list-style: none; position: relative; margin-left: -1.4em; padding-left: 1.9em; }
|
|
75
|
+
li.task-item > input.task, li.task-item > p:first-child > input.task { position: absolute; left: 0; top: 0.33em; margin: 0; }
|
|
76
|
+
input.task { width: 1.05em; height: 1.05em; accent-color: var(--accent); cursor: pointer; }
|
|
77
|
+
input.task:disabled { cursor: default; }
|
|
65
78
|
li.done { color: var(--muted); }
|
|
66
79
|
|
|
67
80
|
blockquote { margin-left: 0; padding: 0.1em 0 0.1em 1.1em; border-left: 3px solid var(--accent); color: var(--muted); }
|
|
68
81
|
|
|
82
|
+
.callout { --callout: var(--accent); margin: 0 0 1.1em; padding: 0.7em 1.1em; border-left: 3px solid var(--callout); border-radius: 0 6px 6px 0; background: color-mix(in srgb, var(--callout) 9%, transparent); }
|
|
83
|
+
.callout > :last-child { margin-bottom: 0; }
|
|
84
|
+
.callout-title { margin: 0 0 0.25em; font: 600 0.9rem/1.5 var(--sans); color: var(--callout); }
|
|
85
|
+
.callout-note { --callout: var(--blue); }
|
|
86
|
+
.callout-tip { --callout: var(--green); }
|
|
87
|
+
.callout-important { --callout: var(--purple); }
|
|
88
|
+
.callout-warning { --callout: var(--orange); }
|
|
89
|
+
.callout-caution { --callout: var(--red); }
|
|
90
|
+
|
|
69
91
|
kbd { font: 0.82em var(--mono); padding: 0.1em 0.45em; border: 1px solid var(--rule); border-bottom-width: 2px; border-radius: 4px; background: var(--code-paper); }
|
|
70
92
|
code { font: 0.88em var(--mono); background: var(--code-paper); padding: 0.12em 0.35em; border-radius: 4px; }
|
|
71
93
|
pre { position: relative; background: var(--code-paper); border-radius: 6px; padding: 1em 1.1em; overflow-x: auto; line-height: 1.5; }
|
|
72
94
|
pre code { background: none; padding: 0; font-size: 0.85rem; }
|
|
73
95
|
pre[data-language]::after { content: attr(data-language); position: absolute; top: 0.4em; right: 0.7em; font: 0.72rem var(--sans); color: var(--muted); }
|
|
74
96
|
pre.mermaid { background: none; text-align: center; font-family: var(--sans); }
|
|
97
|
+
.copy-code { position: absolute; top: 0.35em; right: 0.6em; font: 0.72rem var(--sans); color: var(--muted); background: var(--code-paper); border: 1px solid var(--rule); border-radius: 4px; padding: 0.1em 0.5em; cursor: pointer; opacity: 0; transition: opacity 0.15s; }
|
|
98
|
+
.copy-code:hover, .copy-code:focus-visible { color: var(--ink); }
|
|
99
|
+
pre:hover .copy-code, pre:focus-within .copy-code, .copy-code.copied { opacity: 1; }
|
|
100
|
+
@media (hover: none) {
|
|
101
|
+
.copy-code { opacity: 1; }
|
|
102
|
+
pre[data-language]::after { display: none; }
|
|
103
|
+
}
|
|
75
104
|
|
|
76
105
|
table { display: block; overflow-x: auto; border-collapse: collapse; font: 0.95rem/1.5 var(--sans); }
|
|
77
106
|
th, td { padding: 0.5em 0.9em; border-bottom: 1px solid var(--rule); text-align: left; vertical-align: top; }
|
|
78
107
|
th { font-weight: 600; border-bottom-width: 2px; }
|
|
108
|
+
th[align="center"], td[align="center"] { text-align: center; }
|
|
109
|
+
th[align="right"], td[align="right"] { text-align: right; }
|
|
110
|
+
|
|
111
|
+
.footnotes { margin-top: 2.5em; padding-top: 1em; border-top: 1px solid var(--rule); font-size: 0.9rem; color: var(--muted); }
|
|
112
|
+
.footnotes ol { padding-left: 1.4em; }
|
|
113
|
+
a[data-footnote-ref] { font: 0.75em var(--sans); text-decoration: none; padding: 0 0.1em; }
|
|
114
|
+
a[data-footnote-backref] { text-decoration: none; }
|
|
115
|
+
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
79
116
|
|
|
80
117
|
.progress {
|
|
81
118
|
position: sticky; top: env(safe-area-inset-top, 0px); z-index: 1;
|
|
@@ -90,28 +127,23 @@ th { font-weight: 600; border-bottom-width: 2px; }
|
|
|
90
127
|
.progress-reset:hover { color: var(--ink); }
|
|
91
128
|
.toolbar-status { margin-left: auto; }
|
|
92
129
|
.toolbar-status.error { color: var(--danger); }
|
|
93
|
-
input.task:disabled { cursor: default; }
|
|
94
130
|
|
|
95
131
|
footer { max-width: 42rem; margin: 0 auto; padding: 1.5rem 1.25rem 3rem; font: 0.8rem var(--sans); color: var(--muted); border-top: 1px solid var(--rule); }
|
|
96
132
|
|
|
97
133
|
.hljs-comment, .hljs-quote { color: var(--muted); font-style: italic; }
|
|
98
|
-
.hljs-keyword, .hljs-selector-tag, .hljs-built_in, .hljs-literal { color:
|
|
99
|
-
.hljs-string, .hljs-attr, .hljs-template-tag, .hljs-addition { color:
|
|
100
|
-
.hljs-number, .hljs-symbol, .hljs-variable, .hljs-template-variable { color:
|
|
101
|
-
.hljs-title, .hljs-section, .hljs-name, .hljs-selector-id, .hljs-selector-class { color:
|
|
102
|
-
.hljs-type, .hljs-meta, .hljs-deletion { color:
|
|
103
|
-
@media (prefers-color-scheme: dark) {
|
|
104
|
-
.hljs-keyword, .hljs-selector-tag, .hljs-built_in, .hljs-literal { color: #c79bf2; }
|
|
105
|
-
.hljs-string, .hljs-attr, .hljs-template-tag, .hljs-addition { color: #8fd694; }
|
|
106
|
-
.hljs-number, .hljs-symbol, .hljs-variable, .hljs-template-variable { color: #f0b36b; }
|
|
107
|
-
.hljs-title, .hljs-section, .hljs-name, .hljs-selector-id, .hljs-selector-class { color: #7fb8f5; }
|
|
108
|
-
.hljs-type, .hljs-meta, .hljs-deletion { color: #f28b82; }
|
|
109
|
-
}
|
|
134
|
+
.hljs-keyword, .hljs-selector-tag, .hljs-built_in, .hljs-literal { color: var(--purple); }
|
|
135
|
+
.hljs-string, .hljs-attr, .hljs-template-tag, .hljs-addition { color: var(--green); }
|
|
136
|
+
.hljs-number, .hljs-symbol, .hljs-variable, .hljs-template-variable { color: var(--orange); }
|
|
137
|
+
.hljs-title, .hljs-section, .hljs-name, .hljs-selector-id, .hljs-selector-class { color: var(--blue); }
|
|
138
|
+
.hljs-type, .hljs-meta, .hljs-deletion { color: var(--red); }
|
|
110
139
|
|
|
111
|
-
@media (prefers-reduced-motion: reduce) { .progress-fill { transition: none; } }
|
|
140
|
+
@media (prefers-reduced-motion: reduce) { .progress-fill, .copy-code { transition: none; } }
|
|
112
141
|
@media print {
|
|
113
|
-
.progress { display: none; }
|
|
142
|
+
.progress, .copy-code { display: none; }
|
|
114
143
|
body { background: #fff; color: #000; }
|
|
115
144
|
main { padding-top: 0; }
|
|
116
145
|
a { color: inherit; }
|
|
146
|
+
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
147
|
+
pre, table, blockquote, .callout, img { break-inside: avoid; }
|
|
148
|
+
h1, h2, h3, h4, h5, h6 { break-after: avoid; }
|
|
117
149
|
}
|
package/src/template/page.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/*
|
|
2
|
-
Runs inside every page that has a checklist, and inside every
|
|
3
|
-
The build step defines mdPress before this script: a storage
|
|
4
|
-
object when the page comes from md-press serve.
|
|
2
|
+
Runs inside every page that has a checklist or a code block, and inside every
|
|
3
|
+
served page. The build step defines mdPress before this script: a storage
|
|
4
|
+
key, plus a live object when the page comes from md-press serve.
|
|
5
|
+
|
|
6
|
+
Code blocks get a Copy button. Checkboxes get labels and a progress bar.
|
|
5
7
|
|
|
6
8
|
A built page is static, so checkbox state is saved in this browser only, keyed
|
|
7
9
|
by label text (plus a counter for duplicate labels) so edits to the file do
|
|
@@ -41,16 +43,57 @@ storage call fails quietly: the page still works, it just forgets more.
|
|
|
41
43
|
}
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
function itemFor(checkbox) {
|
|
47
|
+
return checkbox.closest("li") || checkbox.parentElement;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/*
|
|
51
|
+
The item's own text, without any nested list, so renaming a sub-item does
|
|
52
|
+
not reset the parent's saved state.
|
|
53
|
+
*/
|
|
54
|
+
function labelFor(checkbox) {
|
|
55
|
+
const item = itemFor(checkbox);
|
|
56
|
+
let text = "";
|
|
57
|
+
Array.from(item.childNodes).forEach(function (node) {
|
|
58
|
+
if (node.nodeName !== "UL" && node.nodeName !== "OL") text += node.textContent;
|
|
59
|
+
});
|
|
60
|
+
return text.trim().replace(/\s+/g, " ");
|
|
61
|
+
}
|
|
62
|
+
|
|
44
63
|
function updateProgress() {
|
|
45
64
|
if (!progressFill || checkboxes.length === 0) return;
|
|
46
65
|
const doneCount = checkboxes.filter(function (checkbox) { return checkbox.checked; }).length;
|
|
47
|
-
checkboxes.forEach(function (checkbox) { checkbox.
|
|
66
|
+
checkboxes.forEach(function (checkbox) { itemFor(checkbox).classList.toggle("done", checkbox.checked); });
|
|
48
67
|
progressFill.style.width = (doneCount / checkboxes.length * 100) + "%";
|
|
49
68
|
progressLabel.textContent = doneCount + " of " + checkboxes.length + " done";
|
|
50
69
|
}
|
|
51
70
|
|
|
52
|
-
function
|
|
53
|
-
|
|
71
|
+
function setupCopyButtons() {
|
|
72
|
+
document.querySelectorAll("pre > code").forEach(function (code) {
|
|
73
|
+
const button = document.createElement("button");
|
|
74
|
+
button.type = "button";
|
|
75
|
+
button.className = "copy-code";
|
|
76
|
+
button.textContent = "Copy";
|
|
77
|
+
button.setAttribute("aria-label", "Copy code");
|
|
78
|
+
button.addEventListener("click", async function () {
|
|
79
|
+
try {
|
|
80
|
+
await navigator.clipboard.writeText(code.textContent);
|
|
81
|
+
button.textContent = "Copied";
|
|
82
|
+
} catch (_error) {
|
|
83
|
+
const range = document.createRange();
|
|
84
|
+
range.selectNodeContents(code);
|
|
85
|
+
getSelection().removeAllRanges();
|
|
86
|
+
getSelection().addRange(range);
|
|
87
|
+
button.textContent = "Selected, press copy";
|
|
88
|
+
}
|
|
89
|
+
button.classList.add("copied");
|
|
90
|
+
setTimeout(function () {
|
|
91
|
+
button.textContent = "Copy";
|
|
92
|
+
button.classList.remove("copied");
|
|
93
|
+
}, 1500);
|
|
94
|
+
});
|
|
95
|
+
code.parentElement.appendChild(button);
|
|
96
|
+
});
|
|
54
97
|
}
|
|
55
98
|
|
|
56
99
|
function setupStoredChecklist() {
|
|
@@ -160,8 +203,9 @@ storage call fails quietly: the page still works, it just forgets more.
|
|
|
160
203
|
}, 1500);
|
|
161
204
|
}
|
|
162
205
|
|
|
206
|
+
setupCopyButtons();
|
|
163
207
|
checkboxes.forEach(function (checkbox) { checkbox.setAttribute("aria-label", labelFor(checkbox)); });
|
|
164
208
|
if (mdPress.live) setupLiveFile();
|
|
165
|
-
else setupStoredChecklist();
|
|
209
|
+
else if (checkboxes.length > 0) setupStoredChecklist();
|
|
166
210
|
updateProgress();
|
|
167
211
|
})();
|