Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97e6be3afa | ||
|
|
d67b61d3c6 | ||
|
|
f0d18bb800 |
+71
-4
@@ -2440,7 +2440,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 +2452,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 +2489,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' });
|
||||||
@@ -2981,7 +2985,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 +3054,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']);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -470,6 +558,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();
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export const da = {
|
|||||||
'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',
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ export const en = {
|
|||||||
'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',
|
||||||
|
|||||||
@@ -193,6 +193,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
|
||||||
@@ -638,6 +658,13 @@
|
|||||||
|
|
||||||
<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">
|
||||||
@@ -865,73 +892,105 @@
|
|||||||
{/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>
|
||||||
@@ -1560,13 +1619,58 @@
|
|||||||
font-family: var(--mono, ui-monospace, monospace);
|
font-family: var(--mono, ui-monospace, monospace);
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 +1701,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 +1769,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);
|
||||||
|
|||||||
Reference in New Issue
Block a user