latex-stickies 1.0.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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +113 -0
  3. package/bin/latex-stickies.js +36 -0
  4. package/build/icon.icns +0 -0
  5. package/build/icon.png +0 -0
  6. package/package.json +76 -0
  7. package/src/main.js +308 -0
  8. package/src/preload.js +27 -0
  9. package/src/renderer/editor.js +201 -0
  10. package/src/renderer/markdown.js +154 -0
  11. package/src/renderer/note.css +288 -0
  12. package/src/renderer/note.html +34 -0
  13. package/src/renderer/note.js +210 -0
  14. package/src/renderer/vendor/fonts/KaTeX_AMS-Regular.ttf +0 -0
  15. package/src/renderer/vendor/fonts/KaTeX_AMS-Regular.woff +0 -0
  16. package/src/renderer/vendor/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  17. package/src/renderer/vendor/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  18. package/src/renderer/vendor/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  19. package/src/renderer/vendor/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  20. package/src/renderer/vendor/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  21. package/src/renderer/vendor/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  22. package/src/renderer/vendor/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  23. package/src/renderer/vendor/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  24. package/src/renderer/vendor/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  25. package/src/renderer/vendor/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  26. package/src/renderer/vendor/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  27. package/src/renderer/vendor/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  28. package/src/renderer/vendor/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  29. package/src/renderer/vendor/fonts/KaTeX_Main-Bold.ttf +0 -0
  30. package/src/renderer/vendor/fonts/KaTeX_Main-Bold.woff +0 -0
  31. package/src/renderer/vendor/fonts/KaTeX_Main-Bold.woff2 +0 -0
  32. package/src/renderer/vendor/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  33. package/src/renderer/vendor/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  34. package/src/renderer/vendor/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  35. package/src/renderer/vendor/fonts/KaTeX_Main-Italic.ttf +0 -0
  36. package/src/renderer/vendor/fonts/KaTeX_Main-Italic.woff +0 -0
  37. package/src/renderer/vendor/fonts/KaTeX_Main-Italic.woff2 +0 -0
  38. package/src/renderer/vendor/fonts/KaTeX_Main-Regular.ttf +0 -0
  39. package/src/renderer/vendor/fonts/KaTeX_Main-Regular.woff +0 -0
  40. package/src/renderer/vendor/fonts/KaTeX_Main-Regular.woff2 +0 -0
  41. package/src/renderer/vendor/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  42. package/src/renderer/vendor/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  43. package/src/renderer/vendor/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  44. package/src/renderer/vendor/fonts/KaTeX_Math-Italic.ttf +0 -0
  45. package/src/renderer/vendor/fonts/KaTeX_Math-Italic.woff +0 -0
  46. package/src/renderer/vendor/fonts/KaTeX_Math-Italic.woff2 +0 -0
  47. package/src/renderer/vendor/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  48. package/src/renderer/vendor/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  49. package/src/renderer/vendor/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  50. package/src/renderer/vendor/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  51. package/src/renderer/vendor/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  52. package/src/renderer/vendor/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  53. package/src/renderer/vendor/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  54. package/src/renderer/vendor/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  55. package/src/renderer/vendor/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  56. package/src/renderer/vendor/fonts/KaTeX_Script-Regular.ttf +0 -0
  57. package/src/renderer/vendor/fonts/KaTeX_Script-Regular.woff +0 -0
  58. package/src/renderer/vendor/fonts/KaTeX_Script-Regular.woff2 +0 -0
  59. package/src/renderer/vendor/fonts/KaTeX_Size1-Regular.ttf +0 -0
  60. package/src/renderer/vendor/fonts/KaTeX_Size1-Regular.woff +0 -0
  61. package/src/renderer/vendor/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  62. package/src/renderer/vendor/fonts/KaTeX_Size2-Regular.ttf +0 -0
  63. package/src/renderer/vendor/fonts/KaTeX_Size2-Regular.woff +0 -0
  64. package/src/renderer/vendor/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  65. package/src/renderer/vendor/fonts/KaTeX_Size3-Regular.ttf +0 -0
  66. package/src/renderer/vendor/fonts/KaTeX_Size3-Regular.woff +0 -0
  67. package/src/renderer/vendor/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  68. package/src/renderer/vendor/fonts/KaTeX_Size4-Regular.ttf +0 -0
  69. package/src/renderer/vendor/fonts/KaTeX_Size4-Regular.woff +0 -0
  70. package/src/renderer/vendor/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  71. package/src/renderer/vendor/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  72. package/src/renderer/vendor/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  73. package/src/renderer/vendor/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  74. package/src/renderer/vendor/katex.min.css +1 -0
  75. package/src/renderer/vendor/katex.min.js +1 -0
  76. package/src/renderer/vendor/marked.js +80 -0
  77. package/src/renderer/vendor/prism.js +2009 -0
  78. package/src/renderer/vendor/purify.js +3 -0
  79. package/src/store.js +116 -0
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Editor conveniences for the note textarea: auto-indent, Tab handling,
3
+ * bracket pairing and list continuation.
4
+ *
5
+ * The behavior is context-aware. Inside a ``` fence the textarea acts like a
6
+ * code editor; outside one it acts like a notes field. That split matters --
7
+ * indenting after a trailing colon is right in Python and wrong after "Note:",
8
+ * and auto-closing a quote is right in code and infuriating in "don't".
9
+ *
10
+ * Every rule here is a pure function returning an edit descriptor
11
+ * ({ from, to, insert, selStart, selEnd }) rather than touching the DOM, so the
12
+ * logic is testable on its own. applyEdit() is the only part that writes, and
13
+ * it goes through execCommand('insertText') so Cmd+Z still walks back through
14
+ * the changes one at a time -- assigning to textarea.value would wipe the
15
+ * browser's native undo stack.
16
+ */
17
+
18
+ const INDENT = ' '; // two spaces: notes are narrow
19
+
20
+ const PAIRS = { '(': ')', '[': ']', '{': '}', '"': '"', "'": "'", '`': '`', $: '$' };
21
+ // Quotes are excluded outside fences: apostrophes in ordinary prose are far
22
+ // more common than quoted pairs. `$` stays, because math is the point here.
23
+ const PROSE_PAIRS = new Set(['(', '[', '{', '$']);
24
+ const CLOSERS = new Set(Object.values(PAIRS));
25
+
26
+ const lineStart = (v, i) => v.lastIndexOf('\n', i - 1) + 1;
27
+ const lineEnd = (v, i) => {
28
+ const n = v.indexOf('\n', i);
29
+ return n === -1 ? v.length : n;
30
+ };
31
+ const indentOf = (line) => (/^[ \t]*/.exec(line) || [''])[0];
32
+
33
+ /** Is the caret inside a fenced code block? Odd number of fences above it. */
34
+ function insideFence(value, pos) {
35
+ const before = value.slice(0, pos);
36
+ const fences = before.match(/^[ \t]*```/gm);
37
+ return !!fences && fences.length % 2 === 1;
38
+ }
39
+
40
+ /* ---------- Enter ---------- */
41
+
42
+ const OPENS_BLOCK = /[:{[(]\s*$/;
43
+ const LIST_ITEM = /^([ \t]*)([-*+] \[[ xX]\] |[-*+] |\d+[.)] )(.*)$/;
44
+
45
+ function onEnter(value, start, end) {
46
+ const ls = lineStart(value, start);
47
+ const line = value.slice(ls, start);
48
+
49
+ if (insideFence(value, start)) {
50
+ const indent = indentOf(line);
51
+ const deeper = OPENS_BLOCK.test(line);
52
+ const inner = deeper ? indent + INDENT : indent;
53
+ const after = value.slice(end, end + 1);
54
+
55
+ // Caret sits between a just-opened pair: put the closer on its own line,
56
+ // the way an editor does for `{|}`.
57
+ if (deeper && CLOSERS.has(after)) {
58
+ return {
59
+ from: start, to: end,
60
+ insert: `\n${inner}\n${indent}`,
61
+ selStart: start + 1 + inner.length,
62
+ };
63
+ }
64
+ return { from: start, to: end, insert: `\n${inner}` };
65
+ }
66
+
67
+ // Outside a fence: carry markdown lists onward.
68
+ const item = LIST_ITEM.exec(value.slice(ls, lineEnd(value, start)));
69
+ if (!item) return null;
70
+
71
+ const [, indent, marker, content] = item;
72
+
73
+ // Enter on an empty item ends the list rather than making another bullet.
74
+ if (!content.trim()) {
75
+ return { from: ls, to: end, insert: indent };
76
+ }
77
+
78
+ let next = marker;
79
+ const ordered = /^(\d+)([.)] )$/.exec(marker);
80
+ if (ordered) next = `${Number(ordered[1]) + 1}${ordered[2]}`;
81
+ else if (/\[[xX]\]/.test(marker)) next = marker.replace(/\[[xX]\]/, '[ ]');
82
+
83
+ return { from: start, to: end, insert: `\n${indent}${next}` };
84
+ }
85
+
86
+ /* ---------- Tab ---------- */
87
+
88
+ function onTab(value, start, end, shift) {
89
+ const ls = lineStart(value, start);
90
+ const le = lineEnd(value, end);
91
+
92
+ // A selection spanning lines shifts the whole block.
93
+ if (start !== end && value.slice(start, end).includes('\n')) {
94
+ const lines = value.slice(ls, le).split('\n');
95
+ const shifted = lines.map((l) =>
96
+ shift ? l.replace(new RegExp(`^( {1,${INDENT.length}}|\t)`), '') : INDENT + l);
97
+ const insert = shifted.join('\n');
98
+ return { from: ls, to: le, insert, selStart: ls, selEnd: ls + insert.length };
99
+ }
100
+
101
+ if (shift) {
102
+ const line = value.slice(ls, le);
103
+ const removed = /^( {1,2}|\t)/.exec(line);
104
+ if (!removed) return null;
105
+ const n = removed[0].length;
106
+ return {
107
+ from: ls, to: ls + n, insert: '',
108
+ selStart: Math.max(ls, start - n), selEnd: Math.max(ls, end - n),
109
+ };
110
+ }
111
+
112
+ return { from: start, to: end, insert: INDENT };
113
+ }
114
+
115
+ /* ---------- brackets and quotes ---------- */
116
+
117
+ function onPair(value, start, end, ch) {
118
+ const close = PAIRS[ch];
119
+ if (!close) return null;
120
+
121
+ const inFence = insideFence(value, start);
122
+ if (!inFence && !PROSE_PAIRS.has(ch)) return null;
123
+
124
+ // Wrapping a selection always makes sense, in code or prose.
125
+ if (start !== end) {
126
+ const selected = value.slice(start, end);
127
+ return {
128
+ from: start, to: end,
129
+ insert: ch + selected + close,
130
+ selStart: start + 1, selEnd: end + 1,
131
+ };
132
+ }
133
+
134
+ // Don't auto-close when it would run into a word: `foo|bar` typing `(`.
135
+ const after = value.slice(end, end + 1);
136
+ if (after && !/[\s)\]}>,.;:]/.test(after)) return null;
137
+
138
+ // For symmetric marks, a closer is more likely than a new pair when one is
139
+ // already open on this line.
140
+ if (ch === close) {
141
+ const line = value.slice(lineStart(value, start), start);
142
+ const count = line.split(ch).length - 1;
143
+ if (count % 2 === 1) return null;
144
+ }
145
+
146
+ return { from: start, to: end, insert: ch + close, selStart: start + 1 };
147
+ }
148
+
149
+ /** Typing the closer that's already sitting there just steps over it. */
150
+ function onCloser(value, start, end, ch) {
151
+ if (start !== end || !CLOSERS.has(ch)) return null;
152
+ if (value.slice(end, end + 1) !== ch) return null;
153
+ return { from: start, to: end, insert: '', selStart: start + 1 };
154
+ }
155
+
156
+ /** Backspace between an empty pair removes both halves. */
157
+ function onBackspace(value, start, end) {
158
+ if (start !== end || start === 0) return null;
159
+ const before = value[start - 1];
160
+ const after = value[start];
161
+ if (PAIRS[before] && PAIRS[before] === after) {
162
+ return { from: start - 1, to: start + 1, insert: '', selStart: start - 1 };
163
+ }
164
+ return null;
165
+ }
166
+
167
+ /* ---------- dispatch ---------- */
168
+
169
+ function computeEdit(e, value, start, end) {
170
+ if (e.metaKey || e.ctrlKey || e.altKey) return null;
171
+
172
+ if (e.key === 'Enter') return onEnter(value, start, end);
173
+ if (e.key === 'Tab') return onTab(value, start, end, e.shiftKey);
174
+ if (e.key === 'Backspace') return onBackspace(value, start, end);
175
+ if (e.key.length === 1) {
176
+ return onCloser(value, start, end, e.key) || onPair(value, start, end, e.key);
177
+ }
178
+ return null;
179
+ }
180
+
181
+ /* ---------- the one part that writes ---------- */
182
+
183
+ function applyEdit(el, edit) {
184
+ el.setSelectionRange(edit.from, edit.to);
185
+ // Keeps the change on the native undo stack, unlike assigning to .value.
186
+ if (!document.execCommand('insertText', false, edit.insert)) {
187
+ el.setRangeText(edit.insert, edit.from, edit.to, 'end');
188
+ }
189
+ const selStart = edit.selStart ?? edit.from + edit.insert.length;
190
+ el.setSelectionRange(selStart, edit.selEnd ?? selStart);
191
+ }
192
+
193
+ function attachEditorBehaviors(el) {
194
+ el.addEventListener('keydown', (e) => {
195
+ const edit = computeEdit(e, el.value, el.selectionStart, el.selectionEnd);
196
+ if (!edit) return;
197
+ e.preventDefault();
198
+ applyEdit(el, edit);
199
+ el.dispatchEvent(new Event('input', { bubbles: true })); // triggers the save
200
+ });
201
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Markdown + LaTeX rendering.
3
+ *
4
+ * Math is registered as marked extensions rather than pre-extracted with a
5
+ * regex, so marked's own tokenizer claims code spans and fences first and
6
+ * `$x$` inside `code` stays literal text.
7
+ *
8
+ * Each math token renders to a placeholder, not to KaTeX HTML directly. That
9
+ * lets DOMPurify run over the markdown output — which may contain raw HTML
10
+ * typed into the note — before the trusted KaTeX markup is spliced in. KaTeX's
11
+ * own output is deliberately not sanitized: it is complex MathML that a
12
+ * sanitizer would mangle, and it is generated locally with trust disabled.
13
+ */
14
+
15
+ const mathChunks = [];
16
+
17
+ const inlineMath = {
18
+ name: 'inlineMath',
19
+ level: 'inline',
20
+ start(src) {
21
+ return src.indexOf('$');
22
+ },
23
+ tokenizer(src) {
24
+ // A single `$`, no newline inside, `\$` escapes out.
25
+ //
26
+ // The delimiters must hug their content: no space after the opening `$`,
27
+ // none before the closing one. That is the usual CommonMark-math rule and
28
+ // it is what keeps prose like "costs $5 and $6 today" from being swallowed
29
+ // as a formula, since the closing candidate there sits after a space.
30
+ const match = /^\$(?!\s)((?:\\.|[^$\\\n])*?[^\s\\]|[^\s$\\])\$(?!\d)/.exec(src);
31
+ if (!match) return;
32
+ return { type: 'inlineMath', raw: match[0], tex: match[1] };
33
+ },
34
+ renderer(token) {
35
+ return placeholder(token.tex, false);
36
+ },
37
+ };
38
+
39
+ const blockMath = {
40
+ name: 'blockMath',
41
+ level: 'block',
42
+ start(src) {
43
+ return src.indexOf('$$');
44
+ },
45
+ tokenizer(src) {
46
+ const match = /^\s*\$\$([\s\S]+?)\$\$\s*(?:\n|$)/.exec(src);
47
+ if (!match) return;
48
+ return { type: 'blockMath', raw: match[0], tex: match[1].trim() };
49
+ },
50
+ renderer(token) {
51
+ return placeholder(token.tex, true);
52
+ },
53
+ };
54
+
55
+ function placeholder(tex, display) {
56
+ mathChunks.push({ tex, display });
57
+ const tag = display ? 'div' : 'span';
58
+ return `<${tag} data-math="${mathChunks.length - 1}"></${tag}>`;
59
+ }
60
+
61
+ const escapeHtml = (s) =>
62
+ s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
63
+
64
+ // What people actually type on a fence, mapped to Prism's grammar names.
65
+ const LANG_ALIASES = {
66
+ sh: 'bash', shell: 'bash', zsh: 'bash', console: 'bash',
67
+ py: 'python', js: 'javascript', ts: 'typescript', yml: 'yaml',
68
+ rb: 'ruby', 'c++': 'cpp', tex: 'latex', md: 'markdown',
69
+ html: 'markup', xml: 'markup', tsx: 'jsx',
70
+ };
71
+
72
+ function resolveLang(info) {
73
+ // marked hands over the whole info string, e.g. "python title=demo".
74
+ const raw = (info || '').trim().split(/\s+/)[0].toLowerCase();
75
+ if (!raw) return null;
76
+ const name = LANG_ALIASES[raw] || raw;
77
+ return Prism.languages[name] ? name : null;
78
+ }
79
+
80
+ /**
81
+ * Fenced code blocks. A recognised language gets highlighted; anything else --
82
+ * an unknown language, or a bare ``` with none -- falls back to escaped plain
83
+ * text. Bare fences are deliberately not auto-detected: guessing on a two-line
84
+ * snippet is wrong often enough to be worse than nothing.
85
+ */
86
+ function renderCode(token) {
87
+ const lang = resolveLang(token.lang);
88
+ if (!lang) {
89
+ return `<pre><code>${escapeHtml(token.text)}</code></pre>\n`;
90
+ }
91
+ const body = Prism.highlight(token.text, Prism.languages[lang], lang);
92
+ return `<pre data-lang="${lang}"><code class="language-${lang}">${body}</code></pre>\n`;
93
+ }
94
+
95
+ marked.use({
96
+ gfm: true,
97
+ breaks: true, // single newlines are line breaks; notes are not prose documents
98
+ extensions: [inlineMath, blockMath],
99
+ renderer: { code: renderCode },
100
+ });
101
+
102
+ function renderMath(chunk) {
103
+ try {
104
+ return katex.renderToString(chunk.tex, {
105
+ displayMode: chunk.display,
106
+ throwOnError: true,
107
+ strict: false,
108
+ trust: false,
109
+ });
110
+ } catch (err) {
111
+ const label = escapeHtml(chunk.display ? `$$${chunk.tex}$$` : `$${chunk.tex}$`);
112
+ return `<span class="math-error" title="${escapeHtml(err.message)}">${label}</span>`;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Renders note text into `target`.
118
+ *
119
+ * Math is spliced in as a DOM operation, never by string replacement. An
120
+ * earlier version substituted `@@MATH0@@` markers in the sanitized HTML string
121
+ * and assigned the result to innerHTML -- but a note could put that marker
122
+ * inside an attribute (`<img title="@@MATH0@@">`), and the KaTeX markup, which
123
+ * carries its own quotes and angle brackets, then broke out of the attribute
124
+ * when the string was re-parsed. That produced live elements DOMPurify had
125
+ * never seen, including an <img> pointing anywhere the author liked. Filling
126
+ * placeholder *elements* after sanitizing keeps every insertion scoped to one
127
+ * node, so nothing can escape into the surrounding markup.
128
+ */
129
+ function renderInto(target, src) {
130
+ mathChunks.length = 0;
131
+
132
+ target.innerHTML = DOMPurify.sanitize(marked.parse(src), {
133
+ ADD_ATTR: ['target'],
134
+ // `input` stays allowed so GFM task lists keep their checkboxes; DOMPurify
135
+ // strips event handlers and dangerous attributes from it regardless.
136
+ FORBID_TAGS: ['style', 'form', 'button', 'iframe', 'object', 'embed'],
137
+ });
138
+
139
+ target.querySelectorAll('[data-math]').forEach((slot) => {
140
+ const chunk = mathChunks[Number(slot.dataset.math)];
141
+ // KaTeX output is generated locally with trust disabled, and lands inside
142
+ // this one element -- it cannot alter the structure around it.
143
+ if (chunk) slot.innerHTML = renderMath(chunk);
144
+ else slot.remove();
145
+ });
146
+ }
147
+
148
+ /** Markdown source -> HTML string. Serialized from a real DOM, so attribute
149
+ * values are escaped and re-parsing yields the same tree. */
150
+ function renderMarkdown(src) {
151
+ const scratch = document.createElement('div');
152
+ renderInto(scratch, src);
153
+ return scratch.innerHTML;
154
+ }
@@ -0,0 +1,288 @@
1
+ :root {
2
+ --paper: #fdf3a8;
3
+ --paper-edge: #f2e07a;
4
+ --ink: #2b2a1f;
5
+ --radius: 10px;
6
+ }
7
+ body[data-color="blue"] { --paper: #cfe6fb; --paper-edge: #b2d5f5; --ink: #1d2c3a; }
8
+ body[data-color="green"] { --paper: #d3f2c8; --paper-edge: #b8e5a8; --ink: #1f3319; }
9
+ body[data-color="pink"] { --paper: #fbd2e2; --paper-edge: #f4b6ce; --ink: #3a1f2b; }
10
+ body[data-color="purple"] { --paper: #e2d6fb; --paper-edge: #cdbaf5; --ink: #2a1f3a; }
11
+ body[data-color="gray"] { --paper: #e6e6e6; --paper-edge: #d2d2d2; --ink: #262626; }
12
+
13
+ * { box-sizing: border-box; }
14
+
15
+ html, body {
16
+ margin: 0;
17
+ height: 100%;
18
+ background: transparent;
19
+ font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
20
+ color: var(--ink);
21
+ overflow: hidden;
22
+ user-select: none;
23
+ }
24
+
25
+ body {
26
+ display: flex;
27
+ flex-direction: column;
28
+ border-radius: var(--radius);
29
+ background: var(--paper);
30
+ border: 1px solid rgba(0, 0, 0, 0.12);
31
+ }
32
+
33
+ #bar {
34
+ position: relative;
35
+ flex: 0 0 26px;
36
+ background: var(--paper-edge);
37
+ border-radius: var(--radius) var(--radius) 0 0;
38
+ border-bottom: 1px solid rgba(0, 0, 0, 0.06);
39
+ }
40
+
41
+ #drag {
42
+ position: absolute;
43
+ inset: 0;
44
+ -webkit-app-region: drag;
45
+ }
46
+
47
+ #tools {
48
+ position: relative;
49
+ height: 100%;
50
+ display: flex;
51
+ align-items: center;
52
+ gap: 6px;
53
+ padding: 0 8px;
54
+ }
55
+
56
+ /* Hidden with `visibility`, not `opacity`: a transparent button still takes
57
+ clicks, which meant clicking the title bar to drag a note could silently
58
+ toggle the pin underneath the cursor. */
59
+ #tools > * { visibility: hidden; }
60
+
61
+ /* Shown while the window is focused, not only while the body is hovered.
62
+ The title bar is a -webkit-app-region drag surface, which swallows mouse
63
+ events, so body:hover drops the moment the cursor reaches the bar -- the
64
+ controls would vanish exactly as you reached for them. Focus is unaffected
65
+ by the drag region. */
66
+ body.focused #tools > *,
67
+ body:hover #tools > *,
68
+ body.editing #tools > * { visibility: visible; }
69
+
70
+ /* A pinned note must advertise it even when the toolbar is otherwise hidden. */
71
+ #pin.on { visibility: visible; }
72
+
73
+ #tools button {
74
+ -webkit-app-region: no-drag;
75
+ border: 0;
76
+ background: transparent;
77
+ color: var(--ink);
78
+ opacity: 0.5;
79
+ font-size: 12px;
80
+ line-height: 1;
81
+ padding: 3px 4px;
82
+ border-radius: 4px;
83
+ cursor: default;
84
+ }
85
+ #tools button:hover { opacity: 1; background: rgba(0, 0, 0, 0.07); }
86
+ #pin.on {
87
+ opacity: 1;
88
+ color: #b3402b;
89
+ background: rgba(179, 64, 43, 0.16);
90
+ }
91
+
92
+ #swatches { display: flex; gap: 4px; margin-left: auto; margin-right: auto; }
93
+ #swatches span {
94
+ -webkit-app-region: no-drag;
95
+ width: 11px;
96
+ height: 11px;
97
+ border-radius: 50%;
98
+ border: 1px solid rgba(0, 0, 0, 0.18);
99
+ cursor: default;
100
+ }
101
+ #swatches span.active { outline: 1.5px solid rgba(0, 0, 0, 0.5); outline-offset: 1px; }
102
+
103
+ #surface {
104
+ flex: 1;
105
+ position: relative;
106
+ overflow: auto;
107
+ }
108
+
109
+ #editor, #preview {
110
+ width: 100%;
111
+ min-height: 100%;
112
+ padding: 10px 13px 22px;
113
+ font-size: var(--fs, 15px);
114
+ line-height: 1.55;
115
+ user-select: text;
116
+ }
117
+
118
+ #editor {
119
+ position: absolute;
120
+ inset: 0;
121
+ display: none;
122
+ border: 0;
123
+ outline: 0;
124
+ resize: none;
125
+ background: transparent;
126
+ color: var(--ink);
127
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
128
+ font-size: calc(var(--fs, 15px) - 1px);
129
+ }
130
+
131
+ body.editing #editor { display: block; }
132
+ body.editing #preview { visibility: hidden; }
133
+
134
+ #preview { word-break: break-word; }
135
+ #preview > *:first-child { margin-top: 0; }
136
+ #preview > *:last-child { margin-bottom: 0; }
137
+ #preview p { margin: 0 0 0.55em; }
138
+ #preview h1 { font-size: 1.35em; margin: 0.2em 0 0.3em; }
139
+ #preview h2 { font-size: 1.18em; margin: 0.2em 0 0.3em; }
140
+ #preview h3 { font-size: 1.05em; margin: 0.2em 0 0.3em; }
141
+ #preview code {
142
+ font-family: ui-monospace, Menlo, monospace;
143
+ font-size: 0.88em;
144
+ background: rgba(0, 0, 0, 0.07);
145
+ padding: 1px 4px;
146
+ border-radius: 3px;
147
+ }
148
+ #preview a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
149
+
150
+ #preview h4, #preview h5, #preview h6 { font-size: 1em; margin: 0.2em 0 0.3em; }
151
+
152
+ #preview blockquote {
153
+ margin: 0.4em 0;
154
+ padding-left: 0.7em;
155
+ border-left: 2px solid rgba(0, 0, 0, 0.22);
156
+ opacity: 0.85;
157
+ }
158
+
159
+ /* The wrapper anchors the label and copy button; the <pre> inside it scrolls,
160
+ so anything positioned against the <pre> would scroll out of view. */
161
+ #preview .code-block { position: relative; margin: 0.5em 0; }
162
+
163
+ #preview pre {
164
+ margin: 0;
165
+ padding: 7px 9px;
166
+ background: rgba(0, 0, 0, 0.07);
167
+ border-radius: 5px;
168
+ overflow-x: auto;
169
+ }
170
+ #preview pre code { background: none; padding: 0; font-size: 0.85em; }
171
+
172
+ /* Label and button share the top-right slot and cross-fade, so reaching for
173
+ Copy doesn't shift anything. */
174
+ #preview .code-lang,
175
+ #preview .code-copy {
176
+ position: absolute;
177
+ top: 4px;
178
+ right: 6px;
179
+ font-family: ui-monospace, Menlo, monospace;
180
+ font-size: 10px;
181
+ letter-spacing: 0.02em;
182
+ transition: opacity 0.12s ease;
183
+ }
184
+
185
+ #preview .code-lang {
186
+ color: var(--ink);
187
+ opacity: 0.34;
188
+ pointer-events: none;
189
+ }
190
+
191
+ #preview .code-copy {
192
+ border: 0;
193
+ border-radius: 3px;
194
+ padding: 2px 6px;
195
+ background: rgba(0, 0, 0, 0.1);
196
+ color: var(--ink);
197
+ opacity: 0;
198
+ cursor: default;
199
+ }
200
+ #preview .code-block:hover .code-lang { opacity: 0; }
201
+ #preview .code-block:hover .code-copy { opacity: 0.62; }
202
+ #preview .code-copy:hover { opacity: 1; background: rgba(0, 0, 0, 0.16); }
203
+ #preview .code-copy.done { opacity: 1; background: rgba(47, 107, 74, 0.2); }
204
+
205
+ /* Syntax colors.
206
+ Notes come in six pastel papers, so every token is dark and desaturated --
207
+ a stock editor theme assumes one fixed background and washes out on yellow
208
+ or pink. Contrast holds on all six. */
209
+ #preview .token.comment,
210
+ #preview .token.prolog,
211
+ #preview .token.doctype,
212
+ #preview .token.cdata { color: var(--ink); opacity: 0.5; font-style: italic; }
213
+
214
+ #preview .token.punctuation { color: var(--ink); opacity: 0.62; }
215
+
216
+ #preview .token.keyword,
217
+ #preview .token.rule,
218
+ #preview .token.important,
219
+ #preview .token.atrule { color: #8a2f6b; font-weight: 500; }
220
+
221
+ #preview .token.string,
222
+ #preview .token.char,
223
+ #preview .token.attr-value,
224
+ #preview .token.regex { color: #1f6b3a; }
225
+
226
+ #preview .token.number,
227
+ #preview .token.boolean,
228
+ #preview .token.constant,
229
+ #preview .token.symbol { color: #a0522d; }
230
+
231
+ #preview .token.function,
232
+ #preview .token.class-name,
233
+ #preview .token.tag { color: #1b3a6b; }
234
+
235
+ #preview .token.property,
236
+ #preview .token.attr-name,
237
+ #preview .token.variable,
238
+ #preview .token.parameter { color: #6b4a1f; }
239
+
240
+ #preview .token.operator,
241
+ #preview .token.entity,
242
+ #preview .token.url { color: var(--ink); opacity: 0.78; }
243
+
244
+ #preview .token.deleted { color: #a01f1f; }
245
+ #preview .token.inserted { color: #1f6b3a; }
246
+ #preview .token.italic { font-style: italic; }
247
+ #preview .token.bold { font-weight: 600; }
248
+
249
+ #preview ul, #preview ol { margin: 0.35em 0; padding-left: 1.35em; }
250
+ #preview li { margin: 0.12em 0; }
251
+ #preview li::marker { opacity: 0.55; }
252
+ /* marked emits a bare <li> with a checkbox, no class, so match on content. */
253
+ #preview li:has(> input[type="checkbox"]) { list-style: none; margin-left: -1.15em; }
254
+ #preview li > input[type="checkbox"] { margin-right: 0.45em; vertical-align: baseline; }
255
+
256
+ #preview hr {
257
+ border: 0;
258
+ border-top: 1px solid rgba(0, 0, 0, 0.18);
259
+ margin: 0.7em 0;
260
+ }
261
+
262
+ #preview table {
263
+ border-collapse: collapse;
264
+ margin: 0.5em 0;
265
+ font-size: 0.93em;
266
+ display: block;
267
+ overflow-x: auto;
268
+ }
269
+ #preview th, #preview td {
270
+ border: 1px solid rgba(0, 0, 0, 0.16);
271
+ padding: 3px 7px;
272
+ text-align: left;
273
+ }
274
+ #preview th { background: rgba(0, 0, 0, 0.05); }
275
+
276
+ #preview del { opacity: 0.6; }
277
+ #preview img { max-width: 100%; border-radius: 4px; }
278
+ #preview .katex-display { margin: 0.5em 0; overflow-x: auto; overflow-y: hidden; }
279
+ #preview .math-error {
280
+ color: #a01f1f;
281
+ font-family: ui-monospace, Menlo, monospace;
282
+ font-size: 0.85em;
283
+ text-decoration: underline wavy rgba(160, 31, 31, 0.5);
284
+ }
285
+ #preview .placeholder { opacity: 0.4; }
286
+
287
+ ::-webkit-scrollbar { width: 8px; }
288
+ ::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.16); border-radius: 4px; }
@@ -0,0 +1,34 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta http-equiv="Content-Security-Policy"
6
+ content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: https:;" />
7
+ <link rel="stylesheet" href="vendor/katex.min.css" />
8
+ <link rel="stylesheet" href="note.css" />
9
+ <title>Sticky</title>
10
+ </head>
11
+ <body>
12
+ <header id="bar">
13
+ <div id="drag"></div>
14
+ <div id="tools">
15
+ <button id="pin" title="Always on top (Cmd+T)">✛</button>
16
+ <div id="swatches"></div>
17
+ <button id="add" title="New note (Cmd+N)">+</button>
18
+ <button id="close" title="Close (Cmd+W)">✕</button>
19
+ </div>
20
+ </header>
21
+ <main id="surface">
22
+ <div id="preview"></div>
23
+ <textarea id="editor" spellcheck="false"
24
+ placeholder="Markdown + LaTeX. Inline math $…$, display math $$…$$"></textarea>
25
+ </main>
26
+ <script src="vendor/katex.min.js"></script>
27
+ <script src="vendor/marked.js"></script>
28
+ <script src="vendor/purify.js"></script>
29
+ <script src="vendor/prism.js"></script>
30
+ <script src="markdown.js"></script>
31
+ <script src="editor.js"></script>
32
+ <script src="note.js"></script>
33
+ </body>
34
+ </html>