Compare commits

..
10 Commits
Author SHA1 Message Date
Kristian Wind 661e47ebdf Merge pull request #102 from kristianwind/markoer-i-titlen
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 15m22s
CI / Go (push) Failing after 26m42s
Release / Build and publish image (push) Failing after 9s
CI / Docker image builds (push) Successful in 2m36s
fix(noter): en ny note åbner med markøren i overskriften
2026-10-08 10:17:18 +02:00
kristianwindandClaude Opus 5 6178cbbdd8 Merge branch 'main' into markoer-i-titlen
Begge grene føjede prøver til i enden af smoke.spec.js, og begge beholdes:
fire markør-prøver og fire fra tal-en-opgave-ind. Den fælles `/**` ovenfor
dækkede kun den første, så den anden blok har fået sin egen.

Suiten på den løste fletning: 129 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 10:06:42 +02:00
Kristian Wind 43bd405e36 Merge pull request #100 from kristianwind/tal-en-opgave-ind
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 5m50s
CI / Docker image builds (push) Successful in 3m21s
CI / Go (push) Failing after 26m57s
feat(opgaver): tal en opgave ind — en rund knap i bunden på telefonen
2026-10-08 09:59:41 +02:00
Kristian Wind b73f55ad0b Merge pull request #101 from kristianwind/afsnit-efter-retur
fix(noter): afsnit skrevet hurtigt mister ikke deres afstand
2026-10-08 09:59:22 +02:00
kristianwind 475ecbe7ef Merge branch 'afsnit-efter-retur' into markoer-i-titlen 2026-10-08 07:55:04 +02:00
kristianwind 159029675c Merge branch 'afsnit-efter-retur' into tal-en-opgave-ind 2026-10-08 07:49:10 +02:00
kristianwindandClaude Opus 5 b18d9a26e6 fix(noter): afsnit skrevet hurtigt mister ikke deres afstand
Retur-handleren udskyder rettelsen ét hak, fordi der før delingen ikke er
nogen ny blok at rette. Men rettelsen spurgte, hvor markøren stod *nu*, og
et hak er nok: skriver man hurtigt, er næste retur nået igennem først, og så
stod markøren i den NÆSTE blok. Så rettede den den — to gange — og lod den
blok, rettelsen var ment for, stå tilbage som browserens bare <div>. En
<div> læses som brødtekst og er ikke formateret som en, så afstanden mellem
afsnit forsvandt stille, hos netop de brugere der skriver hurtigst.

Målt frem for gættet: "flere markerede afsnit bliver til én monotypeblok"
faldt på en CI-runner med alle tre linjer skrevet rigtigt, hvor "linje et"
var en <div> og "linje to" og "linje tre" var <p>.

Rettelsen er delt efter hvad der er sikkert at røre:

* Der hvor markøren står, gør execCommand det fortsat. Det er den, der
  lægger ændringen i browserens fortryd-historik, og filen vælger den
  bevidst andre steder af samme grund — ⌘Z skal tage afsnittet tilbage,
  ligesom den tager et ord tilbage.
* Den blok, delingen lige skabte, får samme regel uden markør at låne.
  Den dækker overskriften: retur for enden af en overskrift efterlader
  endnu en overskrift i de fleste browsere.
* Og hver <div> i noten bliver fejet op. En <div> er altid browserens egen
  beholder og aldrig nogens valg, så den kan rettes hvor som helst. En <h1>
  bliver IKKE fejet: menuen har en Titel-stil, så en overskrift midt i en
  note kan være en linje, nogen selv har valgt, og den er deres at beholde.

Fejning frem for et håndtag til blokken, og det er målt: når den første
udskudte rettelse bytter tagget, peger den næstes håndtag på en node uden
for dokumentet, og blokken midt i kæden bliver aldrig rettet. Prøven planter
to efterladte blokke og er kontrolleret i tre retninger — ingen rettelse: 2
efterladte <div>, håndtag til blokken: 1, fejning: 0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 07:37:49 +02:00
kristianwindandClaude Opus 5 3768cf946e fix(noter): en ny note åbner med markøren i overskriften
Oprettelse af en note satte ingen markør. Siden kaldte
`document.querySelector('.editor textarea')?.focus()` — og der er ingen
textarea; editoren blev lavet om til en contenteditable, så kaldet har ramt
ingenting lige siden, og `?.` sørgede for, at det aldrig larmede.

Rettelsen ligger i NoteEditor frem for på siden, så begge veje er dækket:
knappen på /noter og den på et projekt, der navigerer til /noter?note=<id>.
Reglen er, at en TOM note sætter markøren i sin overskrift — en note med
indhold rører den ikke, for der er markøren brugerens eget valg.

Fire prøver, og de er kontrolleret i begge retninger: uden kaldet giver den
nye note `Expected: "h1", Received: null`, og med kaldet på enhver note
giver kontrollen `Expected: null, Received: "h1"`.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 07:16:22 +02:00
kristianwindandClaude Opus 5 60795126a5 fix(opgaver): skuffens knapper kan ikke tabbes til, mens den er usynlig
`inert` på skuffen, når den er lukket, så dens felt og knapper er ude af
tabrækkefølgen og ude af tilgængelighedstræet.

Det støder mod funktionen selv: et inert element kan ikke fokuseres, og
fokus i samme hug som klikket er præcis det, iOS kræver for at åbne
tastaturet. Attributten sættes reaktivt af `inert={!open}`, og Sveltes
opdateringer er asynkrone, så den er der stadig i det øjeblik handleren
kører.

