johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
<script lang="ts">
import type { ActorView, Image } from '#lib/lexicons/dev/knotless/defs.ts';
import type * as getFollows from '#lib/lexicons/dev/knotless/getFollows.ts';
import type * as searchOwners from '#lib/lexicons/dev/knotless/searchOwners.ts';
import type * as renderMarkdown from '#lib/lexicons/dev/knotless/renderMarkdown.ts';
import type * as uploadImage from '#lib/lexicons/dev/knotless/uploadImage.ts';
import type { AtUriString } from '@atproto/lex';
import { tick } from 'svelte';
import { page } from '$app/state';
import { type Answer, call, upload } from '#lib/data.ts';
import { fences } from '#lib/highlight.ts';
import { diagrams } from '#lib/mermaid.ts';
import MentionList from './MentionList.svelte';
import Empty from '#lib/molecules/Empty.svelte';
/**
* Markdown being written, `value`, with a tab to write it in and one to
* preview it: drawn by the server's own renderer, as it will show once
* posted, its relative links inside `repo`. The box stays, hidden, while
* the preview shows, so what was written and its undo survive the switch.
* A picture pasted or dropped in, with a `repo`, is uploaded and linked
* where it went, and its blob added to `images`, to attach. Typing `@`
* suggests whom to mention: those in the thread, then whom the viewer
* follows, then the owners, each whose handle starts with what follows it.
*/
let {
value = $bindable(''),
images = $bindable([]),
label,
placeholder,
rows = 5,
repo
}: {
value?: string;
images?: Image[];
label: string;
placeholder?: string;
rows?: number;
repo?: AtUriString;
} = $props();
let box = $state<HTMLTextAreaElement>();
let failed = $state('');
/** The pictures uploaded here, by their link, drawn in the preview from what was pasted: the PDS serves none until it is attached. */
const local = new Map<string, string>();
/** Uploads each picture of `files`, its link standing in for a note where it was put. */
async function pictures(files: FileList | null | undefined, e: Event) {
const chosen = [...(files ?? [])].filter((f) => f.type.startsWith('image/'));
if (!repo || !chosen.length || !box) return;
e.preventDefault();
failed = '';
for (const file of chosen) {
const name = file.name.replace(/[[\]\\\n]/g, '') || 'picture';
const note = `![Uploading ${name}…]()`;
const at = box.selectionStart;
value = `${value.slice(0, at)}${note}${value.slice(box.selectionEnd)}`;
const sent = await upload<typeof uploadImage.main>('dev.knotless.uploadImage', file, {
repo
}).catch((e: Error) => e);
if (sent instanceof Error) {
failed = `${name}: ${sent.message}`;
value = value.replace(note, '');
continue;
}
local.set(sent.url, URL.createObjectURL(file));
images = [...images, { image: sent.blob, width: sent.width, height: sent.height }];
value = value.replace(note, ``);
}
}
$effect(() => () => local.forEach((url) => URL.revokeObjectURL(url)));
/** The most accounts suggested at once. */
const SUGGESTED = 6;
const uid = $props.id();
const list = `${uid}-mentions`;
/** The mention being typed: where its `@` is, and what follows it, lowercase. */
let mention = $state<{ start: number; q: string } | null>(null);
let found = $state<ActorView[]>([]);
let at = $state(0);
const suggesting = $derived(!!mention && found.length > 0);
/** What a page's data holds that this reads, where it holds it. */
type Seen = {
thread?: { item: { author: ActorView }; comments: { author: ActorView }[] };
viewer?: { actor?: ActorView } | null;
};
/** Those in the thread written in, when it is one: its author, then whoever commented. */
const people = $derived.by(() => {
const thread = (page.data as Seen).thread;
return thread ? [thread.item.author, ...thread.comments.map((c) => c.author)] : [];
});
const me = $derived((page.data as Seen).viewer?.actor?.did);
/** Whom the viewer follows, asked once, when first needed. */
let follows: Promise<ActorView[]> | undefined;
let timer: ReturnType<typeof setTimeout>;
let asking: AbortController | undefined;
/** The mention the caret is at the end of, if any, and the accounts it may be of. */
function typed() {
if (!box) return;
const before = value.slice(0, box.selectionStart);
const m = /(?:^|[\s(*_~"'])@([A-Za-z0-9.-]{0,253})$/.exec(before);
mention = m ? { start: before.length - m[1].length - 1, q: m[1].toLowerCase() } : null;
clearTimeout(timer);
asking?.abort();
if (!mention) {
found = [];
return;
}
const { q } = mention;
const signal = (asking = new AbortController()).signal;
timer = setTimeout(async () => {
const ask = async <T>(path: string): Promise<T | null> => {
const res = await fetch(path, { signal }).catch(() => null);
return res?.ok ? res.json() : null;
};
follows ??= me
? ask<Answer<typeof getFollows.main>>(
`/xrpc/dev.knotless.getFollows?${new URLSearchParams({ actor: me, limit: '100' })}`
).then((f) => f?.actors ?? [])
: Promise.resolve([]);
const [followed, owners] = await Promise.all([
follows,
q
? ask<Answer<typeof searchOwners.main>>(
`/xrpc/dev.knotless.searchOwners?${new URLSearchParams({ q, limit: String(SUGGESTED) })}`
)
: null
]);
if (signal.aborted) return;
const all = [...people, ...followed, ...(owners?.owners ?? []).map((o) => o.owner)];
const seen = new Set<string>(me ? [me] : []);
found = all
.filter(
(a) =>
a.handle.toLowerCase().startsWith(q) &&
!a.handle.startsWith('did:') &&
!seen.has(a.did) &&
!!seen.add(a.did)
)
.slice(0, SUGGESTED);
at = 0;
}, 120);
}
/** `a`'s handle where the mention being typed is, the caret after it. */
async function pick(a: ActorView) {
if (!mention || !box) return;
const end = box.selectionStart;
const put = `@${a.handle} `;
value = `${value.slice(0, mention.start)}${put}${value.slice(end)}`;
const caret = mention.start + put.length;
close();
await tick();
box.focus();
box.setSelectionRange(caret, caret);
}
function close() {
clearTimeout(timer);
asking?.abort();
mention = null;
found = [];
}
/** The keys GitHub's suggestions take: arrows to choose, Enter or Tab to pick, Escape to close. */
function keydown(e: KeyboardEvent) {
if (!suggesting) return;
const n = found.length;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
at = e.key === 'ArrowDown' ? (at + 1) % n : (at - 1 + n) % n;
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
pick(found[at]);
} else if (e.key === 'Escape') {
// Closes the suggestions, not what the box is in.
e.preventDefault();
e.stopPropagation();
close();
}
}
let previewing = $state(false);
/** What the preview is of: once the text changes from outside, as when it is sent, the box is back. */
let previewed = $state('');
const showing = $derived(previewing && value === previewed);
let html = $state('');
let problem = $state('');
let busy = $state(false);
/** Which preview was asked last: an earlier one's answer is not shown over it. */
let asked = 0;
async function preview() {
previewing = true;
previewed = value;
problem = '';
html = '';
const n = ++asked;
busy = !!value.trim();
if (!busy) return;
const drawn = await call<typeof renderMarkdown.main>('dev.knotless.renderMarkdown', {
text: value,
repo
}).catch((e: Error) => e);
if (n !== asked) return;
busy = false;
if (drawn instanceof Error) problem = drawn.message;
else
html = [...local].reduce(
(h, [url, here]) => h.replaceAll(url.replaceAll('&', '&'), here),
drawn.html
);
}
</script>
<div class="md-field">
<div class="md-tabs" role="tablist" aria-label={label}>
<button type="button" role="tab" aria-selected={!showing} onclick={() => (previewing = false)}
>Write</button
>
<button type="button" role="tab" aria-selected={showing} onclick={preview}>Preview</button>
</div>
<div class="md-write" hidden={showing}>
<textarea
class="field"
name="body"
aria-label={label}
{placeholder}
{rows}
aria-autocomplete="list"
aria-controls={suggesting ? list : undefined}
aria-activedescendant={suggesting ? `${list}-${at}` : undefined}
bind:value
bind:this={box}
oninput={typed}
onclick={typed}
onkeydown={keydown}
onblur={close}
onpaste={(e) => pictures(e.clipboardData?.files, e)}
ondrop={(e) => pictures(e.dataTransfer?.files, e)}></textarea>
{#if suggesting}<MentionList
id={list}
{found}
{at}
onpick={pick}
onhover={(i) => (at = i)}
/>{/if}
</div>
{#if failed}<p class="problem" role="alert">{failed}</p>{/if}
{#if showing}
<div class="md-preview" role="tabpanel">
{#if problem}
<p class="problem" role="alert">{problem}</p>
{:else if busy}
<div class="empty">Drawing the preview…</div>
{:else if html}
<div class="prose" use:diagrams={html} use:fences={html}>{@html html}</div>
{:else}
<Empty title="Nothing to preview" />
{/if}
</div>
{/if}
</div>