Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8022502015 | ||
|
|
b07938e351 | ||
|
|
de95df44c1 | ||
|
|
2d7b6a1d5e | ||
|
|
4bbc49fe7e | ||
|
|
db2bf619ce | ||
|
|
6116ed2e21 | ||
|
|
1ba26ee723 | ||
|
|
fcedab0b19 | ||
|
|
72b6ea3803 | ||
|
|
4f25905d81 | ||
|
|
e3eeb3037c | ||
|
|
97e6be3afa | ||
|
|
d67b61d3c6 | ||
|
|
f0d18bb800 | ||
|
|
7f8d925299 |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 315 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 328 KiB After Width: | Height: | Size: 344 KiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 328 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 216 KiB |
@@ -313,6 +313,18 @@ func (s *Server) handleExportNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Oldest first, and the id to break a tie. AllNotes comes back newest-first by
|
||||||
|
// the second, so two notes made in the same second have no order between them —
|
||||||
|
// and then which of two same-titled notes gets the plain name and which gets
|
||||||
|
// "(2)" is a coin toss that lands differently each export. Ordering the whole
|
||||||
|
// thing by when it was made settles it: the first note of a name keeps the name.
|
||||||
|
sort.SliceStable(notes, func(i, j int) bool {
|
||||||
|
if notes[i].CreatedAt.Equal(notes[j].CreatedAt) {
|
||||||
|
return notes[i].ID < notes[j].ID
|
||||||
|
}
|
||||||
|
return notes[i].CreatedAt.Before(notes[j].CreatedAt)
|
||||||
|
})
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/zip")
|
w.Header().Set("Content-Type", "application/zip")
|
||||||
w.Header().Set("Content-Disposition",
|
w.Header().Set("Content-Disposition",
|
||||||
`attachment; filename="verdande-noter-`+time.Now().Format("2006-01-02")+`.zip"`)
|
`attachment; filename="verdande-noter-`+time.Now().Format("2006-01-02")+`.zip"`)
|
||||||
|
|||||||
@@ -926,6 +926,26 @@
|
|||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
<div class="shots">
|
<div class="shots">
|
||||||
|
<figure class="shot">
|
||||||
|
<img
|
||||||
|
src="screenshots/calendar-light.webp"
|
||||||
|
alt="A week in the calendar: timed tasks drawn to their length, a fifteen-minute stand-up short and a ninety-minute planning block tall, each on the day and at the time it is due."
|
||||||
|
width="1400"
|
||||||
|
height="900"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>The calendar draws a task to its length — drag its foot to change it.</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img
|
||||||
|
src="screenshots/notes-shared-light.webp"
|
||||||
|
alt="Notes with a Shared with me group at the top of the list, holding a note somebody else shared, marked with whose it is, open on the right."
|
||||||
|
width="1400"
|
||||||
|
height="900"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>A note shared with you, gathered under Shared with me.</figcaption>
|
||||||
|
</figure>
|
||||||
<figure class="shot">
|
<figure class="shot">
|
||||||
<img
|
<img
|
||||||
src="screenshots/board.webp"
|
src="screenshots/board.webp"
|
||||||
@@ -1063,6 +1083,23 @@
|
|||||||
written about it. Archive what is done with it; the bin is for mistakes.
|
written about it. Archive what is done with it; the bin is for mistakes.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Share a note, or a page</h3>
|
||||||
|
<p>
|
||||||
|
Hand a single note to a person — to read or to edit — without standing up
|
||||||
|
a project for it. Notes other people share with you gather under
|
||||||
|
<em>Shared with me</em>, each marked with whose it is. File it in a project
|
||||||
|
instead and everyone who can read the project reads the note.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>How long it takes</h3>
|
||||||
|
<p>
|
||||||
|
A task can carry a length, and the calendar draws it to scale. Drag the
|
||||||
|
foot of a task to make it longer or shorter, or drag its body to another
|
||||||
|
day or time — the length comes along.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<h3>Waiting on others</h3>
|
<h3>Waiting on others</h3>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 38 KiB |
@@ -1225,6 +1225,69 @@ test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven ka
|
|||||||
* thing this view was before anything was laid over it — and a Google account that
|
* thing this view was before anything was laid over it — and a Google account that
|
||||||
* is not there must not take it down with it.
|
* is not there must not take it down with it.
|
||||||
*/
|
*/
|
||||||
|
test('en opgaves længde kan trækkes længere på kalenderen', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// A timed task today at ten, half an hour long. Today, so the week opens on it
|
||||||
|
// without paging, and ten is inside the hours the grid draws by default.
|
||||||
|
const today = await page.evaluate(() => {
|
||||||
|
const d = new Date();
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
});
|
||||||
|
const id = await page.evaluate(async (day) => {
|
||||||
|
const r = await fetch('/api/v1/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: 'Møde med Anders',
|
||||||
|
due_date: day,
|
||||||
|
due_time: '10:00',
|
||||||
|
duration_min: 30
|
||||||
|
})
|
||||||
|
});
|
||||||
|
return (await r.json()).id;
|
||||||
|
}, today);
|
||||||
|
|
||||||
|
await page.goto('/kalender');
|
||||||
|
await page.getByRole('button', { name: 'Uge', exact: true }).click();
|
||||||
|
|
||||||
|
const chip = page
|
||||||
|
.locator(`[data-date="${today}"] .tevent.task`)
|
||||||
|
.filter({ hasText: 'Møde med Anders' });
|
||||||
|
await expect(chip).toBeVisible();
|
||||||
|
const before = await chip.evaluate((el) => el.getBoundingClientRect().height);
|
||||||
|
|
||||||
|
// Drag the foot of the task down a good stretch. It should grow, both on screen
|
||||||
|
// and in what the server stores — a resize is a change of length, not of time.
|
||||||
|
const box = await chip.boundingBox();
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height - 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height + 130, { steps: 6 });
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () =>
|
||||||
|
page.evaluate(async (tid) => {
|
||||||
|
const r = await fetch('/api/v1/tasks/' + tid, {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Sec-Fetch-Site': 'same-origin' }
|
||||||
|
});
|
||||||
|
return (await r.json()).duration_min;
|
||||||
|
}, id)
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(30);
|
||||||
|
|
||||||
|
// And the chip is taller than it was, so the length is something you can see.
|
||||||
|
await expect.poll(async () => chip.evaluate((el) => el.getBoundingClientRect().height)).toBeGreaterThan(before);
|
||||||
|
|
||||||
|
// The time did not move: it is still a ten o'clock task.
|
||||||
|
await expect(chip.locator('.at')).toHaveText('10:00');
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('kalenderen virker uden en forbundet Google-konto', async ({ page }) => {
|
test('kalenderen virker uden en forbundet Google-konto', async ({ page }) => {
|
||||||
const trouble = watchForTrouble(page);
|
const trouble = watchForTrouble(page);
|
||||||
await page.goto('/kalender');
|
await page.goto('/kalender');
|
||||||
@@ -2440,7 +2503,8 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p
|
|||||||
await page.keyboard.type('Fælles aftale om leveringen');
|
await page.keyboard.type('Fælles aftale om leveringen');
|
||||||
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||||
|
|
||||||
// Shared by being filed in a project. Not an access list of its own.
|
// Filing it in a project — the controls live in the Del popover in the footer.
|
||||||
|
await page.locator('.sharewrap > button').click();
|
||||||
await page.getByLabel('Læg i projekt').selectOption({ label: 'Delenoter' });
|
await page.getByLabel('Læg i projekt').selectOption({ label: 'Delenoter' });
|
||||||
await expect(page.getByText('delt med projektets folk')).toBeVisible();
|
await expect(page.getByText('delt med projektets folk')).toBeVisible();
|
||||||
|
|
||||||
@@ -2451,6 +2515,7 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p
|
|||||||
// Taken back out, it is the author's alone again.
|
// Taken back out, it is the author's alone again.
|
||||||
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
||||||
await page.getByRole('button', { name: /Fælles aftale/ }).click();
|
await page.getByRole('button', { name: /Fælles aftale/ }).click();
|
||||||
|
await page.locator('.sharewrap > button').click();
|
||||||
await page.getByLabel('Læg i projekt').selectOption({ label: 'Intet projekt' });
|
await page.getByLabel('Læg i projekt').selectOption({ label: 'Intet projekt' });
|
||||||
await expect(page.getByText('din igen')).toBeVisible();
|
await expect(page.getByText('din igen')).toBeVisible();
|
||||||
|
|
||||||
@@ -2487,10 +2552,12 @@ test('en note kan deles med en person direkte, og dukker op hos dem', async ({ b
|
|||||||
await page.keyboard.type('Ferieplan for hele holdet');
|
await page.keyboard.type('Ferieplan for hele holdet');
|
||||||
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||||
|
|
||||||
const share = page.locator('.people-share');
|
// Sharing lives in a popover behind the Del button in the footer.
|
||||||
|
await page.locator('.sharewrap > button').click();
|
||||||
|
const share = page.locator('.sharepanel');
|
||||||
await expect(share).toBeVisible();
|
await expect(share).toBeVisible();
|
||||||
await share.locator('.addshare select').first().selectOption({ label: 'Sofie' });
|
await share.locator('.addshare select').first().selectOption({ label: 'Sofie' });
|
||||||
await page.getByRole('button', { name: 'Del', exact: true }).click();
|
await share.getByRole('button', { name: 'Tilføj' }).click();
|
||||||
|
|
||||||
// She is on the note now, with the role she was given.
|
// She is on the note now, with the role she was given.
|
||||||
const row = share.locator('.sharelist li').filter({ hasText: 'Sofie' });
|
const row = share.locator('.sharelist li').filter({ hasText: 'Sofie' });
|
||||||
@@ -2557,6 +2624,47 @@ test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsnin
|
|||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[[ foreslår en note, og linket lander i noten', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/noter');
|
||||||
|
|
||||||
|
// One note to point at, then another to write the link in.
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
await ed.click();
|
||||||
|
await page.keyboard.type('Rejseplan til Berlin');
|
||||||
|
await ed.blur();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
await ed.click();
|
||||||
|
await page.keyboard.type('Mine planer');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.keyboard.type('Se ');
|
||||||
|
await page.keyboard.type('[[Rejse');
|
||||||
|
|
||||||
|
// The picker offers the note by title, and choosing it turns the text into a
|
||||||
|
// real inline link — the title, not the brackets.
|
||||||
|
const option = page.locator('.suggestions button', { hasText: 'Rejseplan til Berlin' });
|
||||||
|
await expect(option).toBeVisible();
|
||||||
|
await option.click();
|
||||||
|
const inline = ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' });
|
||||||
|
await expect(inline).toBeVisible();
|
||||||
|
|
||||||
|
// It survives the round-trip through Markdown and a reload.
|
||||||
|
await ed.blur();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole('button', { name: /Mine planer/ }).click();
|
||||||
|
await expect(ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' })).toBeVisible();
|
||||||
|
|
||||||
|
// And it is a link you can follow: clicking it opens the note it names.
|
||||||
|
await ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' }).click();
|
||||||
|
await expect(ed.locator('h1')).toHaveText('Rejseplan til Berlin');
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('en ny note begynder som titel, og linjen efter er brødtekst', async ({ page }) => {
|
test('en ny note begynder som titel, og linjen efter er brødtekst', async ({ page }) => {
|
||||||
const trouble = watchForTrouble(page);
|
const trouble = watchForTrouble(page);
|
||||||
await page.goto('/noter');
|
await page.goto('/noter');
|
||||||
@@ -2981,7 +3089,8 @@ test('en note kan oprettes inde fra et projekt og hører til det med det samme',
|
|||||||
await page.waitForTimeout(1200);
|
await page.waitForTimeout(1200);
|
||||||
|
|
||||||
// Den hører til projektet med det samme: "Læg i projekt" står på projektet,
|
// Den hører til projektet med det samme: "Læg i projekt" står på projektet,
|
||||||
// uden at nogen har valgt det bagefter.
|
// uden at nogen har valgt det bagefter. Kontrollen ligger i Del-popoveren.
|
||||||
|
await page.locator('.sharewrap > button').click();
|
||||||
await expect(page.getByLabel('Læg i projekt')).toHaveValue(/.+/);
|
await expect(page.getByLabel('Læg i projekt')).toHaveValue(/.+/);
|
||||||
const shared = await page.getByLabel('Læg i projekt').evaluate((el) => el.selectedOptions[0].textContent);
|
const shared = await page.getByLabel('Læg i projekt').evaluate((el) => el.selectedOptions[0].textContent);
|
||||||
expect(shared).toContain('Tagprojekt');
|
expect(shared).toContain('Tagprojekt');
|
||||||
@@ -3049,6 +3158,68 @@ test('en note kan trækkes hen på et projekt i sidebjælken', async ({ page })
|
|||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('en monospace-linje kan forlades — på retur og gennem menuen', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/noter');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
await ed.click();
|
||||||
|
await page.keyboard.type('Titel');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
// En monospace-linje, og så retur: linjen efter skal være brødtekst, ikke mere
|
||||||
|
// monospace. Før rettelsen holdt en <pre> på markøren, og alt derefter blev ved
|
||||||
|
// med at være kode.
|
||||||
|
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Monotype' }).click();
|
||||||
|
await page.keyboard.type('kode linje');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.keyboard.type('almindelig tekst');
|
||||||
|
|
||||||
|
// Koden står i sin blok; teksten efter står uden for den.
|
||||||
|
await expect(ed.locator('pre')).toHaveText('kode linje');
|
||||||
|
await expect(ed.locator('p').filter({ hasText: 'almindelig tekst' })).toBeVisible();
|
||||||
|
await expect(ed.locator('pre').filter({ hasText: 'almindelig tekst' })).toHaveCount(0);
|
||||||
|
|
||||||
|
// Shift+retur bliver derimod i blokken, så flerlinjet kode stadig kan skrives.
|
||||||
|
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Monotype' }).click();
|
||||||
|
await page.keyboard.type('linje et');
|
||||||
|
await page.keyboard.press('Shift+Enter');
|
||||||
|
await page.keyboard.type('linje to');
|
||||||
|
await expect(ed.locator('pre').filter({ hasText: 'linje et' })).toContainText('linje to');
|
||||||
|
|
||||||
|
// Og menuen kan føre en monospace-linje tilbage til brødtekst — dét, der før
|
||||||
|
// lagde et <p> inde i <pre> og ikke kom nogen vegne.
|
||||||
|
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Brødtekst' }).click();
|
||||||
|
// Den blok er nu brødtekst, ikke kode: den står som <p>, ikke i en <pre>.
|
||||||
|
await expect(ed.locator('p').filter({ hasText: 'linje et' })).toBeVisible();
|
||||||
|
await expect(ed.locator('pre').filter({ hasText: 'linje et' })).toHaveCount(0);
|
||||||
|
|
||||||
|
// Det overlever turen gennem Markdown. En ren note for sig, så prøven ikke
|
||||||
|
// afhænger af alt det ovenfor: kode, retur, brødtekst — gem, hent igen.
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
await ed.click();
|
||||||
|
await page.keyboard.type('Rundtur');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Monotype' }).click();
|
||||||
|
await page.keyboard.type('docker pull');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.keyboard.type('og så videre i almindelig skrift');
|
||||||
|
await ed.blur();
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole('button', { name: /Rundtur/ }).click();
|
||||||
|
await expect(ed.locator('pre')).toContainText('docker pull');
|
||||||
|
await expect(ed.locator('p').filter({ hasText: 'almindelig skrift' })).toBeVisible();
|
||||||
|
await expect(ed.locator('pre').filter({ hasText: 'almindelig skrift' })).toHaveCount(0);
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('kodeblokken har en kopiér-knap, og knappen havner ikke i filen', async ({ page }) => {
|
test('kodeblokken har en kopiér-knap, og knappen havner ikke i filen', async ({ page }) => {
|
||||||
const trouble = watchForTrouble(page);
|
const trouble = watchForTrouble(page);
|
||||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ const SHOTS = [
|
|||||||
{ name: 'detail', path: (ids) => `/opgave/${ids.detail}`, theme: 'dark' },
|
{ name: 'detail', path: (ids) => `/opgave/${ids.detail}`, theme: 'dark' },
|
||||||
{ name: 'notes', path: '/noter', theme: 'dark', prepare: openMeetingNote },
|
{ name: 'notes', path: '/noter', theme: 'dark', prepare: openMeetingNote },
|
||||||
{ name: 'notes-source', path: '/noter', theme: 'dark', prepare: showNoteSource },
|
{ name: 'notes-source', path: '/noter', theme: 'dark', prepare: showNoteSource },
|
||||||
|
// The new work, and in the light theme, which is what most people want to look
|
||||||
|
// at a landing page in. The calendar drawing tasks to their length, and a note
|
||||||
|
// somebody shared into the "Shared with me" group.
|
||||||
|
{ name: 'calendar-light', path: '/kalender', theme: 'light', prepare: showWeek },
|
||||||
|
{ name: 'notes-shared-light', path: '/noter', theme: 'light', prepare: openSharedNote },
|
||||||
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' },
|
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' },
|
||||||
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
|
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
|
||||||
// Scrolled, because the themes are the second panel on the page and a picture
|
// Scrolled, because the themes are the second panel on the page and a picture
|
||||||
@@ -114,6 +119,13 @@ async function showNoteSource(page) {
|
|||||||
await page.waitForTimeout(600);
|
await page.waitForTimeout(600);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openSharedNote(page) {
|
||||||
|
// The note Mette shared in, so the "Shared with me" group and its owner chip are
|
||||||
|
// what the picture is of rather than something the group happens to sit above.
|
||||||
|
await page.locator('button.row', { hasText: 'Packaging quotes' }).first().click();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
}
|
||||||
|
|
||||||
async function showThemes(page) {
|
async function showThemes(page) {
|
||||||
// scrollIntoView rather than Playwright's scrollIntoViewIfNeeded: the heading is
|
// scrollIntoView rather than Playwright's scrollIntoViewIfNeeded: the heading is
|
||||||
// already a few pixels inside the viewport when the page loads, so "if needed"
|
// already a few pixels inside the viewport when the page loads, so "if needed"
|
||||||
@@ -340,6 +352,23 @@ async function handOver(ids) {
|
|||||||
await api(`/tasks/${ids.detail}/comments`, {
|
await api(`/tasks/${ids.detail}/comments`, {
|
||||||
body: 'Room is booked for ten. Summer numbers are in the shared folder — the labels are still with the printer, I chase them Thursday.'
|
body: 'Room is booked for ten. Summer numbers are in the shared folder — the labels are still with the printer, I chase them Thursday.'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A note of Mette's own, handed to the owner — so their Notes page has a
|
||||||
|
// "Shared with me" group with a real note in it, marked as hers. This is the
|
||||||
|
// other half of sharing: not the project path, but a page passed to a person.
|
||||||
|
const note = await api('/notes', {
|
||||||
|
body: `# Packaging quotes
|
||||||
|
|
||||||
|
Three back, one still to come:
|
||||||
|
|
||||||
|
- **Nordfelt** — 79 øre a bag at 2,000, plates included
|
||||||
|
- **Kolding Karton** — 84 øre, faster, kinder about small runs
|
||||||
|
- Bruun still owes me a number
|
||||||
|
|
||||||
|
I'd lean Kolding for the first run and revisit at volume. #Autumn launch`
|
||||||
|
});
|
||||||
|
await api(`/notes/${note.id}/shares`, { user_id: ids.owner, role: 'editor' });
|
||||||
|
|
||||||
return { colleague: me.id };
|
return { colleague: me.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +402,8 @@ async function seed({ colleague, noteTitle }) {
|
|||||||
).padStart(2, '0')}`;
|
).padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const me = await api('/auth/me', null, 'GET');
|
||||||
|
|
||||||
// Two headings over the projects, which is what the sidebar grew for.
|
// Two headings over the projects, which is what the sidebar grew for.
|
||||||
const work = await api('/project-groups', { name: 'Work' });
|
const work = await api('/project-groups', { name: 'Work' });
|
||||||
const home = await api('/project-groups', { name: 'Home' });
|
const home = await api('/project-groups', { name: 'Home' });
|
||||||
@@ -420,6 +451,17 @@ async function seed({ colleague, noteTitle }) {
|
|||||||
{ content: 'Water the greenhouse', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id },
|
{ content: 'Water the greenhouse', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id },
|
||||||
{ content: 'Collect the parcel from the post office', due_date: day(0), labels: ['errands'] },
|
{ content: 'Collect the parcel from the post office', due_date: day(0), labels: ['errands'] },
|
||||||
|
|
||||||
|
// A handful given a time and a length, so the calendar's week draws them to
|
||||||
|
// scale rather than as a row of identical blocks — a stand-up, a long
|
||||||
|
// planning block, a call, a review. Spread across the day and the days, so
|
||||||
|
// the picture is a week that has a shape.
|
||||||
|
{ content: 'Stand-up', due_date: day(0), due_time: '09:00', duration_min: 15, project_id: launch.id, section_id: inFlight.id },
|
||||||
|
{ content: 'Planning block', due_date: day(0), due_time: '10:00', duration_min: 90, priority: 2, project_id: launch.id, section_id: planning.id, labels: ['meeting'] },
|
||||||
|
{ content: 'Call with the printer', due_date: day(0), due_time: '13:30', duration_min: 30, project_id: launch.id, labels: ['waiting'] },
|
||||||
|
{ content: 'Interview: designer', due_date: day(1), due_time: '11:00', duration_min: 60, priority: 1, project_id: launch.id, section_id: inFlight.id },
|
||||||
|
{ content: 'Budget review', due_date: day(2), due_time: '14:00', duration_min: 45, priority: 1, project_id: launch.id, section_id: inFlight.id },
|
||||||
|
{ content: 'One-to-one with Mette', due_date: day(3), due_time: '15:30', duration_min: 30, project_id: launch.id },
|
||||||
|
|
||||||
// The next couple of weeks, so Upcoming and the month grid have something in
|
// The next couple of weeks, so Upcoming and the month grid have something in
|
||||||
// them rather than a row of empty cells.
|
// them rather than a row of empty cells.
|
||||||
{ content: 'Book a plumber for the bathroom', due_date: day(1), priority: 2, project_id: house.id },
|
{ content: 'Book a plumber for the bathroom', due_date: day(1), priority: 2, project_id: house.id },
|
||||||
@@ -560,6 +602,7 @@ The opening notes are a task, so they can be ticked off: task:${detail.id}`
|
|||||||
await api('/users', { email: 'anders@example.dk' });
|
await api('/users', { email: 'anders@example.dk' });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
owner: me.id,
|
||||||
work: launch.id,
|
work: launch.id,
|
||||||
detail: detail.id,
|
detail: detail.id,
|
||||||
invite: invite.link,
|
invite: invite.link,
|
||||||
|
|||||||
@@ -286,6 +286,48 @@
|
|||||||
/** The cell lit up under the pointer. */
|
/** The cell lit up under the pointer. */
|
||||||
let over = $state(null);
|
let over = $state(null);
|
||||||
|
|
||||||
|
// A resize in progress: which task, and where its foot is being dragged to, in
|
||||||
|
// minutes since midnight. Kept apart from the stored length so the task can
|
||||||
|
// follow the handle live and only be written when the drag ends.
|
||||||
|
let resizing = $state(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dragging the foot of a timed task changes how long it is.
|
||||||
|
*
|
||||||
|
* Pointer events rather than the HTML5 drag the body uses: the two must not be
|
||||||
|
* the same gesture, or every attempt to make a task longer would instead pick it
|
||||||
|
* up and move it. stopPropagation keeps the handle's press from starting the
|
||||||
|
* move, and the column it lives in is what the minutes are measured against —
|
||||||
|
* the same box onDropAt reads a drop time from.
|
||||||
|
*/
|
||||||
|
function startResize(event, task, date) {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
const col = event.currentTarget.closest('.daycol');
|
||||||
|
if (!col || !task.due_datetime) return;
|
||||||
|
const at = new Date(task.due_datetime);
|
||||||
|
const from = at.getHours() * 60 + at.getMinutes();
|
||||||
|
resizing = { id: task.id, from, to: from + (task.duration_min || 30), date: iso(date) };
|
||||||
|
|
||||||
|
const onMove = (e) => {
|
||||||
|
const box = col.getBoundingClientRect();
|
||||||
|
const share = Math.min(Math.max((e.clientY - box.top) / box.height, 0), 1);
|
||||||
|
const minutes = hours.from * 60 + share * hours.minutes;
|
||||||
|
// Quarter-hour steps, at least a quarter long, never past midnight.
|
||||||
|
const snapped = Math.min(Math.max(Math.round(minutes / 15) * 15, from + 15), 24 * 60);
|
||||||
|
resizing = { ...resizing, to: snapped };
|
||||||
|
};
|
||||||
|
const onUp = async () => {
|
||||||
|
window.removeEventListener('pointermove', onMove);
|
||||||
|
window.removeEventListener('pointerup', onUp);
|
||||||
|
const current = resizing;
|
||||||
|
resizing = null;
|
||||||
|
if (current) await app.resize(current.id, current.to - current.from);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', onMove);
|
||||||
|
window.addEventListener('pointerup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
function onDragOver(event, date) {
|
function onDragOver(event, date) {
|
||||||
if (!carries(event, TASK)) return;
|
if (!carries(event, TASK)) return;
|
||||||
accept(event);
|
accept(event);
|
||||||
@@ -394,7 +436,16 @@
|
|||||||
if (!task.due_datetime) continue;
|
if (!task.due_datetime) continue;
|
||||||
const at = new Date(task.due_datetime);
|
const at = new Date(task.due_datetime);
|
||||||
const from = at.getHours() * 60 + at.getMinutes();
|
const from = at.getHours() * 60 + at.getMinutes();
|
||||||
out.push({ kind: 'task', ref: task, from, to: from + 30 });
|
// The task's own length, not a fixed half hour. A quarter-hour floor so a
|
||||||
|
// zero-minute task is still something to see and grab, and the end of the
|
||||||
|
// day is the ceiling. While a resize is in flight this task follows the
|
||||||
|
// handle instead of its stored length.
|
||||||
|
let to =
|
||||||
|
resizing && resizing.id === task.id
|
||||||
|
? resizing.to
|
||||||
|
: from + (task.duration_min || 30);
|
||||||
|
to = Math.min(Math.max(to, from + 15), 24 * 60);
|
||||||
|
out.push({ kind: 'task', ref: task, from, to });
|
||||||
}
|
}
|
||||||
|
|
||||||
return out.toSorted((a, b) => a.from - b.from || a.to - b.to);
|
return out.toSorted((a, b) => a.from - b.from || a.to - b.to);
|
||||||
@@ -547,12 +598,13 @@
|
|||||||
</svelte:element>
|
</svelte:element>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet taskChip(task, kind, extra = '', at = '')}
|
{#snippet taskChip(task, kind, extra = '', at = '', date = null)}
|
||||||
<!-- Draggable as well as clickable: moving something to another day is the
|
<!-- Draggable as well as clickable: moving something to another day is the
|
||||||
whole reason to be looking at a calendar. The browser needs a few pixels
|
whole reason to be looking at a calendar. The browser needs a few pixels
|
||||||
of movement before it calls a press a drag, so the click survives. -->
|
of movement before it calls a press a drag, so the click survives. -->
|
||||||
<button
|
<button
|
||||||
class={kind}
|
class={kind}
|
||||||
|
class:resizing={resizing?.id === task.id}
|
||||||
data-priority={task.priority}
|
data-priority={task.priority}
|
||||||
draggable="true"
|
draggable="true"
|
||||||
ondragstart={(e) => startDrag(e, TASK, task.id)}
|
ondragstart={(e) => startDrag(e, TASK, task.id)}
|
||||||
@@ -562,6 +614,16 @@
|
|||||||
>
|
>
|
||||||
{#if at}<span class="at">{at}</span>{/if}
|
{#if at}<span class="at">{at}</span>{/if}
|
||||||
{task.content}
|
{task.content}
|
||||||
|
<!-- Only a task standing at a time can be made longer, and only where the
|
||||||
|
grid has room to show it — the timed column, which passes the date. -->
|
||||||
|
{#if date && task.due_datetime}
|
||||||
|
<span
|
||||||
|
class="resize"
|
||||||
|
title={t('cal.resize')}
|
||||||
|
aria-hidden="true"
|
||||||
|
onpointerdown={(e) => startResize(e, task, date)}
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
@@ -632,7 +694,7 @@
|
|||||||
{#if item.kind === 'event'}
|
{#if item.kind === 'event'}
|
||||||
{@render eventChip(item.ref, date, 'tevent', place(item))}
|
{@render eventChip(item.ref, date, 'tevent', place(item))}
|
||||||
{:else}
|
{:else}
|
||||||
{@render taskChip(item.ref, 'tevent task', place(item), clock(item.from))}
|
{@render taskChip(item.ref, 'tevent task', place(item), clock(item.from), date)}
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -937,6 +999,29 @@
|
|||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The grip along the foot of a task, for dragging its length. Out of the way
|
||||||
|
until the task is hovered or being resized, so it never competes with the
|
||||||
|
words above it. */
|
||||||
|
.tevent.task .resize {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 7px;
|
||||||
|
cursor: ns-resize;
|
||||||
|
opacity: 0;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.tevent.task:hover .resize,
|
||||||
|
.tevent.task.resizing .resize {
|
||||||
|
opacity: 1;
|
||||||
|
background: linear-gradient(to bottom, transparent, var(--line-strong));
|
||||||
|
}
|
||||||
|
.tevent.task.resizing {
|
||||||
|
cursor: ns-resize;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
.tevent.task[data-priority='1'] {
|
.tevent.task[data-priority='1'] {
|
||||||
border-left-color: var(--p1);
|
border-left-color: var(--p1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { colorVar } from '$lib/colors.js';
|
import { colorVar } from '$lib/colors.js';
|
||||||
|
|
||||||
let { note, onchange, onsave } = $props();
|
let { note, notes = [], onchange, onsave, onopennote } = $props();
|
||||||
|
|
||||||
let editor;
|
let editor;
|
||||||
// Forslagslisten, så tastaturvalget kan rulle den frem.
|
// Forslagslisten, så tastaturvalget kan rulle den frem.
|
||||||
@@ -120,11 +120,99 @@
|
|||||||
|
|
||||||
function setStyle(style) {
|
function setStyle(style) {
|
||||||
stylesOpen = false;
|
stylesOpen = false;
|
||||||
|
editor?.focus();
|
||||||
if (style.key === 'bullet') return apply('insertUnorderedList');
|
if (style.key === 'bullet') return apply('insertUnorderedList');
|
||||||
if (style.key === 'numbered') return apply('insertOrderedList');
|
if (style.key === 'numbered') return apply('insertOrderedList');
|
||||||
|
|
||||||
|
// formatBlock is unreliable around <pre>. Asked to turn a monospace line back
|
||||||
|
// into body, Chromium and WebKit nest a <p> *inside* the <pre> —
|
||||||
|
// `<pre>…<p>body</p></pre>` — so the text stays monospace and there is no way
|
||||||
|
// out of the block from the menu. Do any conversion that touches a <pre> by
|
||||||
|
// hand, replacing the element rather than reformatting inside it.
|
||||||
|
const block = currentBlock();
|
||||||
|
if (block && (block.tagName === 'PRE' || style.tag === 'pre')) {
|
||||||
|
replaceBlock(block, style.tag);
|
||||||
|
return;
|
||||||
|
}
|
||||||
apply('formatBlock', style.tag);
|
apply('formatBlock', style.tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces a block element with one of another tag, keeping its contents and the
|
||||||
|
* caret. Used where execCommand cannot be trusted — converting to or from a
|
||||||
|
* <pre>, which it either nests or ignores.
|
||||||
|
*/
|
||||||
|
function replaceBlock(block, tag) {
|
||||||
|
// The copy button lives inside the <pre> so it can sit in its corner; it is
|
||||||
|
// not text and must not travel into a paragraph. addCopyButtons puts it back
|
||||||
|
// on the next render for any <pre> that still needs one.
|
||||||
|
block.querySelectorAll?.('.copy').forEach((b) => b.remove());
|
||||||
|
|
||||||
|
const el = document.createElement(tag);
|
||||||
|
while (block.firstChild) el.appendChild(block.firstChild);
|
||||||
|
if (!el.firstChild) el.appendChild(document.createElement('br'));
|
||||||
|
block.replaceWith(el);
|
||||||
|
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(el);
|
||||||
|
range.collapse(false);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
|
||||||
|
readState();
|
||||||
|
changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Steps out of a code block on Enter.
|
||||||
|
*
|
||||||
|
* A <pre> is preformatted, so the browser answers Enter by inserting a <br> and
|
||||||
|
* staying inside it — which means a monospace last line makes every line after
|
||||||
|
* it monospace too, with no way back to body by typing. Plain Enter now leaves
|
||||||
|
* the block: the tail after the caret becomes a paragraph below the <pre>, and
|
||||||
|
* that is where the caret lands. Shift+Enter is left untouched, so a soft line
|
||||||
|
* break still writes multi-line code inside the block.
|
||||||
|
*/
|
||||||
|
function exitPre(pre) {
|
||||||
|
pre.querySelectorAll('.copy').forEach((b) => b.remove());
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
|
||||||
|
// Everything from the caret to the end of the block leaves with the new line.
|
||||||
|
const tail = document.createRange();
|
||||||
|
tail.setStart(range.endContainer, range.endOffset);
|
||||||
|
tail.setEnd(pre, pre.childNodes.length);
|
||||||
|
const frag = tail.extractContents();
|
||||||
|
|
||||||
|
// A break at the head of what we carried is the empty line return was pressed
|
||||||
|
// on; it should not open the paragraph with a blank row.
|
||||||
|
while (frag.firstChild && frag.firstChild.nodeName === 'BR') frag.removeChild(frag.firstChild);
|
||||||
|
|
||||||
|
const p = document.createElement('p');
|
||||||
|
if (frag.childNodes.length) p.appendChild(frag);
|
||||||
|
// The split can leave an empty text node behind, and a block with no element
|
||||||
|
// and no text is not a caret host in Chromium — the next keystroke falls back
|
||||||
|
// into the block above. An empty paragraph needs a <br> to be stood in.
|
||||||
|
if (!p.textContent && !p.querySelector('br, img')) {
|
||||||
|
p.replaceChildren(document.createElement('br'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the same break seen from the other side, left dangling in the <pre>.
|
||||||
|
while (pre.lastChild && pre.lastChild.nodeName === 'BR') pre.removeChild(pre.lastChild);
|
||||||
|
|
||||||
|
// Leaving an empty block behind is leaving nothing: the <pre> itself goes.
|
||||||
|
if (!pre.textContent.trim()) pre.replaceWith(p);
|
||||||
|
else pre.after(p);
|
||||||
|
|
||||||
|
const caret = document.createRange();
|
||||||
|
caret.setStart(p, 0);
|
||||||
|
caret.collapse(true);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(caret);
|
||||||
|
}
|
||||||
|
|
||||||
/** What the caret is standing in, for the toolbar's pressed states. */
|
/** What the caret is standing in, for the toolbar's pressed states. */
|
||||||
function readState() {
|
function readState() {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
@@ -302,9 +390,26 @@
|
|||||||
if (!node || node.nodeType !== Node.TEXT_NODE || !selection.isCollapsed) return null;
|
if (!node || node.nodeType !== Node.TEXT_NODE || !selection.isCollapsed) return null;
|
||||||
|
|
||||||
const before = node.textContent.slice(0, selection.anchorOffset);
|
const before = node.textContent.slice(0, selection.anchorOffset);
|
||||||
const match = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
|
|
||||||
if (!match) return null;
|
// A note link, [[, is the same idea as a project tag and gets the same
|
||||||
return { node, start: selection.anchorOffset - match[1].length, term: match[1] };
|
// completion — checked first because it is the longer opener. The term runs
|
||||||
|
// to the caret and is anything up to the closing bracket, which has not been
|
||||||
|
// typed yet.
|
||||||
|
const note = /\[\[([^\]\n]*)$/u.exec(before);
|
||||||
|
if (note) {
|
||||||
|
return { kind: 'note', node, start: selection.anchorOffset - note[1].length, term: note[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
|
||||||
|
if (project) {
|
||||||
|
return {
|
||||||
|
kind: 'project',
|
||||||
|
node,
|
||||||
|
start: selection.anchorOffset - project[1].length,
|
||||||
|
term: project[1]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -348,46 +453,87 @@
|
|||||||
// pixels for hvert bogstav, mens listen snævres ind.
|
// pixels for hvert bogstav, mens listen snævres ind.
|
||||||
if (!suggestions.length) menuAt = tagPosition(partial);
|
if (!suggestions.length) menuAt = tagPosition(partial);
|
||||||
|
|
||||||
// Alle der passer, ikke de seks første.
|
suggestions = partial.kind === 'note' ? noteMatches(term) : projectMatches(term);
|
||||||
//
|
|
||||||
// Loftet på seks var sat, dengang listen ikke kunne rulle, og det gjorde
|
|
||||||
// noget værre end at skjule: med tyve projekter og et blot tastet `#` viste
|
|
||||||
// den seks vilkårlige og så færdig ud. Man kunne ikke se, at der manglede
|
|
||||||
// noget — kun at ens eget projekt ikke var der.
|
|
||||||
//
|
|
||||||
// Rækkefølgen bærer nu det, loftet gjorde forkert: det, der begynder med
|
|
||||||
// det tastede, står øverst, resten alfabetisk. Skriver man "ga", er
|
|
||||||
// GarageRisteriet den første, ikke den, der tilfældigvis lå først.
|
|
||||||
const matches = app.projects.filter(
|
|
||||||
(p) => !p.is_inbox && p.name.toLowerCase().includes(term)
|
|
||||||
);
|
|
||||||
matches.sort((a, b) => {
|
|
||||||
const an = a.name.toLowerCase();
|
|
||||||
const bn = b.name.toLowerCase();
|
|
||||||
const ap = an.startsWith(term);
|
|
||||||
const bp = bn.startsWith(term);
|
|
||||||
if (ap !== bp) return ap ? -1 : 1;
|
|
||||||
return an.localeCompare(bn, 'da');
|
|
||||||
});
|
|
||||||
suggestions = matches;
|
|
||||||
chosen = 0;
|
chosen = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replaces the half-typed tag with the whole name. */
|
// Alle der passer, ikke de seks første. Rækkefølgen bærer det: det, der begynder
|
||||||
function accept(project) {
|
// med det tastede, står øverst, resten alfabetisk. Skriver man "ga", er
|
||||||
|
// GarageRisteriet den første, ikke den, der tilfældigvis lå først.
|
||||||
|
function projectMatches(term) {
|
||||||
|
return app.projects
|
||||||
|
.filter((p) => !p.is_inbox && p.name.toLowerCase().includes(term))
|
||||||
|
.map((p) => ({ id: p.id, kind: 'project', label: p.name, color: p.color }))
|
||||||
|
.sort((a, b) => rank(a.label, b.label, term));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same completion for [[ as for #, over the notes the page has loaded. The
|
||||||
|
// current note is left out — a note that links to itself is a loop nobody meant
|
||||||
|
// — and a note with no title yet cannot be pointed at, so it is skipped too.
|
||||||
|
function noteMatches(term) {
|
||||||
|
return notes
|
||||||
|
.filter((n) => n.id !== note?.id && n.title && n.title.toLowerCase().includes(term))
|
||||||
|
.map((n) => ({ id: n.id, kind: 'note', label: n.title }))
|
||||||
|
.sort((a, b) => rank(a.label, b.label, term));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rank(a, b, term) {
|
||||||
|
const al = a.toLowerCase();
|
||||||
|
const bl = b.toLowerCase();
|
||||||
|
const ap = al.startsWith(term);
|
||||||
|
const bp = bl.startsWith(term);
|
||||||
|
if (ap !== bp) return ap ? -1 : 1;
|
||||||
|
return al.localeCompare(bl, 'da');
|
||||||
|
}
|
||||||
|
|
||||||
|
const escHtml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
const escAttr = (s) => escHtml(s).replace(/"/g, '"');
|
||||||
|
|
||||||
|
// A click on a note link follows it; a click anywhere else re-colours the code,
|
||||||
|
// as it did before. The link is contenteditable="false", so the click is a click
|
||||||
|
// and not a caret landing in the middle of a word.
|
||||||
|
function onEditorClick(event) {
|
||||||
|
const link = event.target.closest?.('.notelink');
|
||||||
|
if (link) {
|
||||||
|
event.preventDefault();
|
||||||
|
onopennote?.(link.getAttribute('data-note') ?? link.textContent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
colourCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the half-typed tag with the whole thing. */
|
||||||
|
function accept(item) {
|
||||||
const partial = partialTag();
|
const partial = partialTag();
|
||||||
if (!partial) return;
|
if (!partial) return;
|
||||||
|
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
range.setStart(partial.node, partial.start);
|
|
||||||
range.setEnd(partial.node, partial.start + partial.term.length);
|
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
selection.removeAllRanges();
|
|
||||||
selection.addRange(range);
|
|
||||||
|
|
||||||
// A trailing space, because the next thing typed is a word and not more of
|
if (item.kind === 'note') {
|
||||||
// the tag — and without it the suggestions come straight back.
|
// Swallow the opening [[ as well and drop the finished link in its place,
|
||||||
document.execCommand('insertText', false, project.name + ' ');
|
// so it is a link the moment it is chosen — not the brackets, waiting to
|
||||||
|
// become one when the note is next opened. A space after, as its own text,
|
||||||
|
// so the caret leaves the atomic link and the next word is a word.
|
||||||
|
range.setStart(partial.node, partial.start - 2);
|
||||||
|
range.setEnd(partial.node, partial.start + partial.term.length);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
document.execCommand(
|
||||||
|
'insertHTML',
|
||||||
|
false,
|
||||||
|
`<a class="notelink" data-note="${escAttr(item.label)}" contenteditable="false">${escHtml(item.label)}</a>`
|
||||||
|
);
|
||||||
|
document.execCommand('insertText', false, ' ');
|
||||||
|
} else {
|
||||||
|
range.setStart(partial.node, partial.start);
|
||||||
|
range.setEnd(partial.node, partial.start + partial.term.length);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
// A trailing space, or the suggestions come straight back for the tag.
|
||||||
|
document.execCommand('insertText', false, `${item.label} `);
|
||||||
|
}
|
||||||
|
|
||||||
suggestions = [];
|
suggestions = [];
|
||||||
onchange?.(htmlToMarkdown(editor));
|
onchange?.(htmlToMarkdown(editor));
|
||||||
}
|
}
|
||||||
@@ -470,6 +616,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
// A code block is the one place the browser keeps the caret inside on
|
||||||
|
// Enter rather than starting a new block. Step out of it ourselves, or a
|
||||||
|
// monospace last line traps everything typed after it in monospace.
|
||||||
|
const block = currentBlock();
|
||||||
|
if (block && block.tagName === 'PRE') {
|
||||||
|
event.preventDefault();
|
||||||
|
exitPre(block);
|
||||||
|
changed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
// After the split, not before it.
|
// After the split, not before it.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
keepTitleFirst();
|
keepTitleFirst();
|
||||||
@@ -678,7 +834,7 @@
|
|||||||
readState();
|
readState();
|
||||||
readSuggestions();
|
readSuggestions();
|
||||||
}}
|
}}
|
||||||
onclick={colourCode}
|
onclick={onEditorClick}
|
||||||
onmouseup={readState}
|
onmouseup={readState}
|
||||||
{onkeydown}
|
{onkeydown}
|
||||||
{onpaste}
|
{onpaste}
|
||||||
@@ -698,7 +854,7 @@
|
|||||||
style:top={menuAt ? `${menuAt.top}px` : null}
|
style:top={menuAt ? `${menuAt.top}px` : null}
|
||||||
style:left={menuAt ? `${menuAt.left}px` : null}
|
style:left={menuAt ? `${menuAt.left}px` : null}
|
||||||
>
|
>
|
||||||
{#each suggestions as project, i (project.id)}
|
{#each suggestions as item, i (item.kind + item.id)}
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
class:on={i === chosen}
|
class:on={i === chosen}
|
||||||
@@ -706,12 +862,17 @@
|
|||||||
aria-selected={i === chosen}
|
aria-selected={i === chosen}
|
||||||
onmousedown={(e) => {
|
onmousedown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
accept(project);
|
accept(item);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"
|
{#if item.kind === 'note'}
|
||||||
></span>
|
<span class="dot note" aria-hidden="true">[[</span>
|
||||||
#{project.name}
|
{item.label}
|
||||||
|
{:else}
|
||||||
|
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
#{item.label}
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -864,6 +1025,18 @@
|
|||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A note suggestion wears the brackets that open it where a project wears its
|
||||||
|
colour, so the two kinds are told apart at a glance in the same list. */
|
||||||
|
.suggestions .dot.note {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
background: none;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-family: var(--mono, ui-monospace, monospace);
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.suggestions button.on {
|
.suggestions button.on {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
@@ -1023,6 +1196,18 @@
|
|||||||
margin: var(--s2) 0;
|
margin: var(--s2) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A note link reads as a link: the accent colour, underlined on hover, and a
|
||||||
|
pointer so it is plainly something to click rather than to edit. */
|
||||||
|
.page :global(a.notelink) {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.page :global(a.notelink:hover) {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.page :global(blockquote) {
|
.page :global(blockquote) {
|
||||||
margin: 0 0 var(--s2);
|
margin: 0 0 var(--s2);
|
||||||
padding-left: var(--s3);
|
padding-left: var(--s3);
|
||||||
|
|||||||
@@ -23,6 +23,9 @@
|
|||||||
let priority = $state(4);
|
let priority = $state(4);
|
||||||
let dueDate = $state('');
|
let dueDate = $state('');
|
||||||
let dueTime = $state('');
|
let dueTime = $state('');
|
||||||
|
// Length in minutes, as a string for the field. Only meaningful with a time —
|
||||||
|
// see the field below — so it is seeded and shown alongside the clock.
|
||||||
|
let duration = $state('');
|
||||||
let labels = $state('');
|
let labels = $state('');
|
||||||
let projectId = $state('');
|
let projectId = $state('');
|
||||||
let recurrence = $state('');
|
let recurrence = $state('');
|
||||||
@@ -57,6 +60,7 @@
|
|||||||
priority = task.priority;
|
priority = task.priority;
|
||||||
dueDate = task.due_date ?? '';
|
dueDate = task.due_date ?? '';
|
||||||
dueTime = task.due_datetime ? clockOf(task.due_datetime) : '';
|
dueTime = task.due_datetime ? clockOf(task.due_datetime) : '';
|
||||||
|
duration = task.duration_min ? String(task.duration_min) : '';
|
||||||
labels = (task.labels ?? []).join(', ');
|
labels = (task.labels ?? []).join(', ');
|
||||||
projectId = task.project_id;
|
projectId = task.project_id;
|
||||||
recurrence = task.recurrence_rule ?? '';
|
recurrence = task.recurrence_rule ?? '';
|
||||||
@@ -436,6 +440,21 @@
|
|||||||
disabled={!dueDate}
|
disabled={!dueDate}
|
||||||
onchange={() => save({ due_date: dueDate, due_time: dueTime })}
|
onchange={() => save({ due_date: dueDate, due_time: dueTime })}
|
||||||
/>
|
/>
|
||||||
|
<!-- Length in minutes, and only with a time to hang it on: a duration
|
||||||
|
without a start is a length of nothing, and the calendar has
|
||||||
|
nowhere to draw it. Empty clears it. -->
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="dur"
|
||||||
|
min="0"
|
||||||
|
step="15"
|
||||||
|
aria-label={t('detail.duration')}
|
||||||
|
placeholder={t('detail.durationShort')}
|
||||||
|
bind:value={duration}
|
||||||
|
disabled={!dueTime}
|
||||||
|
onchange={() =>
|
||||||
|
save({ duration_min: duration === '' ? 0 : Math.max(0, Math.round(Number(duration))) })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -731,6 +750,11 @@
|
|||||||
width: 7.5rem;
|
width: 7.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.due input.dur {
|
||||||
|
flex: none;
|
||||||
|
width: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.due input:disabled {
|
.due input:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,13 @@ export const da = {
|
|||||||
'notes.unshared': 'Noten er din igen.',
|
'notes.unshared': 'Noten er din igen.',
|
||||||
// Deling med enkeltpersoner, ved siden af projektet.
|
// Deling med enkeltpersoner, ved siden af projektet.
|
||||||
'notes.groupSharedWithMe': 'Delt med mig',
|
'notes.groupSharedWithMe': 'Delt med mig',
|
||||||
|
'notes.linkOpen': 'Åbn',
|
||||||
|
'notes.linkMissing': 'Noten blev ikke fundet.',
|
||||||
'notes.sharedBy': 'Delt af {name}',
|
'notes.sharedBy': 'Delt af {name}',
|
||||||
'notes.sharePeople': 'Del med personer',
|
'notes.sharePeople': 'Del med personer',
|
||||||
'notes.sharePick': 'Vælg en person …',
|
'notes.sharePick': 'Vælg en person …',
|
||||||
'notes.shareAdd': 'Del',
|
'notes.shareButton': 'Del',
|
||||||
|
'notes.shareAdd': 'Tilføj',
|
||||||
'notes.shareRoleViewer': 'Kan læse',
|
'notes.shareRoleViewer': 'Kan læse',
|
||||||
'notes.shareRoleEditor': 'Kan rette',
|
'notes.shareRoleEditor': 'Kan rette',
|
||||||
'notes.shareRemove': 'Fjern adgang',
|
'notes.shareRemove': 'Fjern adgang',
|
||||||
@@ -596,6 +599,8 @@ export const da = {
|
|||||||
'detail.none': 'Ingen',
|
'detail.none': 'Ingen',
|
||||||
'detail.due': 'Forfalder',
|
'detail.due': 'Forfalder',
|
||||||
'detail.dueTime': 'Klokkeslæt',
|
'detail.dueTime': 'Klokkeslæt',
|
||||||
|
'detail.duration': 'Længde i minutter',
|
||||||
|
'detail.durationShort': 'min',
|
||||||
'detail.labels': 'Etiketter',
|
'detail.labels': 'Etiketter',
|
||||||
'detail.labelsPlaceholder': 'adskilt af komma',
|
'detail.labelsPlaceholder': 'adskilt af komma',
|
||||||
'detail.project': 'Projekt',
|
'detail.project': 'Projekt',
|
||||||
@@ -748,6 +753,7 @@ export const da = {
|
|||||||
'cal.settings': 'Indstillinger',
|
'cal.settings': 'Indstillinger',
|
||||||
'cal.noneChosen': 'Ingen af kontoens kalendere er valgt endnu.',
|
'cal.noneChosen': 'Ingen af kontoens kalendere er valgt endnu.',
|
||||||
'cal.allDay': 'Hele dagen',
|
'cal.allDay': 'Hele dagen',
|
||||||
|
'cal.resize': 'Træk for at ændre længden',
|
||||||
'cal.openInGoogle': 'Åbn i Google Kalender',
|
'cal.openInGoogle': 'Åbn i Google Kalender',
|
||||||
'cal.beyondWindow':
|
'cal.beyondWindow':
|
||||||
'Begivenheder gemmes fra {from} til {to}. Uden for det viser gitteret kun dine egne opgaver.',
|
'Begivenheder gemmes fra {from} til {to}. Uden for det viser gitteret kun dine egne opgaver.',
|
||||||
|
|||||||
@@ -18,10 +18,13 @@ export const en = {
|
|||||||
'notes.unshared': 'The note is yours again.',
|
'notes.unshared': 'The note is yours again.',
|
||||||
// Sharing with individual people, alongside the project.
|
// Sharing with individual people, alongside the project.
|
||||||
'notes.groupSharedWithMe': 'Shared with me',
|
'notes.groupSharedWithMe': 'Shared with me',
|
||||||
|
'notes.linkOpen': 'Open',
|
||||||
|
'notes.linkMissing': 'That note could not be found.',
|
||||||
'notes.sharedBy': 'Shared by {name}',
|
'notes.sharedBy': 'Shared by {name}',
|
||||||
'notes.sharePeople': 'Share with people',
|
'notes.sharePeople': 'Share with people',
|
||||||
'notes.sharePick': 'Choose someone …',
|
'notes.sharePick': 'Choose someone …',
|
||||||
'notes.shareAdd': 'Share',
|
'notes.shareButton': 'Share',
|
||||||
|
'notes.shareAdd': 'Add',
|
||||||
'notes.shareRoleViewer': 'Can view',
|
'notes.shareRoleViewer': 'Can view',
|
||||||
'notes.shareRoleEditor': 'Can edit',
|
'notes.shareRoleEditor': 'Can edit',
|
||||||
'notes.shareRemove': 'Remove access',
|
'notes.shareRemove': 'Remove access',
|
||||||
@@ -592,6 +595,8 @@ export const en = {
|
|||||||
'detail.none': 'None',
|
'detail.none': 'None',
|
||||||
'detail.due': 'Due',
|
'detail.due': 'Due',
|
||||||
'detail.dueTime': 'Time',
|
'detail.dueTime': 'Time',
|
||||||
|
'detail.duration': 'Length in minutes',
|
||||||
|
'detail.durationShort': 'min',
|
||||||
'detail.labels': 'Labels',
|
'detail.labels': 'Labels',
|
||||||
'detail.labelsPlaceholder': 'separated by commas',
|
'detail.labelsPlaceholder': 'separated by commas',
|
||||||
'detail.project': 'Project',
|
'detail.project': 'Project',
|
||||||
@@ -741,6 +746,7 @@ export const en = {
|
|||||||
'cal.settings': 'Settings',
|
'cal.settings': 'Settings',
|
||||||
'cal.noneChosen': 'None of the account’s calendars are chosen yet.',
|
'cal.noneChosen': 'None of the account’s calendars are chosen yet.',
|
||||||
'cal.allDay': 'All day',
|
'cal.allDay': 'All day',
|
||||||
|
'cal.resize': 'Drag to change the length',
|
||||||
'cal.openInGoogle': 'Open in Google Calendar',
|
'cal.openInGoogle': 'Open in Google Calendar',
|
||||||
'cal.beyondWindow':
|
'cal.beyondWindow':
|
||||||
'Events are kept from {from} to {to}. Outside that the grid shows only your own tasks.',
|
'Events are kept from {from} to {to}. Outside that the grid shows only your own tasks.',
|
||||||
|
|||||||
@@ -70,19 +70,39 @@ const attr = (s) => escapeHtml(s).replace(/"/g, '"').replace(/'/g, ''')
|
|||||||
* det overlever hverken en contenteditable eller en Markdown-fil.
|
* det overlever hverken en contenteditable eller en Markdown-fil.
|
||||||
*/
|
*/
|
||||||
const IMAGE = /!\[([^\]]*)\]\((\/api\/v1\/attachments\/[0-9a-f-]+)\)/g;
|
const IMAGE = /!\[([^\]]*)\]\((\/api\/v1\/attachments\/[0-9a-f-]+)\)/g;
|
||||||
|
// [[a note by title]], the spelling every note app settled on. Non-greedy, so two
|
||||||
|
// links on one line stay two.
|
||||||
|
const NOTELINK = /\[\[([^\]\n]+?)\]\]/g;
|
||||||
const MARK = "\u0000";
|
const MARK = "\u0000";
|
||||||
|
|
||||||
/** Inline marks, innermost first so a bold word inside italics survives both. */
|
/** Inline marks, innermost first so a bold word inside italics survives both. */
|
||||||
function inlineToHtml(text) {
|
function inlineToHtml(text) {
|
||||||
|
// Lifted out before anything escapes or marks the text, and dropped back in
|
||||||
|
// whole at the end — the same placeholder trick the image already used, now for
|
||||||
|
// two kinds of thing the surrounding markup must not touch.
|
||||||
|
const pieces = [];
|
||||||
|
const lift = (html) => {
|
||||||
|
pieces.push(html);
|
||||||
|
return `${MARK}${pieces.length - 1}${MARK}`;
|
||||||
|
};
|
||||||
|
|
||||||
// Kun vores egen adresse bliver til et billede. Et `` fra en
|
// Kun vores egen adresse bliver til et billede. Et `` fra en
|
||||||
// indsat tekst ville ellers hente fra en fremmed vært, når noten åbnes, og
|
// indsat tekst ville ellers hente fra en fremmed vært, når noten åbnes, og
|
||||||
// fortælle den, at den er blevet læst — og en note er det sidste sted, man vil
|
// fortælle den, at den er blevet læst — og en note er det sidste sted, man vil
|
||||||
// have en sporingspixel. Alt andet står som den tekst, det er.
|
// have en sporingspixel. Alt andet står som den tekst, det er.
|
||||||
const images = [];
|
let lifted = (text ?? '').replace(IMAGE, (_, alt, src) => lift(`<img src="${src}" alt="${attr(alt)}">`));
|
||||||
const lifted = (text ?? '').replace(IMAGE, (_, alt, src) => {
|
|
||||||
images.push(`<img src="${src}" alt="${attr(alt)}">`);
|
// A [[note]] is a link you can follow, not the brackets it was typed as. Made an
|
||||||
return `${MARK}${images.length - 1}${MARK}`;
|
// atomic token — contenteditable="false" — so it moves and deletes as one thing,
|
||||||
});
|
// and it carries the title it points at in data-note; the editor reads that on a
|
||||||
|
// click. htmlToMarkdown turns it back into [[title]] exactly.
|
||||||
|
lifted = lifted.replace(
|
||||||
|
NOTELINK,
|
||||||
|
(_, title) =>
|
||||||
|
lift(
|
||||||
|
`<a class="notelink" data-note="${attr(title)}" contenteditable="false">${escapeHtml(title)}</a>`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const marked = escapeHtml(lifted)
|
const marked = escapeHtml(lifted)
|
||||||
.replace(/<u>(.+?)<\/u>/g, '<u>$1</u>')
|
.replace(/<u>(.+?)<\/u>/g, '<u>$1</u>')
|
||||||
@@ -91,7 +111,7 @@ function inlineToHtml(text) {
|
|||||||
.replace(/~~(.+?)~~/g, '<s>$1</s>')
|
.replace(/~~(.+?)~~/g, '<s>$1</s>')
|
||||||
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
|
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
|
||||||
|
|
||||||
return marked.replace(new RegExp(MARK + '(\\d+)' + MARK, 'g'), (_, i) => images[Number(i)] ?? '');
|
return marked.replace(new RegExp(MARK + '(\\d+)' + MARK, 'g'), (_, i) => pieces[Number(i)] ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -234,6 +254,13 @@ function inlineToMarkdown(node) {
|
|||||||
if (node.nodeType === Node.TEXT_NODE) return node.textContent;
|
if (node.nodeType === Node.TEXT_NODE) return node.textContent;
|
||||||
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
||||||
|
|
||||||
|
// A note link goes back to the brackets it came from, by the title it carries
|
||||||
|
// rather than by the text it shows — the two are the same today, but the title
|
||||||
|
// is the thing that must survive the round trip.
|
||||||
|
if (node.tagName === 'A' && node.classList?.contains('notelink')) {
|
||||||
|
return `[[${node.getAttribute('data-note') ?? node.textContent}]]`;
|
||||||
|
}
|
||||||
|
|
||||||
const inner = [...node.childNodes].map(inlineToMarkdown).join('');
|
const inner = [...node.childNodes].map(inlineToMarkdown).join('');
|
||||||
switch (node.tagName) {
|
switch (node.tagName) {
|
||||||
// Et billede har ingen tekst i sig, så uden det her læses det som ingenting
|
// Et billede har ingen tekst i sig, så uden det her læses det som ingenting
|
||||||
|
|||||||
@@ -403,6 +403,19 @@ class AppState {
|
|||||||
await this.update(id, { due_date: date, due_time: clock });
|
await this.update(id, { due_date: date, due_time: clock });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets how long a task is, in minutes. Nothing else about it moves — the date
|
||||||
|
* and time are left alone, so making a task longer on the calendar does not
|
||||||
|
* also reschedule it.
|
||||||
|
*/
|
||||||
|
async resize(id, minutes) {
|
||||||
|
const previous = this.get(id);
|
||||||
|
if (!previous) return;
|
||||||
|
const rounded = Math.max(15, Math.round(minutes / 15) * 15);
|
||||||
|
if (previous.duration_min === rounded) return;
|
||||||
|
await this.update(id, { duration_min: rounded });
|
||||||
|
}
|
||||||
|
|
||||||
async remove(id) {
|
async remove(id) {
|
||||||
const previous = [...this.tasks];
|
const previous = [...this.tasks];
|
||||||
this.tasks = this.tasks.filter((t) => t.id !== id);
|
this.tasks = this.tasks.filter((t) => t.id !== id);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { t, tag } from '$lib/i18n.svelte.js';
|
import { t, tag } from '$lib/i18n.svelte.js';
|
||||||
import { NOTE, startDrag } from '$lib/dnd.js';
|
import { NOTE, startDrag } from '$lib/dnd.js';
|
||||||
import { colorVar } from '$lib/colors.js';
|
import { colorVar } from '$lib/colors.js';
|
||||||
@@ -193,6 +194,26 @@
|
|||||||
let shareBusy = $state(false);
|
let shareBusy = $state(false);
|
||||||
const ownsSelected = $derived(!!selected && selected.created_by === app.user?.id);
|
const ownsSelected = $derived(!!selected && selected.created_by === app.user?.id);
|
||||||
|
|
||||||
|
// The whole of sharing — a project and named people — lives in a popover behind
|
||||||
|
// one button, so the note's footer stays a status and three buttons rather than
|
||||||
|
// a row that runs off its own width.
|
||||||
|
let shareOpen = $state(false);
|
||||||
|
let shareWrap = $state(null);
|
||||||
|
// Closing on a switch of note, and only then: the panel belongs to the note it
|
||||||
|
// was opened on. Tracked by id rather than by the object, so a save that returns
|
||||||
|
// a fresh copy of the same note does not slam the panel shut mid-use.
|
||||||
|
let sharePanelFor = $state(null);
|
||||||
|
$effect(() => {
|
||||||
|
const id = selected?.id ?? null;
|
||||||
|
if (id !== sharePanelFor) {
|
||||||
|
shareOpen = false;
|
||||||
|
sharePanelFor = id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
function onShareOutside(event) {
|
||||||
|
if (shareOpen && shareWrap && !shareWrap.contains(event.target)) shareOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const id = selected?.id;
|
const id = selected?.id;
|
||||||
// Nulstil ved hvert skift, så den forrige notes folk ikke står et øjeblik
|
// Nulstil ved hvert skift, så den forrige notes folk ikke står et øjeblik
|
||||||
@@ -633,11 +654,56 @@
|
|||||||
// What the note points at, for the panel under the editor.
|
// What the note points at, for the panel under the editor.
|
||||||
let links = $derived(selected?.links ?? []);
|
let links = $derived(selected?.links ?? []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follows a link from the panel to the thing it names.
|
||||||
|
*
|
||||||
|
* A [[note]] resolves by its title to a note — the one on the list if it is
|
||||||
|
* there, otherwise looked up — and opens it in place. A #project and a task go
|
||||||
|
* to their own pages. Until now these were text and nothing else: the note said
|
||||||
|
* it pointed somewhere and there was no way to go.
|
||||||
|
*/
|
||||||
|
async function openLink(link) {
|
||||||
|
if (link.kind === 'note') {
|
||||||
|
const wanted = link.target_id.toLowerCase();
|
||||||
|
const here = notes.find((n) => (n.title ?? '').toLowerCase() === wanted);
|
||||||
|
if (here) {
|
||||||
|
open(here);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const found = (await api.notes({ q: link.target_id })).notes ?? [];
|
||||||
|
const hit = found.find((n) => (n.title ?? '').toLowerCase() === wanted) ?? found[0];
|
||||||
|
if (hit) open(hit);
|
||||||
|
else app.toast(t('notes.linkMissing'));
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (link.kind === 'project') {
|
||||||
|
const project = app.projects.find(
|
||||||
|
(p) => p.name.toLowerCase() === link.target_id.toLowerCase()
|
||||||
|
);
|
||||||
|
if (project) goto(`/projekt/${project.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (link.kind === 'task') {
|
||||||
|
goto(`/opgave/${link.target_id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
|
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
onclick={onShareOutside}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape' && shareOpen) shareOpen = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="notes">
|
<div class="notes">
|
||||||
<aside>
|
<aside>
|
||||||
<div class="head">
|
<div class="head">
|
||||||
@@ -849,6 +915,8 @@
|
|||||||
{#if selected}
|
{#if selected}
|
||||||
<NoteEditor
|
<NoteEditor
|
||||||
note={selected}
|
note={selected}
|
||||||
|
notes={notes}
|
||||||
|
onopennote={(title) => openLink({ kind: 'note', target_id: title })}
|
||||||
onchange={(body) => {
|
onchange={(body) => {
|
||||||
draft = body;
|
draft = body;
|
||||||
typed();
|
typed();
|
||||||
@@ -861,77 +929,111 @@
|
|||||||
{#if links.length}
|
{#if links.length}
|
||||||
<span class="links">
|
<span class="links">
|
||||||
{#each links as link}
|
{#each links as link}
|
||||||
<span class="link">{label(link)}</span>
|
<button class="link" onclick={() => openLink(link)} title={t('notes.linkOpen')}>
|
||||||
|
{label(link)}
|
||||||
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Filing a note in a project is one way to share it: everybody who can
|
|
||||||
read the project reads the note. Owner only, because moving a note
|
|
||||||
the owner is not in would take it away from them. -->
|
|
||||||
{#if ownsSelected}
|
|
||||||
<label class="share">
|
|
||||||
<span class="hint">{t('notes.shareWith')}</span>
|
|
||||||
<select value={selected.project_id ?? ''} onchange={(e) => share(e.currentTarget.value)}>
|
|
||||||
<option value="">{t('notes.private')}</option>
|
|
||||||
{#each app.projects.filter((p) => !p.is_inbox) as project (project.id)}
|
|
||||||
<option value={project.id}>{project.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<!-- The other way: hand the note to named people, without a project
|
|
||||||
between. The owner picks who and at what role; the list below is
|
|
||||||
who can already see it. -->
|
|
||||||
<div class="people-share">
|
|
||||||
<span class="hint">{t('notes.sharePeople')}</span>
|
|
||||||
{#if shares.length}
|
|
||||||
<ul class="sharelist">
|
|
||||||
{#each shares as sh (sh.user.id)}
|
|
||||||
<li>
|
|
||||||
<span class="who">
|
|
||||||
<span class="avatar" style="--who: {sh.user.avatar_color}" aria-hidden="true"></span>
|
|
||||||
{sh.user.name}
|
|
||||||
</span>
|
|
||||||
<select
|
|
||||||
value={sh.role}
|
|
||||||
onchange={(e) => changeShareRole(sh.user.id, e.currentTarget.value)}
|
|
||||||
>
|
|
||||||
<option value="viewer">{t('notes.shareRoleViewer')}</option>
|
|
||||||
<option value="editor">{t('notes.shareRoleEditor')}</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
class="unshare"
|
|
||||||
onclick={() => removeShare(sh.user.id)}
|
|
||||||
title={t('notes.shareRemove')}
|
|
||||||
aria-label={t('notes.shareRemove')}>×</button
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{:else}
|
|
||||||
<span class="none">{t('notes.shareNobody')}</span>
|
|
||||||
{/if}
|
|
||||||
{#if shareCandidates.length}
|
|
||||||
<div class="addshare">
|
|
||||||
<select bind:value={sharePick}>
|
|
||||||
<option value="">{t('notes.sharePick')}</option>
|
|
||||||
{#each shareCandidates as person (person.id)}
|
|
||||||
<option value={person.id}>{person.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<select bind:value={shareRole}>
|
|
||||||
<option value="viewer">{t('notes.shareRoleViewer')}</option>
|
|
||||||
<option value="editor">{t('notes.shareRoleEditor')}</option>
|
|
||||||
</select>
|
|
||||||
<button class="button" disabled={!sharePick || shareBusy} onclick={addShare}>
|
|
||||||
{t('notes.shareAdd')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
<!-- All of sharing behind one button. A note has two ways in — a
|
||||||
|
project and named people — and both belong to the owner; the
|
||||||
|
panel gathers them rather than lining them up across the footer. -->
|
||||||
|
{#if ownsSelected}
|
||||||
|
<div class="sharewrap" bind:this={shareWrap}>
|
||||||
|
<button
|
||||||
|
class="button"
|
||||||
|
class:on={shareOpen}
|
||||||
|
aria-expanded={shareOpen}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
onclick={() => (shareOpen = !shareOpen)}
|
||||||
|
>
|
||||||
|
{t('notes.shareButton')}{#if shares.length}<span class="count">{shares.length}</span>{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if shareOpen}
|
||||||
|
<div class="sharepanel" role="dialog" aria-label={t('notes.shareButton')}>
|
||||||
|
<!-- Filing it in a project: everybody who can read the project
|
||||||
|
reads the note. A real <label> around the select, so it is
|
||||||
|
named for a screen reader and not only for the eye. -->
|
||||||
|
<section>
|
||||||
|
<label class="field">
|
||||||
|
<span class="phead">{t('notes.shareWith')}</span>
|
||||||
|
<select
|
||||||
|
aria-label={t('notes.shareWith')}
|
||||||
|
value={selected.project_id ?? ''}
|
||||||
|
onchange={(e) => share(e.currentTarget.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t('notes.private')}</option>
|
||||||
|
{#each app.projects.filter((p) => !p.is_inbox) as project (project.id)}
|
||||||
|
<option value={project.id}>{project.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Handing it to named people, without a project between. -->
|
||||||
|
<section>
|
||||||
|
<span class="phead">{t('notes.sharePeople')}</span>
|
||||||
|
{#if shares.length}
|
||||||
|
<ul class="sharelist">
|
||||||
|
{#each shares as sh (sh.user.id)}
|
||||||
|
<li>
|
||||||
|
<span class="who">
|
||||||
|
<span
|
||||||
|
class="avatar"
|
||||||
|
style="--who: {sh.user.avatar_color}"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
{sh.user.name}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={sh.role}
|
||||||
|
onchange={(e) => changeShareRole(sh.user.id, e.currentTarget.value)}
|
||||||
|
>
|
||||||
|
<option value="viewer">{t('notes.shareRoleViewer')}</option>
|
||||||
|
<option value="editor">{t('notes.shareRoleEditor')}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
class="unshare"
|
||||||
|
onclick={() => removeShare(sh.user.id)}
|
||||||
|
title={t('notes.shareRemove')}
|
||||||
|
aria-label={t('notes.shareRemove')}>×</button
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<span class="none">{t('notes.shareNobody')}</span>
|
||||||
|
{/if}
|
||||||
|
{#if shareCandidates.length}
|
||||||
|
<div class="addshare">
|
||||||
|
<select bind:value={sharePick}>
|
||||||
|
<option value="">{t('notes.sharePick')}</option>
|
||||||
|
{#each shareCandidates as person (person.id)}
|
||||||
|
<option value={person.id}>{person.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<select bind:value={shareRole}>
|
||||||
|
<option value="viewer">{t('notes.shareRoleViewer')}</option>
|
||||||
|
<option value="editor">{t('notes.shareRoleEditor')}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
class="button"
|
||||||
|
disabled={!sharePick || shareBusy}
|
||||||
|
onclick={addShare}
|
||||||
|
>
|
||||||
|
{t('notes.shareAdd')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<button class="button" onclick={save}>{t('notes.save')}</button>
|
<button class="button" onclick={save}>{t('notes.save')}</button>
|
||||||
{#if ownsSelected}
|
{#if ownsSelected}
|
||||||
<button class="button danger" onclick={() => remove(selected)}>{t('notes.delete')}</button>
|
<button class="button danger" onclick={() => remove(selected)}>{t('notes.delete')}</button>
|
||||||
@@ -1555,18 +1657,71 @@
|
|||||||
.link {
|
.link {
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0 var(--s1);
|
padding: 0 var(--s1);
|
||||||
font-family: var(--mono, ui-monospace, monospace);
|
font-family: var(--mono, ui-monospace, monospace);
|
||||||
|
font-size: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.link:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-color: var(--line-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.share {
|
/* Sharing lives in a popover above the Del button, so the footer stays a status
|
||||||
|
and a few buttons rather than a row that runs off its own width. */
|
||||||
|
.sharewrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.on {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sharewrap .count {
|
||||||
|
margin-left: 0.4em;
|
||||||
|
padding: 0 0.45em;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--line-strong);
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sharepanel {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + var(--s2));
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
width: 20rem;
|
||||||
|
max-width: 80vw;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
|
gap: var(--s3);
|
||||||
|
padding: var(--s3);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
|
||||||
|
text-align: left;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sharepanel section,
|
||||||
|
.sharepanel .field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: var(--s1);
|
gap: var(--s1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.share select {
|
.sharepanel .phead {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sharepanel select {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
padding: 2px var(--s1);
|
padding: 2px var(--s1);
|
||||||
}
|
}
|
||||||
@@ -1597,15 +1752,7 @@
|
|||||||
content: none;
|
content: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Del med personer, under the note. */
|
.sharepanel .none {
|
||||||
.people-share {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--s1);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.people-share .none {
|
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
}
|
}
|
||||||
@@ -1673,6 +1820,15 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--s1);
|
gap: var(--s1);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
margin-top: var(--s1);
|
||||||
|
}
|
||||||
|
/* The person picker takes its own row, so the role and the button below it never
|
||||||
|
get squeezed to nothing in the panel's width. */
|
||||||
|
.addshare select:first-child {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
.addshare .button {
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
.addshare select {
|
.addshare select {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
|
|||||||