Derfor fjernes den med hånden i `start()`, før fokus sættes, og den
reaktive binding lægger den tilbage ved lukning. En imperativ DOM-ændring
midt i en deklarativ komponent ser ud som sjusk, og uden en grund skrevet
ved siden af ville den næste læser helt rimeligt rydde den op.

Plantet ved at fjerne linjen: prøven går rød med "markøren skal stå i
skuffens felt efter trykket — Expected: true, Received: false". Linjen
bærer noget.

Luk-knappens `tabindex` er fjernet som overflødig; inert dækker hele træet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 07:04:27 +02:00
kristianwindandClaude Opus 5 664614b924 feat(opgaver): tal en opgave ind — en rund knap i bunden på telefonen
En stor rund knap nederst til højre, inden for tommelfingerens rækkevidde.
Den åbner en skuffe med quick-add-feltet og sætter markøren i det, så
tastaturet kommer frem — og så trykker man mikrofonen på tastaturet.

VI LAVER INGEN TALEGENKENDELSE, og det er et valg frem for en mangel. iOS'
egen diktering kører på enheden på nyere telefoner. En talegenkendelse
bygget ind her ville sende lyden fra et selvhostet program videre til Apple
eller Google — en datavej, der skulle forklares på privatlivssiden, for at
spare ét tryk. Af de tre AI-udbydere, instansen understøtter, har anthropic
i øvrigt slet ingen lyd-API, så serverside-transskription ville også dele
brugerne op efter hvem de har en nøgle hos.

Teksten lander i det felt, der i forvejen viser, hvad parseren forstod, så
"ring til tandlægen i dag kl 14 p1" bliver læst mens man ser på det, og
intet oprettes før man siger ja. Derfor hedder komponenten diktat og ikke
tale: den gør ikke selv noget ved lyden, den stiller feltet frem.

## Det ene, funktionen hænger på

Safari på iOS åbner kun tastaturet, hvis focus() kaldes INDE i den
brugerhandling, der bad om det. QuickAdds `autofocus` sker i en $effect,
altså efter klikket er returneret — det giver feltet en markør og intet
tastatur, og der er ingen fejl nogen steder.

Så skuffen er ALTID monteret og bare skubbet ud af syne med transform og
opacity. Ikke display:none, ikke visibility:hidden, ikke inert — alle tre
gør feltet ufokuserbart. Prøven påstår direkte, at feltet findes FØR
klikket, fordi det er den eneste del af iOS-kravet, en browser kan måles på;
plantet som `{#if open}` går den rød med Expected: 1, Received: 0.

## To kollisioner, hvor jeg kun så den ene

Skuffen lægger et felt mere på siden, og det rammer to ting:

- `data-quickadd`, som skriv-hvor-som-helst-genvejen slår op med
  querySelector — altså FØRSTE match. Set på forhånd og løst med en
  `marked`-prop.

- Det tilgængelige navn. Det så jeg IKKE, og otte prøver gik røde på
  `getByLabel('Ny opgave') resolved to 2 elements`. Det er heller ikke kun
  et prøveproblem: to felter med samme navn læses op to gange af en
  skærmlæser, uden at man kan høre forskel. De to ER forskellige ting — det
  ene er kassen på siden, det andet er det, knappen åbner — så de har hver
  sit navn nu.

## Og en fejl, skærmbilledet fandt

Fremhævningen bag teksten lå på en linje, inputtet ikke havde — en bleg
blok under sætningen. Spejlet stod på `white-space: pre-wrap`, men et
<input type="text"> er enkeltlinje per specifikation og kan ikke ombryde,
uanset hvad reglen siger. Komponentens egen kommentar forudser det ordret:
"same wrapping. Any difference shows up as highlighting that drifts out of
alignment as the line grows."

Sjældent synligt på et bredt felt, konstant på 390px med en dikteret
sætning. Spejlet står nu på `pre` og rulles med inputtets scrollLeft.

