Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0110a86858 | ||
|
|
c868be8f89 | ||
|
|
145fefea02 | ||
|
|
5d911d40fe | ||
|
|
9788bbaaf8 | ||
|
|
f68d6a8c15 | ||
|
|
ff0447317b | ||
|
|
a2f6e9bda3 | ||
|
|
1bbd36c79b | ||
|
|
54359f8d68 | ||
|
|
f400705bb7 |
+18
-10
@@ -1,13 +1,21 @@
|
||||
name: Documentation
|
||||
|
||||
# The docs are built on every push so a broken build is caught immediately, but
|
||||
# only published once GitHub Pages has been enabled for the repository. Until the
|
||||
# owner decides to publish, this job builds and stops.
|
||||
# Built on every push, and published from main.
|
||||
#
|
||||
# And on pull requests, where the build is the only check there is: `mkdocs build
|
||||
# --strict` fails on a link to a page that does not exist, and building only after
|
||||
# the merge means finding that out on `main`. Nothing is published from a pull
|
||||
# request — the `deploy` job below runs on a manual dispatch and nothing else.
|
||||
# The publishing used to wait for a manual dispatch, with a note saying it would
|
||||
# run "once Pages is enabled". Pages was enabled — and the condition was never
|
||||
# changed, so the site was built forty times and published none of them. What it
|
||||
# showed was a Verdande from before v0.43: /links/, /beacon/ and /notifications/
|
||||
# all answered 404, months after they were written. Nothing failed, because
|
||||
# building *is* what the job was asked to do.
|
||||
#
|
||||
# The lesson is not about this line. It is that "publish later" needs something
|
||||
# that fails when later arrives — a switch that is off and silent looks exactly
|
||||
# like a switch that is on.
|
||||
#
|
||||
# Pull requests still build and never publish: `mkdocs build --strict` fails on a
|
||||
# link to a page that does not exist, and building only after the merge means
|
||||
# finding that out on `main`.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -54,9 +62,9 @@ jobs:
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
# Only runs once Pages is enabled. Until then the build above still guards
|
||||
# against a broken docs tree.
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
# From main, and by hand. Never from a pull request — a fork could otherwise
|
||||
# publish over the site by opening one.
|
||||
if: github.event_name != 'pull_request'
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deploy.outputs.page_url }}
|
||||
|
||||
@@ -72,13 +72,44 @@ jobs:
|
||||
#
|
||||
# Den sidste linje er et link til udgivelsen selv, og den tages af: en
|
||||
# side, der linker til sig selv, hjælper ingen.
|
||||
#
|
||||
# Og `fetch-depth: 0` er ikke nok, hvilket kostede to udgivelser at opdage.
|
||||
# actions/checkout henter først alle tags korrekt — og overskriver derefter
|
||||
# netop den ene, der udløste kørslen:
|
||||
#
|
||||
# git rev-parse refs/tags/v0.46.0
|
||||
# git fetch --no-tags origin +0dc4c2f9b8…:refs/tags/v0.46.0
|
||||
#
|
||||
# Den henter commit-SHA'en ind i tag-refen, så den annoterede tag bliver til
|
||||
# en letvægtstag, der peger direkte på commit'en. `%(contents)` giver så
|
||||
# commitbeskeden, og det var flettecommittens overskrift, der stod som
|
||||
# udgivelsestekst på v0.45.0 og v0.46.0 — uden at noget fejlede.
|
||||
#
|
||||
# Derfor hentes tagget som *ref* igen her, før det læses.
|
||||
- id: notes
|
||||
run: |
|
||||
git fetch --force origin \
|
||||
"refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
||||
|
||||
body=$(git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" \
|
||||
| sed '\#^https://github.com/.*/releases/tag/#d' \
|
||||
| sed -e :a -e '/^\n*$/{$d;N;ba' -e '}')
|
||||
|
||||
# Hver tagbesked begynder med sin egen version. Holder det ikke, læser vi
|
||||
# noget andet end tagget — og en forkert udgivelsestekst skal fejle her
|
||||
# frem for at blive opdaget på siden bagefter.
|
||||
case "$body" in
|
||||
"${GITHUB_REF_NAME}"*) ;;
|
||||
*)
|
||||
echo "::error::Tagbeskeden begynder ikke med ${GITHUB_REF_NAME}." \
|
||||
"Læste vi en letvægtstag? Første linje: $(printf '%s' "$body" | head -1)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
{
|
||||
echo 'body<<VERDANDE_EOF'
|
||||
git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" \
|
||||
| sed '\#^https://github.com/.*/releases/tag/#d' \
|
||||
| sed -e :a -e '/^\n*$/{$d;N;ba' -e '}'
|
||||
printf '%s\n' "$body"
|
||||
echo VERDANDE_EOF
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ Which is what a to-do list is.
|
||||
All six phases are built. See [What works today](#what-works-today) for what is
|
||||
tested and what is not.
|
||||
|
||||
Documentation: [`docs/`](docs/), built with Material for MkDocs.
|
||||
Documentation: **<https://kristianwind.github.io/verdande/>** — the source is in
|
||||
[`docs/`](docs/), built with Material for MkDocs and published from `main`.
|
||||
|
||||
## Why this exists
|
||||
|
||||
@@ -43,6 +44,10 @@ and an MCP endpoint so Claude can work your task list directly.
|
||||
the text, which shares it with her and tells her. `#project` and
|
||||
`[[another note]]` are links, and a task shows the notes that mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
|
||||
the exporter in [`tools/`](tools/) — and comes back out the same shape.
|
||||
- **Notes are sealed at rest.** Title and body are encrypted in the database under
|
||||
a key in a file beside it — the title too, because a title beside a sealed body
|
||||
leaves the beginning of every note in the open. A copy of the file is not a copy
|
||||
of the notes.
|
||||
- **Drag where it means something.** Reorder a list or a board, file a project under
|
||||
a group, drop a task on another project, or drop it on another day.
|
||||
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
|
||||
@@ -128,6 +133,17 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
||||
| OpenAPI 3.1 spec, checked against the router | Done, tested |
|
||||
| End-to-end smoke tests (Playwright) | Done |
|
||||
| Documentation, landing page, licence | Done |
|
||||
| Notes sealed at rest: title and body encrypted in the database | Done, tested |
|
||||
| `@name` in a note shares it with that person, and tells them | Done, tested |
|
||||
| Share a project with somebody who already has an account, by name | Done, tested |
|
||||
| Invitations that have not been taken up stay visible, and can be withdrawn | Done, tested |
|
||||
| Told when a project is shared with you — and able to leave it | Done, tested |
|
||||
| Push from other programs: a webhook address per person | Done, tested |
|
||||
| Today's plan as a card on **I dag**, not only as a notification | Done, tested |
|
||||
| Search the settings, from the page or from ⌘K | Done, tested |
|
||||
| Choose which browser a link opens in | Done, tested |
|
||||
| Images in notes: open full size, copy to the clipboard | Done, tested |
|
||||
| Install count: two values, once a day, one click to stop | Done, tested |
|
||||
|
||||
**Not finished:** Web Push, the AI providers and the Google Calendar API calls are
|
||||
implemented against their specifications and have only ever been exercised there,
|
||||
|
||||
@@ -41,6 +41,16 @@ it in an export. Every raster format works; SVG is stored but not shown inline
|
||||
it is a document that can run script, and a note is not a place to run somebody
|
||||
else's.
|
||||
|
||||
**Click one to see it whole.** In the note it is scaled down to the width of the
|
||||
column — a photo off a phone is four thousand pixels across — so what you are
|
||||
looking at is a thumbnail that does not look like one. The full-size view has a
|
||||
button that copies the picture to the clipboard, and a link that opens it in its
|
||||
own tab. Escape closes it and puts the cursor back where it was.
|
||||
|
||||
Copying converts to PNG on the way out when it has to. Clipboards take PNG and
|
||||
little else, and a photo off a phone is a JPEG — without the conversion the copy
|
||||
either fails or succeeds with something nothing will paste.
|
||||
|
||||
### Code blocks
|
||||
|
||||
A fenced block is coloured for the language it names, and guesses when it does not.
|
||||
@@ -128,6 +138,14 @@ Search finds a word wherever it is:
|
||||
body. A word in the title is what the note is about; the same word once in a long
|
||||
body is a mention.
|
||||
|
||||
Two places to search from, and they now agree. The field above the list filters it
|
||||
in place. **⌘K** searches everything at once — tasks, projects, notes and the
|
||||
settings — and picking a note from it opens that note *with the rest of the matches
|
||||
still in the list beside it*. It used to drop you on the ordinary list, so the
|
||||
other results existed only in the popup you had just closed. The address carries
|
||||
the search, which also means a search worth keeping can be bookmarked or sent to
|
||||
somebody.
|
||||
|
||||
## Putting notes away
|
||||
|
||||
Two different things, and the difference matters:
|
||||
|
||||
+58
-11
@@ -1326,6 +1326,64 @@
|
||||
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
|
||||
</p>
|
||||
<ul class="lately">
|
||||
<li>
|
||||
<span class="ver">0.47</span>
|
||||
Click a picture in a note and it opens at full size, with a button that
|
||||
copies it to the clipboard. In the note it is scaled to the width of the
|
||||
column — a photo off a phone is four thousand pixels across — so what you
|
||||
were looking at was a thumbnail that did not look like one.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.47</span>
|
||||
Search in ⌘K, pick a note, and the rest of the matches stay in the list
|
||||
beside it. They used to exist only in the popup you had just closed.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.46</span>
|
||||
Share a project with somebody who already has an account by picking their
|
||||
name, rather than by spelling an email address you would have to know.
|
||||
Invitations that have not been taken up stay visible so they do not get
|
||||
sent twice, and can be withdrawn.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.46</span>
|
||||
You are told when a project is shared with you, and there is a
|
||||
<strong>Leave the project</strong> button. Being let in without being
|
||||
asked, without being told, and with no way out was three things at once.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.45</span>
|
||||
Search for a setting instead of remembering which tab it was under — in
|
||||
the settings themselves and in ⌘K. It knows them by what they do, so
|
||||
<em>beacon</em> finds the install count even though that is not its name.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.45</span>
|
||||
Today's plan is a card on <strong>I dag</strong> rather than only a
|
||||
message in the morning that was gone if you did not read it at once. And
|
||||
a link can be told which browser to open in.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.44</span>
|
||||
An optional AI layer, on your own key: it tidies the inbox, pulls the
|
||||
tasks out of a meeting note, writes a morning plan, turns
|
||||
<em>"SV: SV: Vedr. levering uge 12"</em> into what the mail actually asks
|
||||
for, and answers questions about your own notes with links to what the
|
||||
answer rests on. Every one of them is a line you accept, edit or refuse —
|
||||
it suggests, it never writes.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.44</span>
|
||||
Administrators are told in the bell when a new version is out, once per
|
||||
version, and the message leads to the button that restarts.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.43</span>
|
||||
Anything that can call a URL can put a task in your inbox — a shortcut on
|
||||
your phone, a script, a service. Notes can be handed to somebody who has
|
||||
no account yet, and writing <code>@Sofie</code> in a note shares it with
|
||||
her and tells her. Unread counts show on the app's icon in the Dock.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.42</span>
|
||||
A sixth theme, <strong>Charcoal</strong> — a warm near-black after Apple
|
||||
@@ -1362,17 +1420,6 @@
|
||||
body to move it. A single note can be shared with one person, and a
|
||||
<code>[[note]]</code> is a link you follow inline.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.34</span>
|
||||
Sections can be dragged into the order the work actually happens in. The
|
||||
sidebar keeps its head still while the projects scroll.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.33</span>
|
||||
Notes grew an archive, selection of many at once, pictures you can paste
|
||||
or drag in, a source view, and a copy button on code blocks. Search
|
||||
stopped answering by date and started answering by relevance.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -4635,3 +4635,189 @@ test('et projekt kan deles med en, der allerede har en konto', async ({ browser,
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* En søgning i ⌘K efterlader resultaterne, den fandt.
|
||||
*
|
||||
* Paletten viste de noter, der passede, i sin egen liste — og når man valgte en,
|
||||
* åbnede den noten på en side, hvis liste stod helt uændret. De andre træffere var
|
||||
* væk, og det eneste sted, de nogensinde havde været, var en popup, der lige var
|
||||
* lukket. Rapporteret sådan her: "jeg får kun præsenteret en popup liste med
|
||||
* resultater, men hvis jeg trykker retur, kommer jeg tilbage til den normale liste
|
||||
* — ikke resultater."
|
||||
*
|
||||
* Prøven laver sine egne noter med et ord, ingen anden note i basen har. Et ord
|
||||
* lånt fra en tidligere prøve ville gøre den her afhængig af rækkefølgen, og den
|
||||
* kobling har allerede narret mig én gang i den her fil.
|
||||
*/
|
||||
test('en søgning i ⌘K efterlader de træffere, den fandt', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
// Gemt ved at forlade feltet, som resten af fladen gør det — der er ingen
|
||||
// Gem-knap at huske.
|
||||
//
|
||||
// Der ventes på "Gemt", før den næste note laves. Uden det klikkes "Ny note",
|
||||
// mens den forrige stadig er undervejs, og listen når ikke at få den med — det
|
||||
// fejlede i CI én gang ud af tre kørsler, og bestod hver gang her på maskinen.
|
||||
// En prøve, der er hurtigere end programmet, måler maskinen den kører på.
|
||||
for (const title of ['Fyrreskov nord', 'Fyrreskov syd', 'Egeskov']) {
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const body = page.getByLabel('Notens tekst');
|
||||
await body.fill(`${title}\n\nnoget om den`);
|
||||
await body.blur();
|
||||
await expect(page.getByText('Gemt', { exact: true })).toBeVisible();
|
||||
await expect(page.locator('.list').getByText(title, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /Søg/ }).click();
|
||||
const palette = page.getByRole('dialog');
|
||||
await palette.getByRole('textbox').fill('Fyrreskov');
|
||||
await expect(palette.locator('li button', { hasText: 'Fyrreskov nord' })).toBeVisible();
|
||||
await palette.locator('li button', { hasText: 'Fyrreskov nord' }).click();
|
||||
|
||||
// Noten er åben …
|
||||
await expect(page).toHaveURL(/\/noter\?note=[^&]+&q=Fyrreskov/);
|
||||
await expect(page.getByLabel('Notens tekst')).toContainText('Fyrreskov nord');
|
||||
|
||||
// … og listen ved siden af den er de andre træffere, ikke den normale liste.
|
||||
await expect(page.getByPlaceholder('Søg i noter')).toHaveValue('Fyrreskov');
|
||||
const list = page.locator(".list");
|
||||
await expect(list.getByText('Fyrreskov syd')).toBeVisible();
|
||||
await expect(list.getByText('Egeskov')).toHaveCount(0);
|
||||
|
||||
// Og adressen lyver ikke, når feltet tømmes: uden det ville et genbesøg hente
|
||||
// en søgning tilbage, man selv havde ryddet væk.
|
||||
await page.getByPlaceholder('Søg i noter').fill('');
|
||||
await expect(page).not.toHaveURL(/[?&]q=/);
|
||||
await expect(list.getByText('Egeskov')).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
/**
|
||||
* Et indsat billede kan ses i fuld størrelse og kopieres videre.
|
||||
*
|
||||
* I arket er et billede skaleret ned til spaltens bredde — et foto fra en telefon
|
||||
* er fire tusind pixels bredt — så det, man kigger på, er et miniaturebillede, der
|
||||
* ikke ser ud som et. Der var ingen vej fra det til billedet selv.
|
||||
*
|
||||
* Prøven indsætter billedet ad den vej, folk gør det: en rigtig paste-hændelse med
|
||||
* en fil i udklipsholderen. Et billede lagt ind ved at skrive markdown ville springe
|
||||
* hele vedhæftningen over og dermed netop den kode, det her handler om.
|
||||
*/
|
||||
test('et indsat billede kan åbnes stort og kopieres', async ({ page, context }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
// Kopiering til udklipsholderen kræver lov. Uden den fejler knappen, og prøven
|
||||
// ville måle en browserindstilling frem for programmet.
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
|
||||
await page.goto('/noter');
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const body = page.getByLabel('Notens tekst');
|
||||
await body.fill('Billednote');
|
||||
|
||||
// En rigtig 1×1 JPEG, og med vilje ikke en PNG: udklipsholderen tager kun PNG,
|
||||
// så et foto skal tegnes om undervejs — og et foto fra en telefon ER en JPEG.
|
||||
// Med en PNG her ville prøven køre forbi netop den kode, den skal vogte.
|
||||
await body.evaluate((el) => {
|
||||
const jpeg =
|
||||
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a' +
|
||||
'HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA' +
|
||||
'AAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==';
|
||||
const bytes = Uint8Array.from(atob(jpeg), (c) => c.charCodeAt(0));
|
||||
const file = new File([bytes], 'skud.jpg', { type: 'image/jpeg' });
|
||||
const data = new DataTransfer();
|
||||
data.items.add(file);
|
||||
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
const image = page.locator('[aria-label="Notens tekst"] img');
|
||||
await expect(image).toBeVisible();
|
||||
|
||||
// Klikket åbner det, og ruden viser det samme billede.
|
||||
await image.click();
|
||||
const shot = page.getByRole('dialog', { name: 'Billede' });
|
||||
await expect(shot).toBeVisible();
|
||||
await expect(shot.locator('img')).toBeVisible();
|
||||
|
||||
// Kopiér-knappen skal gøre noget. En knap, der tier, er værre end ingen knap:
|
||||
// man indsætter noget andet og opdager det et helt andet sted.
|
||||
await shot.getByRole('button', { name: 'Kopiér billedet' }).click();
|
||||
await expect(shot.getByRole('button', { name: 'Kopieret' })).toBeVisible();
|
||||
await expect(shot.getByText('Kunne ikke kopiere')).toHaveCount(0);
|
||||
|
||||
// Escape lukker, og teksten har fokus igen — ellers står fokus på en knap, der
|
||||
// ikke findes mere, og den næste tast går ingen steder.
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(shot).toHaveCount(0);
|
||||
await expect(body).toBeFocused();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
/**
|
||||
* En opgaves tekst kan læses færdig, også på en telefon.
|
||||
*
|
||||
* Rapporteret fra en telefon, med et billede af en beskrivelse, der manglede sit
|
||||
* første bogstav på hver linje: "hermostat RU0910301440" og "atteries". Det er
|
||||
* ikke manglende tegn — det er et felt, der er rullet til højre. En beskrivelse er
|
||||
* tit et klippet stykke mail med en URL i, et <textarea> ombryder ved mellemrum,
|
||||
* og en URL har ingen.
|
||||
*
|
||||
* Og titlen var et <input>, som ikke kan ombryde overhovedet. "tado°: Your Smart
|
||||
* Thermostat needs new batteries" er en almindelig opgavetitel, og halvdelen af
|
||||
* den stod uden for kanten.
|
||||
*/
|
||||
test('en opgaves tekst kan læses færdig på en telefon', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
|
||||
const langTitel = 'tado°: Your Smart Thermostat needs new batteries';
|
||||
// "i dag" for at den lander på siden, prøven står på — parseren klipper det ud
|
||||
// af titlen igen, så den hedder præcis det, den skal.
|
||||
await page.getByLabel('Ny opgave').fill(`${langTitel} i dag`);
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
await page.getByText('tado°', { exact: false }).first().click();
|
||||
|
||||
const titel = page.getByLabel('Opgavens tekst');
|
||||
await expect(titel).toBeVisible();
|
||||
await expect(titel).toHaveValue(langTitel);
|
||||
|
||||
// Beskrivelsen: en URL uden et eneste mellemrum at bryde ved.
|
||||
const url = 'https://link.tado.com/emails/8/ttid/1-a37f2c9b4e6d8a1f0c3b5e7d9a2f4c6b8e0d1a3f5c7b9e2d4a6f8c0b3e5d7a9f';
|
||||
const beskrivelse = page.getByLabel('Beskrivelse');
|
||||
await beskrivelse.fill(`Thermostat RU0910301440\nBatteries\n${url}`);
|
||||
await beskrivelse.blur();
|
||||
|
||||
// Målt frem for beskrevet: et felt, der ruller vandret, har en scrollWidth
|
||||
// større end sin clientWidth. Det var dét, der klippede venstre kant.
|
||||
//
|
||||
// Felterne måles gennem deres egne lokatorer og ikke med en forespørgsel på
|
||||
// aria-label: beskrivelsen har et <label for>, ikke et aria-label, så en
|
||||
// forespørgsel fandt ingenting og ville have bestået tomt.
|
||||
const spild = [];
|
||||
for (const [navn, felt] of [
|
||||
['Opgavens tekst', titel],
|
||||
['Beskrivelse', beskrivelse]
|
||||
]) {
|
||||
const m = await felt.evaluate((el) => ({ ind: el.scrollWidth, ude: el.clientWidth }));
|
||||
if (m.ind - m.ude > 1) spild.push(`${navn}: ${m.ind} > ${m.ude}`);
|
||||
}
|
||||
expect(spild).toEqual([]);
|
||||
|
||||
// Beskrivelsen løber ikke over i Chromium, heller ikke uden reglen — det var
|
||||
// Safari, den blev set i, og det er en anden motor. Prøven måler derfor reglen
|
||||
// selv frem for dens virkning. Det er svagere, og det står her, så den næste
|
||||
// ved det: den vogter mod at reglen bliver slettet, ikke mod at den er forkert.
|
||||
await expect(beskrivelse).toHaveCSS('overflow-wrap', 'break-word');
|
||||
|
||||
// Og titlen er blevet høj nok til at rumme sig selv frem for at klippe.
|
||||
const linjer = await titel.evaluate((el) => ({
|
||||
hoejde: el.clientHeight,
|
||||
linje: parseFloat(getComputedStyle(el).lineHeight)
|
||||
}));
|
||||
expect(linjer.hoejde).toBeGreaterThan(linjer.linje * 1.5);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -125,7 +125,13 @@
|
||||
// En indstilling bærer sin egen adresse i sit id — der er ingen række at slå
|
||||
// op, den ER stedet.
|
||||
if (item.kind === 'setting') return goto(item.id);
|
||||
if (item.kind === 'note') return goto(`/noter?note=${item.id}`);
|
||||
// Søgningen følger med noten. Uden den åbner man én træffer på en side, hvis
|
||||
// liste står uændret — og de andre træffere var kun at se i den popup, man
|
||||
// lige lukkede.
|
||||
if (item.kind === 'note') {
|
||||
const q = query.trim();
|
||||
return goto(`/noter?note=${item.id}` + (q ? `&q=${encodeURIComponent(q)}` : ''));
|
||||
}
|
||||
goto(item.kind === 'project' ? `/projekt/${item.id}` : `/projekt/${item.project}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -640,9 +640,69 @@
|
||||
onopennote?.(link.getAttribute('data-note') ?? link.textContent);
|
||||
return;
|
||||
}
|
||||
// Et billede i en note er et billede, man har indsat for at kunne se det. I
|
||||
// arket er det skaleret ned til spaltens bredde — et foto fra en telefon er
|
||||
// fire tusind pixels bredt — så det, man kigger på, er et miniaturebillede,
|
||||
// der ikke ser ud som et.
|
||||
const image = event.target.closest?.('img');
|
||||
if (image) {
|
||||
event.preventDefault();
|
||||
showing = { src: image.currentSrc || image.src, alt: image.alt ?? '' };
|
||||
copied = false;
|
||||
copyError = '';
|
||||
return;
|
||||
}
|
||||
colourCode();
|
||||
}
|
||||
|
||||
// --- billedet i fuld størrelse ------------------------------------------------
|
||||
|
||||
let showing = $state(null);
|
||||
let copied = $state(false);
|
||||
let copyError = $state('');
|
||||
|
||||
function closeImage() {
|
||||
showing = null;
|
||||
// Tilbage i teksten, hvor man var. Uden det står fokus på en knap, der ikke
|
||||
// findes længere, og den næste tast går ingen steder.
|
||||
editor?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Billedet som PNG, uanset hvad det kom som.
|
||||
*
|
||||
* Udklipsholderen tager kun PNG i de fleste browsere, og et foto fra en telefon
|
||||
* er en JPEG. Så et billede, der ikke allerede er PNG, tegnes om — ellers
|
||||
* lykkes skrivningen enten ikke, eller den lykkes med noget, ingen kan indsætte.
|
||||
*/
|
||||
async function asPNG(src) {
|
||||
const blob = await (await fetch(src)).blob();
|
||||
if (blob.type === 'image/png') return blob;
|
||||
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
canvas.getContext('2d').drawImage(bitmap, 0, 0);
|
||||
return new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||||
}
|
||||
|
||||
async function copyImage() {
|
||||
copyError = '';
|
||||
try {
|
||||
// Løftet gives videre frem for at blive ventet af først. Safari kræver, at
|
||||
// clipboard.write kaldes i selve klikket, og en hentning imellem gør
|
||||
// klikket til fortid — et ClipboardItem må gerne indeholde et løfte.
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': asPNG(showing.src) })]);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
} catch (e) {
|
||||
// Sagt højt frem for tavst. En kopiér-knap, der ikke gør noget, er værre
|
||||
// end ingen knap: man indsætter noget andet og opdager det et andet sted.
|
||||
copyError = t('notes.copyFailed');
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces the half-typed tag with the whole thing. */
|
||||
function accept(item) {
|
||||
const partial = partialTag();
|
||||
@@ -1068,7 +1128,102 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Billedet i fuld størrelse. Uden for .wrap, fordi editoren har sin egen
|
||||
rulning og sin egen stablingskontekst — et lag inde i den ville blive klippet
|
||||
af netop det ark, det skal lægge sig over. -->
|
||||
{#if showing}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div class="lightbox" onclick={closeImage} role="dialog" aria-modal="true" aria-label={t('notes.imageTitle')}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div class="shot" onclick={(e) => e.stopPropagation()}>
|
||||
<img src={showing.src} alt={showing.alt} />
|
||||
<div class="tools">
|
||||
<button onclick={copyImage}>{copied ? t('notes.imageCopied') : t('notes.imageCopy')}</button>
|
||||
<a href={showing.src} target="_blank" rel="noopener">{t('notes.imageOpen')}</a>
|
||||
<button class="shut" onclick={closeImage}>{t('detail.close')}</button>
|
||||
</div>
|
||||
{#if copyError}<p class="bad">{copyError}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(e) => {
|
||||
if (showing && e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
closeImage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
/* Over alt andet og midt på skærmen. Baggrunden er mørk nok til at billedet
|
||||
står alene — det er hele grunden til at åbne det. */
|
||||
.lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: rgb(0 0 0 / 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--s4);
|
||||
}
|
||||
|
||||
.shot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* `min-height: 0` hører med: uden den nægter et flex-element at blive lavere
|
||||
end sit indhold, og et højt billede vokser ud over skærmen i stedet for at
|
||||
passe sig ind i den. */
|
||||
.shot img {
|
||||
min-height: 0;
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 8rem);
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.tools {
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tools button,
|
||||
.tools a {
|
||||
padding: var(--s2) var(--s3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-size: var(--text-sm);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tools button:hover,
|
||||
.tools a:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.bad {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Markøren siger, at billedet er til at klikke på, før man prøver. */
|
||||
.page :global(img) {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -171,6 +171,32 @@
|
||||
* took is said out loud, because a title that edits itself without a word is
|
||||
* worse than one that does nothing.
|
||||
*/
|
||||
/**
|
||||
* Lader et felt være lige så højt som sin tekst.
|
||||
*
|
||||
* `height: auto` først, og så `scrollHeight`: uden nulstillingen kan feltet kun
|
||||
* vokse, for scrollHeight på et felt, der allerede er for højt, er dets egen
|
||||
* højde.
|
||||
*/
|
||||
function grow(node) {
|
||||
const fit = () => {
|
||||
node.style.height = 'auto';
|
||||
node.style.height = `${node.scrollHeight}px`;
|
||||
};
|
||||
fit();
|
||||
node.addEventListener('input', fit);
|
||||
// Bredden skifter, når ruden gør — og en tekst, der fyldte to linjer, fylder
|
||||
// tre på en smallere skærm uden at der er rørt ved den.
|
||||
const watch = new ResizeObserver(fit);
|
||||
watch.observe(node);
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('input', fit);
|
||||
watch.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function saveContent() {
|
||||
if (!task) return;
|
||||
const trimmed = content.trim();
|
||||
@@ -418,13 +444,27 @@
|
||||
|
||||
<div class="scroll">
|
||||
<div class="field">
|
||||
<input
|
||||
<!-- Et felt frem for en linje. En opgavetitel er tit længere end en telefon
|
||||
er bred — "tado°: Your Smart Thermostat needs new batteries" er en
|
||||
almindelig en — og et <input> kan ikke ombryde: det ruller, og så står
|
||||
halvdelen af overskriften uden for kanten. -->
|
||||
<textarea
|
||||
class="title"
|
||||
rows="1"
|
||||
use:focusOnMount
|
||||
use:grow
|
||||
bind:value={content}
|
||||
onblur={saveContent}
|
||||
onkeydown={(e) => {
|
||||
// Retur gemmer frem for at lave en ny linje. En titel er én linje,
|
||||
// uanset hvor mange den fylder på skærmen.
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
aria-label={t('detail.text')}
|
||||
/>
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -894,6 +934,16 @@
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
/* En beskrivelse er tit et klippet stykke mail med en URL i. Et <textarea>
|
||||
ombryder ved mellemrum, og en URL har ingen — så feltet rullede vandret,
|
||||
og på en telefon stod teksten forskudt med venstre kant klippet af. Det
|
||||
ser ikke ud som rulning; det ser ud som manglende bogstaver.
|
||||
|
||||
`break-word` og ikke `anywhere`: den første bryder kun et ord, der ikke kan
|
||||
stå på en linje alene, mens den anden bryder ved først givne lejlighed og
|
||||
delte "GarageRisteriet" midt i en stavelse. Se h1 på projektets side — den
|
||||
lektion er betalt én gang. */
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* The title is the one field that should not look like a field until you are in
|
||||
@@ -905,6 +955,10 @@
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
padding-left: 0;
|
||||
/* Højden sættes af `grow`, så håndtaget ville love noget, det ikke kan
|
||||
holde: trækker man i det, retter det næste bogstav det tilbage. */
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.title:hover {
|
||||
|
||||
@@ -173,6 +173,10 @@ export const da = {
|
||||
'notes.copyCode': 'Kopiér',
|
||||
'notes.copied': 'Kopieret',
|
||||
'notes.copyFailed': 'Kunne ikke kopiere',
|
||||
'notes.imageTitle': 'Billede',
|
||||
'notes.imageCopy': 'Kopiér billedet',
|
||||
'notes.imageCopied': 'Kopieret',
|
||||
'notes.imageOpen': 'Åbn i nyt faneblad',
|
||||
'notes.strike': 'Gennemstreget',
|
||||
'notes.code': 'Kode',
|
||||
'notes.showSource': 'Vis kilden (Markdown)',
|
||||
|
||||
@@ -170,6 +170,10 @@ export const en = {
|
||||
'notes.copyCode': 'Copy',
|
||||
'notes.copied': 'Copied',
|
||||
'notes.copyFailed': 'Could not copy',
|
||||
'notes.imageTitle': 'Image',
|
||||
'notes.imageCopy': 'Copy the image',
|
||||
'notes.imageCopied': 'Copied',
|
||||
'notes.imageOpen': 'Open in a new tab',
|
||||
'notes.strike': 'Strikethrough',
|
||||
'notes.code': 'Code',
|
||||
'notes.showSource': 'Show the source (Markdown)',
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { untrack } from 'svelte';
|
||||
import { goto, replaceState } from '$app/navigation';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
import { NOTE, startDrag } from '$lib/dnd.js';
|
||||
import { colorVar } from '$lib/colors.js';
|
||||
@@ -483,6 +484,49 @@
|
||||
load(query);
|
||||
});
|
||||
|
||||
/**
|
||||
* En søgning, man kommer med, og en søgning, man kan gå videre med.
|
||||
*
|
||||
* ⌘K viste noter, der passede, i sin egen liste — og når man valgte en, åbnede
|
||||
* den noten på en side, hvis liste stod uændret. Så var de andre træffere væk,
|
||||
* og det eneste sted, de havde været, var en popup, der lige var lukket. "Jeg
|
||||
* får kun præsenteret en popup med resultater, og trykker jeg retur, kommer jeg
|
||||
* tilbage til den normale liste."
|
||||
*
|
||||
* Nu bærer adressen søgningen, så listen ved siden af den åbne note er de andre
|
||||
* træffere. Og den skrives tilbage, når feltet ændres, så adressen kan gemmes og
|
||||
* deles — og frem for alt ikke lyver, når man har tømt feltet.
|
||||
*
|
||||
* `settled` er det, de to retninger er blevet enige om — og den læses med
|
||||
* `untrack`, hvilket er hele forskellen på at virke og ikke.
|
||||
*
|
||||
* Uden den: den nederste effekt sætter `settled`, det vækker den øverste, og
|
||||
* `replaceState` har endnu ikke opdateret `$page.url`. Den øverste læser derfor
|
||||
* den *gamle* adresse, finder en søgning, der ikke er enig med `settled`, og
|
||||
* skriver den tilbage i feltet. Man tømmer søgefeltet, og ordet kommer igen.
|
||||
* Hver effekt skal reagere på sin egen kilde — adressen og feltet — og aldrig
|
||||
* på den fælles markør.
|
||||
*/
|
||||
let settled = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
const inURL = $page.url.searchParams.get('q') ?? '';
|
||||
if (inURL === untrack(() => settled)) return;
|
||||
settled = inURL;
|
||||
draftQuery = inURL;
|
||||
query = inURL;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const now = query;
|
||||
if (now === untrack(() => settled)) return;
|
||||
settled = now;
|
||||
const url = new URL(untrack(() => $page.url));
|
||||
if (now) url.searchParams.set('q', now);
|
||||
else url.searchParams.delete('q');
|
||||
replaceState(url, {});
|
||||
});
|
||||
|
||||
// Arriving from somewhere that names a note — a task's panel, a project's page —
|
||||
// opens it rather than dropping the person on a list to find it again.
|
||||
let asked = $derived($page.url.searchParams.get('note'));
|
||||
|
||||
Reference in New Issue
Block a user