~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
255 lines · 8.9 KB Raw History
<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, `![${name}](${sent.url})`);
		}
	}
	$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('&', '&amp;'), 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>