Knappen tegnes ikke over 820px — der er feltet fremme hele tiden og et
tastatur at skrive på — og ikke i urd-ansigtet, hvor der ingen opgaver er.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 06:50:56 +02:00
10 changed files with 737 additions and 38 deletions
+266
View File
@@ -5102,6 +5102,85 @@ test('flere markerede afsnit bliver til én monotypeblok', async ({ page }) => {
expect(trouble).toEqual([]);
});
/**
* Blokke, som retur efterlod som <div>, bliver brødtekst — også bagefter.
*
* Chromium svarer på retur med en bar <div>. Den læses som brødtekst og er ikke
* formateret som en, så afstanden mellem afsnit forsvinder stille. Rettelsen er
* udskudt ét hak — før delingen er der ingen ny blok at rette — og den spørger,
* hvor markøren står. Skriver man hurtigt, når næste retur igennem først, og så
* retter den den næste blok to gange og lader denne stå.
*
* Det er målt frem for gættet: på en CI-runner faldt "flere markerede afsnit
* bliver til én monotypeblok" med tre linjer rigtigt skrevet, hvor "linje et" var
* en <div> og "linje to" og "linje tre" var <p>.
*
* Racet selv kan ikke skrives som en pålidelig prøve — det afhænger af, hvornår
* runneren når sine timere. Derfor planter denne den tilstand, racet efterlader,
* og holder på invarianten i stedet: ingen blok i en note er browserens egen
* beholder, uanset hvor markøren har været i mellemtiden.
*/
test('blokke retur efterlod som <div> bliver brødtekst alligevel', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
const ed = await nyNote(page);
await page.keyboard.type('Overskrift');
await page.keyboard.press('Enter');
await page.keyboard.type('første afsnit');
await page.keyboard.press('Enter');
await page.keyboard.type('andet afsnit');
await expect(ed.locator('p')).toHaveCount(2);
// Tilstanden racet efterlader: rettelserne løb, mens markøren stod et andet sted,
// så ingen af blokkene blev lavet om. To af dem, fordi én er for let: en rettelse,
// der holder fast i et håndtag til blokken, kan nå den første og mister den næste,
// når den første ombytning tager dens node ud af dokumentet.
await ed.evaluate((el) => {
for (const para of [...el.querySelectorAll('p')]) {
const div = document.createElement('div');
while (para.firstChild) div.appendChild(para.firstChild);
para.replaceWith(div);
}
// Markøren tilbage for enden af den sidste, hvor racet ville have efterladt den:
// brugeren skriver videre. At erstatte blokken kollapser markeringen til
// begyndelsen, og så bliver plantningen selv den fejl, prøven måler.
const range = document.createRange();
range.selectNodeContents(el.lastElementChild);
range.collapse(false);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
});
await expect(ed.locator('p')).toHaveCount(0);
await expect(ed.locator('> div')).toHaveCount(2);
// Næste retur henter dem begge ind igen, sammen med den nye linje.
await page.keyboard.press('Enter');
await page.keyboard.type('tredje afsnit');
await expect(ed.locator('> div')).toHaveCount(0);
await expect(ed.locator('p')).toHaveCount(3);
await expect(ed.locator('h1')).toHaveCount(1);
// Og teksten er den samme hele vejen — en rettelse, der taber et ord, er ingen
// rettelse. Den skal også overleve en gemning.
const gemt = noteGemt(page);
await ed.blur();
await gemt;
await page.reload();
await page.getByRole('button', { name: /Overskrift/ }).click();
await expect(ed.locator('> div')).toHaveCount(0);
await expect(ed.locator('p')).toHaveCount(3);
expect(await ed.locator('p').nth(0).textContent()).toBe('første afsnit');
expect(await ed.locator('p').nth(1).textContent()).toBe('andet afsnit');
expect(await ed.locator('p').nth(2).textContent()).toBe('tredje afsnit');
expect(trouble).toEqual([]);
});
/**
* Hele kontoens opgaver kan hentes i én omgang.
*
@@ -5620,3 +5699,190 @@ test('the verdande door is unaffected by the urd one', async ({ page }) => {
expect(trouble).toEqual([]);
});
/**
* En ny note åbner med markøren i sin titel.
*
* Titlen er første linje, som i Apple Notes — der er ikke et selvstændigt felt,
* så "sæt markøren i overskriften" betyder et område placeret inde i <h1> i et
* contenteditable. Derfor måles der på markeringen og ikke på hvad der har fokus:
* editoren kan have fokus med markøren stående et helt andet sted.
*/
async function caretBlock(page) {
return page.evaluate(() => {
const s = window.getSelection();
if (!s || !s.anchorNode) return null;
const node = s.anchorNode.nodeType === 1 ? s.anchorNode : s.anchorNode.parentElement;
const block = node?.closest('h1, h2, h3, p, li, pre');
return block ? block.tagName.toLowerCase() : null;
});
}
test('en ny note åbner med markøren i overskriften', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
expect(await caretBlock(page), 'markøren skal stå i h1').toBe('h1');
// Og den står et sted, man kan skrive: det, der tastes, bliver titlen, og
// titlen er det, listen kalder noten.
await page.keyboard.type('Pakkeliste til Island');
await expect(page.locator('.editor h1').first()).toHaveText('Pakkeliste til Island');
await expect(page.locator('.notes button.row strong').first()).toHaveText(
/^Pakkeliste til Island$/
);
expect(trouble).toEqual([]);
});
test('en note med indhold stjæler ikke markøren', async ({ page }) => {
// Kontrollen, og den halvdel der gør det til en regel frem for en vane: åbner
// man en note, man har skrevet i forvejen, skal markøren IKKE hoppe op i
// overskriften og skubbe det, man var i gang med.
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
await page.keyboard.type('Allerede skrevet');
// Vent til den er gemt, så den er en note med indhold næste gang den åbnes.
await page.waitForTimeout(1200);
await page.reload();
await page.getByRole('button', { name: /Allerede skrevet/ }).first().click();
await expect(page.locator('.editor h1').first()).toHaveText('Allerede skrevet');
expect(await caretBlock(page), 'ingen markør skal være sat').toBe(null);
expect(trouble).toEqual([]);
});
test('urd-døren opfører sig ens: ny note, markør i overskriften', async ({ page }) => {
// Han bad om "Urd og noter". Det er samme side, men målt gennem døren, fordi
// det er den vej en bruger af urd-appen kommer — og fordi en regel, der kun er
// prøvet ad én vej, er prøvet ét sted.
const trouble = watchForTrouble(page);
await page.goto('/urd');
await expect(page).toHaveURL(/\/noter$/);
await page.getByRole('button', { name: 'Ny note' }).click();
expect(await caretBlock(page), 'markøren skal stå i h1 også i urd').toBe('h1');
expect(trouble).toEqual([]);
});
test('en note oprettet fra et projekt åbner også med markøren i overskriften', async ({ page }) => {
// Den anden vej ind, og den blev påstået dækket før den blev målt: projektets
// "Ny note" opretter noten og sender videre med ?note=<id>, så markøren
// placeres af editoren på den anden side af en navigation frem for i samme
// hug. Reglen sidder i editoren netop for at dække begge — det er værdiløst
// uden en prøve, der går den anden vej.
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByLabel('Nyt projekt').click();
await sidebar.getByLabel('Projektnavn').fill('Islandsturen');
await sidebar.getByLabel('Projektnavn').press('Enter');
await sidebar.getByRole('link', { name: 'Islandsturen' }).click();
await expect(page.getByRole('heading', { name: 'Islandsturen' })).toBeVisible();
await page.getByRole('button', { name: 'Ny note', exact: true }).click();
await expect(page).toHaveURL(/\/noter\?note=/);
expect(await caretBlock(page), 'markøren skal stå i h1 efter navigationen').toBe('h1');
await page.keyboard.type('Dæk og kort');
await expect(page.locator('.editor h1').first()).toHaveText('Dæk og kort');
expect(trouble).toEqual([]);
});
/**
* Tal en opgave ind.
*
* The button does not do speech recognition and is not meant to: it puts the
* quick-add field in front of the thumb so the keyboard's own dictation can be
* used. So what there is to test is the field — that it is reachable, that it ends
* up with the caret in it, and that what lands there becomes a task.
*
* What a browser cannot be asked: whether iOS opened its keyboard. That happens
* only when focus() is called inside the user gesture, and no test can see the
* difference. What a test CAN see is the precondition — that the field is already
* in the document before the button is pressed — and that is asserted below,
* because it is exactly what a well-meaning `{#if open}` would take away.
*/
const PHONE = { width: 390, height: 844 };
test('den runde knap sætter markøren i feltet, så tastaturet kan diktere', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.setViewportSize(PHONE);
await page.goto('/');
const knap = page.getByRole('button', { name: 'Tal en opgave ind' });
await expect(knap).toBeVisible();
// Feltet findes FØR klikket. Det er forudsætningen for at fokus kan sættes
// synkront, og den eneste del af iOS-kravet en prøve kan se.
//
// `toHaveCount` og ikke `count()`: den første venter, den anden er et
// øjebliksbillede taget med det samme — og en SPA har ikke tegnet endnu, når
// `goto` vender tilbage, så den ville tælle nul uanset hvad koden gjorde.
await expect(page.locator('.sheet input[type="text"]')).toHaveCount(1);
await knap.click();
// Markøren er i skuffens felt — ikke i sidens eget.
const fokusIskuffen = await page.evaluate(
() => !!document.activeElement?.closest('.sheet')
);
expect(fokusIskuffen, 'markøren skal stå i skuffens felt efter trykket').toBe(true);
// Og det, der dikteres, bliver læst af den samme parser som alt andet.
const felt = page.locator('.sheet input[type="text"]');
await felt.fill('ring til tandlægen i dag p1');
await felt.press('Enter');
// "i dag", af samme grund som de andre prøver her siger det: dette er
// I dag-visningen, og en opgave uden dato eller med "i morgen" er korrekt
// fraværende fra den — hvilket ville ligne, at tilføjelsen var fejlet.
await expect(page.getByText('ring til tandlægen').first()).toBeVisible();
// Og skuffen lukker sig selv, så man ikke står med tastaturet oppe bagefter.
await expect(page.locator('.sheet.open')).toHaveCount(0);
expect(trouble).toEqual([]);
});
test('skuffens felt stjæler ikke skriv-hvor-som-helst-genvejen', async ({ page }) => {
// Genvejen finder feltet med querySelector('[data-quickadd]'), som returnerer
// FØRSTE match i dokumentet. Skuffens felt må derfor ikke bære mærket — ellers
// begynder et tastetryk at skrive i en kasse, ingen kan se.
await page.setViewportSize(PHONE);
await page.goto('/');
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await expect(page.locator('[data-quickadd]'), 'kun ét mærket felt').toHaveCount(1);
const mærketErIskuffen = await page.evaluate(
() => !!document.querySelector('[data-quickadd]')?.closest('.sheet')
);
expect(mærketErIskuffen, 'mærket skal sidde på sidens felt, ikke skuffens').toBe(false);
await page.keyboard.press('k');
await expect(page.locator('[data-quickadd]')).toHaveValue('k');
});
test('knappen findes ikke på en skrivebordsskærm', async ({ page }) => {
// Der er feltet fremme hele tiden og et tastatur at skrive på. En rund knap i
// bunden er til tommelfingeren.
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto('/');
await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toBeHidden();
});
test('knappen findes ikke i urd-ansigtet', async ({ page }) => {
// Der er ingen opgaver at lægge noget i. Målt gennem døren frem for ved at
// skrue på en variabel, så det er den vej en bruger faktisk kommer.
await page.setViewportSize(PHONE);
await page.goto('/urd');
await expect(page.getByRole('navigation', { name: /Hovedmenu|Main menu/ })).toBeVisible();
await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toHaveCount(0);
// Kontrollen: samme telefon, den anden dør, og så er den der.
await page.goto('/');
await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toBeVisible();
});
+243
View File
@@ -0,0 +1,243 @@
<script>
/**
* Tal en opgave ind.
*
* En stor rund knap i bunden af skærmen, inden for tommelfingerens rækkevidde.
* Den åbner en skuffe med quick-add-feltet og sætter markøren i det, så
* tastaturet kommer frem — og så trykker man mikrofonen på tastaturet.
*
* VI LAVER INGEN TALEGENKENDELSE, og det er et valg frem for en mangel. iOS'
* egen diktering kører på enheden på nyere telefoner; en talegenkendelse bygget
* ind her ville sende lyden fra et selvhostet program videre til Apple eller
* Google, og det er en datavej, der skal forklares på privatlivssiden for at
* spare ét tryk. Teksten lander i det felt, der i forvejen viser, hvad parseren
* forstod — så "ring til tandlægen i morgen kl 10 p1" bliver læst, mens man ser
* på det, og intet oprettes før man siger ja.
*
* Derfor hedder komponenten diktat og ikke tale: den gør ikke selv noget ved
* lyden, den stiller feltet frem.
*/
import QuickAdd from './QuickAdd.svelte';
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
let open = $state(false);
let box;
let sheet;
/**
* Åbner og sætter fokus I SAMME HUG som klikket.
*
* Rækkefølgen her er hele funktionen. Safari på iOS åbner kun tastaturet, hvis
* `focus()` kaldes inde i den brugerhandling, der bad om det — og Sveltes
* opdateringer er asynkrone, så feltet kan ikke være betinget tegnet og få
* fokus i samme omgang. Derfor er skuffen ALTID monteret og bare skubbet ud af
* syne med `transform` og `opacity`: elementet findes, så det kan fokuseres nu
* frem for efter næste opdatering.
*
* `display: none` og `visibility: hidden` ville begge gøre feltet ufokuserbart,
* og `inert` ligeså. Det er grunden til, at den lukkede tilstand er gennemsigtig
* og uden pointer-events frem for skjult.
*/
function start() {
// `inert` fjernes med hånden her, og det er ikke sjusk. Attributten sættes
// reaktivt af `inert={!open}` nedenfor, så den er der stadig i det øjeblik,
// klikket kører — Sveltes opdateringer er asynkrone. Og et inert element kan
// ikke fokuseres, så `focusField()` ville ramme ingenting, og iOS ville ikke
// åbne tastaturet.
//
// Den findes, fordi alternativet er værre: uden den kan skuffens knapper
// tabbes til, mens den er usynlig, på hver eneste side.
sheet?.removeAttribute('inert');
open = true;
box?.focusField();
}
function close() {
open = false;
// Tastaturet bliver stående, hvis feltet beholder fokus.
if (sheet?.contains(document.activeElement)) document.activeElement.blur();
}
function onkeydown(event) {
if (event.key === 'Escape' && open) {
event.preventDefault();
close();
}
}
</script>
<svelte:window {onkeydown} />
<!-- Knappen tegnes ikke i urd-ansigtet: der er ingen opgaver at lægge noget i.
Og den er skjult over 820px i CSS frem for her, fordi en skærm, der skifter
bredde, ikke skal kunne tabe et felt, der har fokus. -->
{#if !app.showsUrd}
<button class="fab" class:away={open} onclick={start} aria-label={t('task.speak')}>
<!-- En mikrofon, tegnet i samme streger som mærkerne: kapslen, bøjlen under
den og foden. Ingen tekst i knappen — den er rund og tommelfingerstor,
og navnet står i aria-label. -->
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="9" y="2.5" width="6" height="11" rx="3" />
<path d="M5.5 11a6.5 6.5 0 0 0 13 0" />
<path d="M12 17.5V21" />
</svg>
</button>
{/if}
<div
class="scrim"
class:open
aria-hidden="true"
onclick={close}
role="presentation"
></div>
<div
class="sheet"
class:open
bind:this={sheet}
role="dialog"
aria-modal="false"
aria-label={t('task.speak')}
aria-hidden={!open}
inert={!open}
>
<div class="grab" aria-hidden="true"></div>
<p class="hint">{t('task.speakHint')}</p>
<!-- `marked={false}`: den her må ikke være den, type-anywhere-genvejen finder.
Se kommentaren ved `marked` i QuickAdd. -->
<QuickAdd bind:this={box} marked={false} label={t('task.speak')} onadded={close} />
<button class="done" onclick={close}>{t('detail.close')}</button>
</div>
<style>
.fab {
position: fixed;
right: max(var(--s4), env(safe-area-inset-right));
/* Over hjemmeindikatoren på en iPhone, ikke under den. */
bottom: calc(var(--s4) + env(safe-area-inset-bottom));
z-index: 40;
width: 64px;
height: 64px;
border: none;
border-radius: var(--radius-full);
background: var(--accent);
color: var(--accent-ink);
box-shadow: var(--shadow-lg);
display: grid;
place-items: center;
cursor: pointer;
transition:
transform var(--fast) var(--ease-out),
opacity var(--fast) var(--ease-out);
}
.fab svg {
width: 28px;
height: 28px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.fab:active {
transform: scale(0.94);
}
/* Væk, mens skuffen er åben: den ville ellers stå oven på feltet. */
.fab.away {
opacity: 0;
pointer-events: none;
transform: translateY(8px);
}
.scrim {
position: fixed;
inset: 0;
z-index: 44;
background: rgb(0 0 0 / 0.4);
opacity: 0;
pointer-events: none;
transition: opacity var(--medium) var(--ease-out);
}
.scrim.open {
opacity: 1;
pointer-events: auto;
}
.sheet {
position: fixed;
inset: auto 0 0 0;
z-index: 45;
padding: var(--s3) max(var(--s3), env(safe-area-inset-left))
calc(var(--s3) + env(safe-area-inset-bottom)) max(var(--s3), env(safe-area-inset-right));
background: var(--surface-raised);
border-top: 1px solid var(--line);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
box-shadow: var(--shadow-lg);
display: grid;
gap: var(--s2);
/* Lukket: gennemsigtig og skubbet ned, IKKE display:none — feltet skal kunne
fokuseres i samme hug som klikket. Se `start()`. */
opacity: 0;
transform: translateY(100%);
pointer-events: none;
transition:
transform var(--medium) var(--ease-out),
opacity var(--medium) var(--ease-out);
}
.sheet.open {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.grab {
width: 36px;
height: 4px;
border-radius: var(--radius-full);
background: var(--line-strong);
justify-self: center;
}
.hint {
margin: 0;
color: var(--ink-muted);
font-size: 0.85rem;
text-align: center;
}
.done {
justify-self: center;
background: none;
border: none;
color: var(--ink-muted);
padding: var(--s2);
cursor: pointer;
}
/* En skrivebordsskærm har feltet fremme hele tiden og et tastatur at skrive
på. Knappen er til tommelfingeren. */
@media (min-width: 821px) {
.fab,
.scrim,
.sheet {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.fab,
.scrim,
.sheet {
transition: none;
}
}
</style>
+92 -17
View File
@@ -81,12 +81,55 @@
// fired.
editor.innerHTML = body.trim() ? markdownToHtml(body) : '<h1><br></h1>';
colourCode();
// Og markøren sættes DER.
//
// Indtil nu lavede linjen ovenfor kun titelblokken, så sætningen i
// kommentaren — "opens on its title" — handlede om, hvad der blev tegnet,
// ikke om hvor man kunne skrive. Noter-siden prøvede at råde bod på det
// med `document.querySelector('.editor textarea')?.focus()`, og der er
// ingen textarea: editoren er et contenteditable. Det kald har ramt
// ingenting, stille, siden editoren blev lavet om, og `?.` sørgede for,
// at det aldrig kastede.
//
// Her frem for på siden, så begge veje ind er dækket: noter-sidens egen
// opret-knap, og projektsidens, der sender videre med ?note=<id>.
if (!body.trim()) placeCaretInTitle();
} catch (e) {
editor.textContent = body;
console.error('verdande: noten kunne ikke tegnes', note.id, e);
}
});
/**
* Sætter markøren i titlen på en tom note.
*
* En contenteditable har ikke et felt at fokusere — den har en markør, og den
* skal placeres. `focus()` alene giver elementet tastaturfokus og lader
* markøren stå, hvor browseren synes, hvilket for et tomt element er starten
* på dokumentet frem for inde i overskriften.
*
* Fokus FØRST og så området: at fokusere et contenteditable kan nulstille
* markeringen, så den omvendte rækkefølge sætter et område, der straks bliver
* kasseret igen.
*
* Hvad den IKKE kan, sagt her frem for opdaget på en telefon: åbne tastaturet
* på iOS. Det sker kun, når focus() kaldes inde i den brugerhandling, der bad
* om det — og en ny note skal først oprettes på serveren, så der ligger et
* `await` imellem. Markøren står rigtigt; på en telefon skal man stadig trykke
* én gang for at få tastaturet frem.
*/
function placeCaretInTitle() {
const heading = editor?.querySelector('h1');
if (!heading) return;
editor.focus();
const range = document.createRange();
range.selectNodeContents(heading);
range.collapse(true);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
const SYNTAX = [
{ mark: '#', what: 'notes.syntaxProject' },
{ mark: '[[', what: 'notes.syntaxNote' },
@@ -821,23 +864,54 @@
* the caret in another heading in most browsers, and a note where every line is
* a title is a note with no title at all.
*/
function keepTitleFirst() {
const block = currentBlock();
if (!block) return;
function keepTitleFirst(split) {
if (!editor) return;
// A heading that is not the first line becomes body.
if (block.tagName === 'H1' && block !== editor.firstElementChild) {
document.execCommand('formatBlock', false, 'p');
return;
}
// Der hvor markøren står, gør execCommand det, for det er den, der lægger
// ændringen i browserens egen fortryd-historik — ⌘Z skal tage afsnittet tilbage,
// ligesom den tager et ord tilbage.
const caret = currentBlock();
if (caret && stray(caret)) document.execCommand('formatBlock', false, 'p');
// And the browser's own container becomes a paragraph. Chromium answers Enter
// with a bare <div>, which reads as body but is not styled as one — so the
// spacing between paragraphs quietly disappears the moment somebody presses
// return rather than choosing Brødtekst from the menu.
if (block.tagName === 'DIV') {
document.execCommand('formatBlock', false, 'p');
}
// Retur for enden af en overskrift efterlader endnu en overskrift i de fleste
// browsere, og markøren kan være løbet videre, inden rettelsen når frem. Kun den
// blok, delingen lige skabte: en <h1> andre steder i noten kan være en linje,
// nogen har valgt Titel til i menuen, og den er deres at beholde.
const lavet = split?.nextElementSibling;
if (lavet && lavet !== currentBlock() && stray(lavet)) swapTag(lavet, 'p');
// Og hver <div>, rettelsen ikke nåede.
//
// Den er udskudt ét hak, fordi der før delingen ikke er nogen ny blok at rette.
// Men et hak er nok: skriver man hurtigt, når næste retur igennem først, og så
// står markøren i den NÆSTE blok. Så rettede den den — to gange — og lod den
// blok, rettelsen var ment for, stå tilbage. Målt på en CI-runner, hvor "linje
// et" blev en <div> mens "linje to" og "linje tre" blev <p>: brødtekst uden
// afstand, hos netop de brugere der skriver hurtigst.
//
// Den spørger noten frem for et håndtag. Et håndtag holder ikke: når den første
// udskudte rettelse bytter tagget på en blok, peger den næstes håndtag på en
// node, der ikke længere sidder i dokumentet, og så bliver blokken midt i
// kæden aldrig rettet. En <div> er altid browserens egen beholder og aldrig
// nogens valg, så den kan fejes op hvor som helst uden at tage noget fra nogen.
const her = currentBlock();
for (const block of [...editor.children])
if (block !== her && block.tagName === 'DIV') swapTag(block, 'p');
}
/**
* En blok, der ikke må stå, som den står.
*
* Browserens egen beholder: Chromium svarer på retur med en bar <div>, som læses
* som brødtekst og ikke er formateret som en, så afstanden mellem afsnit stille
* forsvinder. Eller en overskrift, der ikke er den første linje — en note, hvor
* hver linje er en titel, er en note uden titel.
*/
function stray(block) {
if (block.parentNode !== editor) return false;
return (
block.tagName === 'DIV' || (block.tagName === 'H1' && block !== editor.firstElementChild)
);
}
/**
@@ -912,9 +986,10 @@
changed();
return;
}
// After the split, not before it.
// After the split, not before it — and with the block the split started from,
// because by the time this runs the caret may be two blocks further on.
setTimeout(() => {
keepTitleFirst();
keepTitleFirst(block);
changed();
}, 0);
return;
+73 -6
View File
@@ -16,14 +16,53 @@
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
import { readList } from '$lib/pastelist.js';
let { projectId = undefined, onadded = undefined, autofocus = false } = $props();
let {
projectId = undefined,
onadded = undefined,
autofocus = false,
/**
* Whether this box carries the `data-quickadd` marker.
*
* The layout's type-anywhere shortcut finds the field with
* `document.querySelector('[data-quickadd]')`, which returns the FIRST match
* in the document. So a second box on the page — the dictate sheet's — has
* to stay unmarked, or the shortcut starts typing into a box nobody can see.
*/
marked = true,
/**
* The field's accessible name.
*
* A prop because the dictate sheet puts a second one of these on the page,
* and two fields answering to "Ny opgave" is a screen reader reading the
* same name twice with no way to tell which is which. They are different
* affordances — one is the box on the page, the other is the thing the
* round button opens — so they say different names.
*/
label = undefined
} = $props();
let fieldLabel = $derived(label ?? t('task.new'));
let text = $state('');
let spans = $state([]);
let submitting = $state(false);
let input;
let mirror;
let focused = $state(false);
/**
* Keeps the tint under the right characters when the line is longer than the
* field.
*
* The input scrolls horizontally and the mirror has to scroll with it. Done on
* both `scroll` and `input` because typing at the end of a long line moves the
* caret — and therefore the scroll — without ever firing a scroll event in some
* browsers.
*/
function syncScroll() {
if (mirror && input) mirror.scrollLeft = input.scrollLeft;
}
// Kept beside the parser's own vocabulary on purpose: a legend that promises
// something internal/quickadd cannot read is worse than no legend. These four
// are what Parse recognises as marks; dates it reads as prose, which is why the
@@ -72,6 +111,20 @@
if (autofocus && input) input.focus();
});
/**
* Focus the field from outside, synchronously.
*
* This exists because of iOS and nothing else. Safari opens the on-screen
* keyboard only when `focus()` is called INSIDE the user gesture that asked for
* it — so the `autofocus` effect above, which runs after the click handler has
* returned, gives the field a caret and no keyboard. For the dictate button that
* is the whole feature failing quietly: the sheet opens, nothing to dictate
* into, and there is no error anywhere.
*/
export function focusField() {
input?.focus();
}
/** Splits the raw text into highlighted and plain runs, by byte offset. */
let segments = $derived.by(() => {
if (!spans.length) return [{ text, kind: null }];
@@ -197,7 +250,7 @@
<!-- The mirror sits behind the input and must match it exactly: same font,
same padding, same wrapping. Any difference shows up as highlighting
that drifts out of alignment as the line grows. -->
<div class="mirror" aria-hidden="true">
<div class="mirror" aria-hidden="true" bind:this={mirror}>
{#each segments as segment}
{#if segment.kind}
<mark data-kind={segment.kind}>{segment.text}</mark>
@@ -211,12 +264,14 @@
bind:this={input}
bind:value={text}
{onkeydown}
onscroll={syncScroll}
oninput={syncScroll}
onfocus={() => (focused = true)}
onblur={() => (focused = false)}
type="text"
data-quickadd
data-quickadd={marked ? '' : undefined}
placeholder={t('task.placeholder')}
aria-label={t('task.new')}
aria-label={fieldLabel}
autocomplete="off"
spellcheck="false"
{onpaste}
@@ -385,8 +440,16 @@
letter-spacing: normal;
padding: 0;
border: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
/* `pre`, not `pre-wrap`, and that is the whole alignment.
An <input type="text"> is single-line by specification and cannot wrap
whatever CSS says. So a wrapping mirror behind a non-wrapping input does
not drift a little — it puts the tint on a second line that the input does
not have, and the highlight ends up under empty space. Rare on a wide
desktop field, constant in the dictate sheet on a 390px phone, where a
spoken sentence overflows immediately. */
white-space: pre;
overflow-wrap: normal;
}
.mirror {
@@ -394,6 +457,10 @@
inset: 0;
pointer-events: none;
color: transparent;
/* Scrolled in step with the input by hand — see `syncScroll`. The overflow
has to be hidden rather than visible, or the long tail of a line paints
out past the field's edge. */
overflow: hidden;
}
/* The tint is behind the glyphs, not on them: colouring the text itself would
+5
View File
@@ -267,6 +267,11 @@ export const da = {
'task.pastedAdd': 'Opret {n} opgaver',
'task.pastedCancel': 'Fortryd',
'task.new': 'Ny opgave',
// Diktat, ikke tale: programmet laver ingen talegenkendelse, det stiller
// feltet frem, så tastaturets egen mikrofon kan bruges. Hjælpelinjen siger
// det, fordi en knap med en mikrofon på lover noget andet.
'task.speak': 'Tal en opgave ind',
'task.speakHint': 'Tryk mikrofonen på tastaturet, og sig hvad der skal gøres.',
'task.syntaxProject': 'projekt',
'task.syntaxSection': 'sektion',
'task.syntaxLabel': 'etiket',
+2
View File
@@ -264,6 +264,8 @@ export const en = {
'task.pastedAdd': 'Add {n} tasks',
'task.pastedCancel': 'Cancel',
'task.new': 'New task',
'task.speak': 'Speak a task',
'task.speakHint': 'Tap the microphone on your keyboard and say what needs doing.',
'task.syntaxProject': 'project',
'task.syntaxSection': 'section',
'task.syntaxLabel': 'label',
+19 -2
View File
@@ -86,9 +86,26 @@ class AppState {
urdFace = $state(false);
enterUrd() {
this.urdFace = true;
this.setDoor('urd');
}
leaveUrd() {
this.setDoor('verdande');
}
/**
* Hvilken dør dette vindue sidst kom ind ad.
*
* Symmetrisk med vilje: `/` er også en dør. Uden den halvdel bliver en fane,
* der ÉN gang har set /urd, ved med at hedde urd — også på opgavesiderne, også
* efter et link til forsiden. I den installerede app sker det aldrig, for dens
* start_url er /urd og den anden apps er /; i en browserfane sker det hele
* tiden.
*/
setDoor(door) {
this.urdFace = door === 'urd';
try {
sessionStorage.setItem('verdande:door', 'urd');
sessionStorage.setItem('verdande:door', door);
} catch (e) {
// Privat browsing kan nægte. Ansigtet holder så vinduet ud alligevel,
// fordi det står i $state ovenfor — det er kun genindlæsningen, der
+29
View File
@@ -6,6 +6,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Sidebar from '$lib/components/Sidebar.svelte';
import DictateSheet from '$lib/components/DictateSheet.svelte';
import CommandPalette from '$lib/components/CommandPalette.svelte';
import SignIn from '$lib/components/SignIn.svelte';
import NoConnection from '$lib/components/NoConnection.svelte';
@@ -57,6 +58,25 @@
app.load();
});
/**
* Hvilken dør dette vindue er inde ad.
*
* To døre, og reglen er én sætning: den, du sidst kom ind ad, bestemmer
* ansigtet. `/urd` sætter urd, `/` sætter verdande, og alt derimellem rører
* det ikke — så et klik videre til en note eller en opgave beholder det navn,
* vinduet åbnede med.
*
* Symmetrien er det, der gør det forudsigeligt. Uden `/`-halvdelen ville en
* fane, der én gang havde set /urd, blive ved med at hedde urd, også på
* opgavesiderne. I de installerede apps sker det aldrig — deres start_url er
* hver sin dør — men en browserfane springer mellem dem hele tiden.
*/
$effect(() => {
const here = $page.url.pathname;
if (here === '/urd') app.enterUrd();
else if (here === '/') app.leaveUrd();
});
/**
* Opgavernes sider findes ikke i noter-udgaven.
*
@@ -295,6 +315,15 @@
tabindex="-1"
></button>
{/if}
<!-- Tal en opgave ind. Kun på telefonen, og kun hvor der er opgaver — se
komponenten.
UBETINGET monteret, og det er ikke en forglemmelse: feltet inde i den
skal kunne få fokus i samme hug som klikket, ellers åbner iOS ikke
tastaturet. Et `{#if}` her ville tage feltet ud af DOM'en og gøre det
umuligt. Menuen dækker den i stedet, fordi den har højere z-index. -->
<DictateSheet />
</div>
<CommandPalette bind:open={paletteOpen} />
+3 -1
View File
@@ -730,8 +730,10 @@
const note = await api.createNote({ body: '' });
notes = [note, ...notes];
// Svaret er hele noten, så den skal ikke hentes igen — se open().
// Markøren sættes af editoren selv, når noten er tom — se
// placeCaretInTitle i NoteEditor. Her stod en focus() på
// `.editor textarea`, og editoren har ingen textarea.
await open(note, { whole: true });
document.querySelector('.editor textarea')?.focus();
} catch (e) {
app.toast(humanMessage(e));
}
+5 -12
View File
@@ -7,25 +7,18 @@
* hvad appen gør, når den så er startet: husker hvilken dør den kom ind ad, og
* går videre til noterne.
*
* `sessionStorage` og ikke `localStorage`, og det er hele grunden til at de to
* apps kan være to apps: de deler origin, så de deler localStorage, cookies og
* service worker. sessionStorage er per vindue — og en installeret PWA er sit
* eget vindue — så urd-appen har sit ansigt og verdande-appen sit, uden at
* træde på hinanden. Det overlever også en genindlæsning, hvilket det skal:
* efter et par klik står adressen på /noter, og en F5 dér går ikke gennem /urd
* igen.
* Ansigtet sættes ikke her, men i layoutet, hvor begge døre står ved siden af
* hinanden — `/urd` sætter urd og `/` sætter verdande. Delt op, fordi en regel
* med to halvdele, der ligger i hver sin fil, er en regel, hvor kun den ene
* halvdel bliver vedligeholdt.
*
* `replaceState`, så telefonens tilbage-gestus ikke fører tilbage til en side,
* der kun omdirigerer.
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { app } from '$lib/stores.svelte.js';
onMount(() => {
app.enterUrd();
goto('/noter', { replaceState: true });
});
onMount(() => goto('/noter', { replaceState: true }));
</script>
<!-- Bevidst tom. Omdirigeringen sker i samme hug som tegningen, så alt her ville