Compare commits
6
Commits
475ecbe7ef
...
6178cbbdd8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6178cbbdd8 | ||
|
|
43bd405e36 | ||
|
|
b73f55ad0b | ||
|
|
159029675c | ||
|
|
60795126a5 | ||
|
|
664614b924 |
@@ -5793,3 +5793,96 @@ test('en note oprettet fra et projekt åbner også med markøren i overskriften'
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user