Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5757f06e7a | ||
|
|
5f04fe56bc |
@@ -101,6 +101,7 @@ func (s *Server) buildMCP() *mcp.Server {
|
||||
}, "task_id", "body"),
|
||||
}, s.mcpAddComment)
|
||||
|
||||
s.registerNoteTools(m)
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -496,3 +497,176 @@ func clamp(value, fallback, max int) int {
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// --- notes ---------------------------------------------------------------------
|
||||
|
||||
// registerNoteTools adds the note half of the MCP surface.
|
||||
//
|
||||
// Separate from the task tools for one reason worth stating: this is how a note
|
||||
// gets in from somewhere else. Apple Notes has no export worth the name, but a
|
||||
// model running on the machine can read Notes.app and write what it finds through
|
||||
// these — one note at a time, with the text as Markdown. That is a migration
|
||||
// nobody has to build a parser for.
|
||||
func (s *Server) registerNoteTools(m *mcp.Server) {
|
||||
m.Register(mcp.Tool{
|
||||
Name: "search_notes",
|
||||
Description: "Find notes by text. Matches title and body, and is generous about " +
|
||||
"Danish spelling — searching \"gron\" finds \"grøn\", and the other way round. " +
|
||||
"With no query it returns the most recent notes. This is the tool for " +
|
||||
"\"what did I write about the tax return\" or \"find my note on the kitchen\".",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"text": mcp.Str("Words to look for in the title and body."),
|
||||
"limit": mcp.Int("How many to return. Defaults to 25, maximum 100."),
|
||||
}),
|
||||
}, s.mcpSearchNotes)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "create_note",
|
||||
Description: "Write a note. The body is Markdown and is stored as typed — it is " +
|
||||
"also exactly what an export produces, so nothing is converted and nothing " +
|
||||
"is lost. Two things in the text are read out of it and become links: " +
|
||||
"`#Projekt` files the note against that project, so it appears on the " +
|
||||
"project's page without anybody filing it there, and `[[Another note]]` " +
|
||||
"links to a note by its title. Use this to bring notes in from elsewhere — " +
|
||||
"Apple Notes, OneNote, a folder of Markdown — one call per note.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"body": mcp.Str("The note as Markdown. #Projekt and [[note]] become links."),
|
||||
"title": mcp.Str("A title. Left out, the first line of the body becomes it."),
|
||||
"project_id": mcp.Str("File it under a project by id. Usually unnecessary — a #tag in the body does the same and is what a person would type."),
|
||||
"pinned": mcp.Bool("Keep it at the top of the list."),
|
||||
}),
|
||||
}, s.mcpCreateNote)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "update_note",
|
||||
Description: "Change a note. Only the fields given are changed. Sending `body` " +
|
||||
"replaces the whole text, so read the note first if you mean to add to it — " +
|
||||
"and the links are read again from the new text, so a #tag removed is a link " +
|
||||
"removed.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"note_id": mcp.Str("Which note."),
|
||||
"body": mcp.Str("The whole new text, as Markdown."),
|
||||
"title": mcp.Str("A new title."),
|
||||
"pinned": mcp.Bool("Pin it or unpin it."),
|
||||
}),
|
||||
}, s.mcpUpdateNote)
|
||||
}
|
||||
|
||||
func (s *Server) mcpSearchNotes(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||
var params struct {
|
||||
Text string `json:"text"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
_ = json.Unmarshal(args, ¶ms)
|
||||
|
||||
limit := params.Limit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 25
|
||||
}
|
||||
|
||||
var notes []store.Note
|
||||
var err error
|
||||
if strings.TrimSpace(params.Text) == "" {
|
||||
notes, err = s.db.LooseNotes(ctx, userID)
|
||||
} else {
|
||||
notes, err = s.db.SearchNotes(ctx, params.Text, limit)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The same filter the HTTP surface applies, for the same reason: a search must
|
||||
// not become a way to read what you cannot open.
|
||||
out := make([]map[string]any, 0, len(notes))
|
||||
for _, n := range notes {
|
||||
if !s.mcpMayRead(ctx, &n, userID) {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"id": n.ID, "title": n.Title, "body": n.Body,
|
||||
"project_id": n.ProjectID, "updated_at": n.UpdatedAt,
|
||||
})
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return map[string]any{"notes": out, "count": len(out)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) mcpMayRead(ctx context.Context, n *store.Note, userID string) bool {
|
||||
if n.ProjectID == "" {
|
||||
return n.CreatedBy == userID
|
||||
}
|
||||
_, err := store.RequireProjectRole(ctx, s.db, n.ProjectID, userID, store.RoleViewer)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Server) mcpCreateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||
var params struct {
|
||||
Body string `json:"body"`
|
||||
Title string `json:"title"`
|
||||
ProjectID string `json:"project_id"`
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
_ = json.Unmarshal(args, ¶ms)
|
||||
|
||||
n := &store.Note{
|
||||
CreatedBy: userID,
|
||||
Title: params.Title,
|
||||
Body: params.Body,
|
||||
Pinned: params.Pinned,
|
||||
}
|
||||
if params.ProjectID != "" {
|
||||
if _, err := store.RequireProjectRole(ctx, s.db, params.ProjectID, userID, store.RoleEditor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.ProjectID = params.ProjectID
|
||||
}
|
||||
if err := s.db.SaveNote(ctx, n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The links are reported back rather than left silent. A note whose #tag was
|
||||
// misspelt looks identical to one whose tag landed, and in a chat nobody sees
|
||||
// the row that was written.
|
||||
return map[string]any{
|
||||
"id": n.ID, "title": n.Title, "links": n.Links,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) mcpUpdateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||
var params struct {
|
||||
NoteID string `json:"note_id"`
|
||||
Body *string `json:"body"`
|
||||
Title *string `json:"title"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
}
|
||||
_ = json.Unmarshal(args, ¶ms)
|
||||
|
||||
n, err := s.db.Note(ctx, params.NoteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == nil || !s.mcpMayRead(ctx, n, userID) {
|
||||
return nil, fmt.Errorf("no such note")
|
||||
}
|
||||
if n.ProjectID != "" {
|
||||
if _, err := store.RequireProjectRole(ctx, s.db, n.ProjectID, userID, store.RoleEditor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if params.Body != nil {
|
||||
n.Body = *params.Body
|
||||
}
|
||||
if params.Title != nil {
|
||||
n.Title = *params.Title
|
||||
}
|
||||
if params.Pinned != nil {
|
||||
n.Pinned = *params.Pinned
|
||||
}
|
||||
if err := s.db.SaveNote(ctx, n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"id": n.ID, "title": n.Title, "links": n.Links}, nil
|
||||
}
|
||||
|
||||
+50
-1
@@ -1464,7 +1464,10 @@ test('en opgave lukket ved et uheld kan hentes tilbage', async ({ page }) => {
|
||||
await expect(page.getByText('rens tagrenden', { exact: true })).toBeHidden();
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('link', { name: 'Færdige' }).click();
|
||||
// Færdige left the sidebar: it is a rare errand next to four entries read every
|
||||
// day, and it now sits with the other "where did that go" answers.
|
||||
await page.goto('/indstillinger/data');
|
||||
await page.getByRole('link', { name: 'Åbn færdige opgaver' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Færdige', level: 1 })).toBeVisible();
|
||||
|
||||
const closed = page.locator('.row').filter({ hasText: 'rens tagrenden' });
|
||||
@@ -1951,3 +1954,49 @@ test('en note kan skrives, findes igen, og peger på det den nævner', async ({
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('noteeditoren viser formatering, og lagene flugter', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const body = page.getByLabel('Notens tekst');
|
||||
await body.fill(
|
||||
'# Overskrift\n\nDette er **fed** og *kursiv* og `kode`.\n\nHandler om #Firma og [[En anden note]].\n- et punkt\n> et citat'
|
||||
);
|
||||
|
||||
// The marks are still there — this is a mirror, not a renderer — but they are
|
||||
// drawn as what they mean.
|
||||
const mirror = page.locator('.mirror');
|
||||
await expect(mirror.locator('.bold')).toHaveText('**fed**');
|
||||
await expect(mirror.locator('.italic')).toHaveText('*kursiv*');
|
||||
await expect(mirror.locator('.code')).toHaveText('`kode`');
|
||||
await expect(mirror.locator('.tag')).toHaveText('#Firma');
|
||||
await expect(mirror.locator('.wikilink')).toHaveText('[[En anden note]]');
|
||||
await expect(mirror.locator('.h1')).toContainText('Overskrift');
|
||||
|
||||
// The whole risk of the technique: two layers that must line up character for
|
||||
// character. If they drift, the formatting slides away from the text as the
|
||||
// note grows, and it is unusable — so this measures rather than trusts.
|
||||
const drift = await page.evaluate(() => {
|
||||
const ta = document.querySelector('.paper textarea');
|
||||
const mi = document.querySelector('.paper .mirror');
|
||||
const a = getComputedStyle(ta), b = getComputedStyle(mi);
|
||||
return {
|
||||
font: a.fontSize === b.fontSize && a.fontFamily === b.fontFamily,
|
||||
line: a.lineHeight === b.lineHeight,
|
||||
wrap: a.whiteSpace === b.whiteSpace,
|
||||
padding: a.paddingTop === b.paddingTop && a.paddingLeft === b.paddingLeft,
|
||||
// And the rendered height, which is what actually proves it: the same text
|
||||
// laid out twice has to take the same room.
|
||||
heights: Math.abs(ta.scrollHeight - mi.scrollHeight)
|
||||
};
|
||||
});
|
||||
expect(drift.font, 'skrifttypen er ikke ens').toBe(true);
|
||||
expect(drift.line, 'linjehøjden er ikke ens').toBe(true);
|
||||
expect(drift.wrap, 'ombrydningen er ikke ens').toBe(true);
|
||||
expect(drift.padding, 'polstringen er ikke ens').toBe(true);
|
||||
expect(drift.heights, 'lagene fylder ikke det samme').toBeLessThanOrEqual(2);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -369,29 +369,27 @@
|
||||
{t('nav.delegated')}
|
||||
</a>
|
||||
{/if}
|
||||
<!-- After the three views of what is left, because it is the one that is not
|
||||
about what is left. It earns a permanent place rather than a toggle in
|
||||
each view: somebody looking for a task they closed by mistake does not
|
||||
know which view they closed it in. -->
|
||||
<a href="/faerdige" class:active={current === '/faerdige'} onclick={onnavigate}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{t('done.title')}
|
||||
</a>
|
||||
<!-- Færdige is not in this list any more. It used to sit here on the argument
|
||||
that somebody hunting a task they closed by mistake does not know which
|
||||
view they closed it in — which is true, and is also a rare errand next to
|
||||
four entries that are read every day. It lives under Settings → Data, with
|
||||
the trash, where the other "where did that go" questions are answered. -->
|
||||
{#if app.inbox}
|
||||
{@render projectRow(app.inbox, false)}
|
||||
<!-- Notes sit with the fixed places rather than among the projects: it is a
|
||||
|
||||
place you go, not a thing filed under one. -->
|
||||
<a href="/noter" class="fixed" onclick={onnavigate}>
|
||||
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
|
||||
{t('notes.title')}
|
||||
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Notes stand apart, with a rule above them.
|
||||
They are not a fourth way of looking at the same tasks, which is what
|
||||
everything above is; they are the other half of the program. Sitting in that
|
||||
run made them read as one more filter. -->
|
||||
<div class="apart">
|
||||
<a href="/noter" class:active={current === '/noter'} onclick={onnavigate}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{t('notes.title')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- One row for every project the sidebar shows — your own, the Inbox and the
|
||||
shared ones — because all three are somewhere a task can be dropped, and
|
||||
three copies of that would be three chances to fix a bug in two of them.
|
||||
@@ -873,6 +871,17 @@
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
/* A rule and a little air, which is the whole of "separate". Notes are the other
|
||||
half of the program, not a fifth way of looking at the same tasks. */
|
||||
.apart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
margin: var(--s3) 0;
|
||||
padding-top: var(--s3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -318,6 +318,8 @@ export const da = {
|
||||
'data.csvFile': 'CSV-fil',
|
||||
'data.importing': 'Importerer …',
|
||||
'data.openProject': 'Åbn projektet',
|
||||
'data.doneHint': 'Alt du har lukket, nyeste først. Lukkede du noget ved en fejl, ligger det her — og kan åbnes igen.',
|
||||
'data.openDone': 'Åbn færdige opgaver',
|
||||
'data.trash': 'Papirkurv',
|
||||
'data.trashEmpty': 'Papirkurven er tom.',
|
||||
'data.restore': 'Hent tilbage',
|
||||
|
||||
@@ -313,6 +313,8 @@ export const en = {
|
||||
'data.csvFile': 'CSV file',
|
||||
'data.importing': 'Importing …',
|
||||
'data.openProject': 'Open the project',
|
||||
'data.doneHint': 'Everything you have closed, newest first. Closed something by mistake and it is here — and can be opened again.',
|
||||
'data.openDone': 'Open finished tasks',
|
||||
'data.trash': 'Trash',
|
||||
'data.trashEmpty': 'The trash is empty.',
|
||||
'data.restore': 'Restore',
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Just enough Markdown to see what you are writing.
|
||||
*
|
||||
* Not a renderer: the output is the same text, in the same order, with parts
|
||||
* wrapped so they can be styled. That matters because it sits behind a textarea
|
||||
* and has to line up with it character for character — a renderer that dropped
|
||||
* the `**` or reflowed a list would drift out of alignment on the first bold word.
|
||||
*
|
||||
* So the marks stay, dimmed. Obsidian hides them except in the line the cursor is
|
||||
* in; dimming them always is simpler, does not move text under the cursor as it
|
||||
* travels, and still lets you read a document at a glance. It is a smaller idea
|
||||
* that survives contact with a textarea.
|
||||
*/
|
||||
|
||||
const INLINE = [
|
||||
// Order matters: the longer fence first, or `**bold**` is read as two italics.
|
||||
{ kind: 'code', re: /`([^`\n]+)`/g },
|
||||
{ kind: 'bold', re: /\*\*([^*\n]+)\*\*/g },
|
||||
{ kind: 'italic', re: /\*([^*\n]+)\*/g },
|
||||
{ kind: 'tag', re: /(?:^|(?<=\s))#[\p{L}\p{N}_-]{1,64}/gu },
|
||||
{ kind: 'wikilink', re: /\[\[[^\]\n]{1,200}\]\]/g }
|
||||
];
|
||||
|
||||
/**
|
||||
* segmentsOf splits one line into pieces, each with the kind it should be drawn
|
||||
* as. A piece with no kind is ordinary text.
|
||||
*/
|
||||
function inlineSegments(line) {
|
||||
const marks = [];
|
||||
|
||||
// Each pass reads a copy with the earlier passes' characters blanked out.
|
||||
//
|
||||
// Skipping overlaps after the fact is not enough, and the failure is subtle:
|
||||
// after `**fed**` is claimed as bold, a plain overlap check still lets the
|
||||
// italic pass start at the leftover `*` and run to the next one — swallowing
|
||||
// " og " and leaving the actual italic word unmarked. Blanking means a later
|
||||
// pass cannot see the characters at all.
|
||||
let masked = line;
|
||||
const blank = (start, end) =>
|
||||
(masked = masked.slice(0, start) + '\u0000'.repeat(end - start) + masked.slice(end));
|
||||
|
||||
for (const { kind, re } of INLINE) {
|
||||
re.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = re.exec(masked))) {
|
||||
marks.push({ kind, start: m.index, end: m.index + m[0].length });
|
||||
}
|
||||
for (const mark of marks) blank(mark.start, mark.end);
|
||||
}
|
||||
marks.sort((a, b) => a.start - b.start);
|
||||
|
||||
const out = [];
|
||||
let at = 0;
|
||||
for (const mark of marks) {
|
||||
if (mark.start > at) out.push({ text: line.slice(at, mark.start) });
|
||||
out.push({ text: line.slice(mark.start, mark.end), kind: mark.kind });
|
||||
at = mark.end;
|
||||
}
|
||||
if (at < line.length) out.push({ text: line.slice(at) });
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* linesOf turns a body into lines, each with a block kind and its inline pieces.
|
||||
* Every line is returned, blank ones included, or the mirror would be one line
|
||||
* short of the textarea for every empty line in the note.
|
||||
*/
|
||||
export function linesOf(body) {
|
||||
return body.split('\n').map((line) => {
|
||||
const heading = /^(#{1,6})\s/.exec(line);
|
||||
if (heading) {
|
||||
return { block: `h${heading[1].length}`, parts: inlineSegments(line) };
|
||||
}
|
||||
if (/^\s*[-*+]\s/.test(line)) return { block: 'list', parts: inlineSegments(line) };
|
||||
if (/^\s*\d+\.\s/.test(line)) return { block: 'list', parts: inlineSegments(line) };
|
||||
if (/^\s*>\s?/.test(line)) return { block: 'quote', parts: inlineSegments(line) };
|
||||
return { block: 'p', parts: inlineSegments(line) };
|
||||
});
|
||||
}
|
||||
@@ -225,6 +225,19 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Færdige used to be a permanent entry in the sidebar. It is a rare errand next
|
||||
to the four that are read every day, and it belongs with the other "where did
|
||||
that go" questions rather than above them. -->
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>{t('done.title')}</h2>
|
||||
<p class="hint">{t('data.doneHint')}</p>
|
||||
</header>
|
||||
<div class="row">
|
||||
<a class="ghost" href="/faerdige">{t('data.openDone')}</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>{t('data.trash')}</h2>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { linesOf } from '$lib/markdown.js';
|
||||
|
||||
let notes = $state([]);
|
||||
let selected = $state(null);
|
||||
@@ -101,6 +102,12 @@
|
||||
|
||||
// What the note points at, for the panel under the editor.
|
||||
let links = $derived(selected?.links ?? []);
|
||||
|
||||
// The styled twin of what is in the textarea. Recomputed on every keystroke,
|
||||
// which sounds expensive and is not: it is a split and a handful of regexes
|
||||
// over a page of text, and it runs in the same frame as the character that
|
||||
// caused it.
|
||||
let lines = $derived(linesOf(draft));
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
|
||||
@@ -140,14 +147,33 @@
|
||||
|
||||
<section class="editor">
|
||||
{#if selected}
|
||||
<textarea
|
||||
bind:value={draft}
|
||||
oninput={typed}
|
||||
onblur={save}
|
||||
spellcheck="true"
|
||||
aria-label={t('notes.body')}
|
||||
placeholder={t('notes.placeholder')}
|
||||
></textarea>
|
||||
<!-- The mirror sits behind the textarea and must match it exactly: same font,
|
||||
same size, same padding, same wrapping. Any difference shows up as
|
||||
formatting that drifts away from the text as the note grows.
|
||||
|
||||
A contenteditable would let the text be styled directly and would also
|
||||
break undo, autocorrect and IME input — a bad trade in the one place in
|
||||
this program where somebody writes for half an hour at a stretch. -->
|
||||
<div class="paper">
|
||||
<div class="mirror" aria-hidden="true">
|
||||
{#each lines as line}
|
||||
<div class={line.block}>
|
||||
{#each line.parts as part}
|
||||
{#if part.kind}<span class={part.kind}>{part.text}</span>{:else}{part.text}{/if}
|
||||
{/each}{#if !line.parts.length} {/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
bind:value={draft}
|
||||
oninput={typed}
|
||||
onblur={save}
|
||||
spellcheck="true"
|
||||
aria-label={t('notes.body')}
|
||||
placeholder={t('notes.placeholder')}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<span class="hint">{saving ? t('notes.saving') : t('notes.saved')}</span>
|
||||
@@ -168,6 +194,8 @@
|
||||
|
||||
<style>
|
||||
.notes {
|
||||
/* Air above the heading, so it does not sit against the top bar. */
|
||||
padding-top: var(--s3);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 300px) 1fr;
|
||||
gap: var(--s4);
|
||||
@@ -192,6 +220,7 @@
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-xl);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.new {
|
||||
@@ -258,23 +287,94 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
textarea {
|
||||
/* Everything below exists to keep two layers in lockstep. The shared rules are
|
||||
set on both at once on purpose: a font-size on one and not the other is a
|
||||
whole line of drift by the bottom of a long note. */
|
||||
.paper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 50vh;
|
||||
resize: none;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.paper textarea,
|
||||
.paper .mirror {
|
||||
font: inherit;
|
||||
font-size: var(--text-base, 1rem);
|
||||
line-height: 1.6;
|
||||
padding: var(--s2) 0;
|
||||
border: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
word-break: normal;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
textarea {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
resize: none;
|
||||
background: none;
|
||||
/* Transparent, so what is read is the mirror underneath. The caret keeps its
|
||||
own colour or there would be nothing to type against. */
|
||||
color: transparent;
|
||||
caret-color: var(--ink);
|
||||
}
|
||||
|
||||
textarea::selection {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.mirror {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Headings change weight and colour, never size: a larger font would take more
|
||||
lines than the textarea does and everything below it would slide. */
|
||||
.mirror .h1,
|
||||
.mirror .h2,
|
||||
.mirror .h3,
|
||||
.mirror .h4,
|
||||
.mirror .h5,
|
||||
.mirror .h6 {
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.mirror .h1 {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.mirror .quote {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.mirror .bold {
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.mirror .italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.mirror .code {
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mirror .tag,
|
||||
.mirror .wikilink {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user