mirror of
https://seed.radicle.dev/rad:z4D5UCArafTzTQpDZNQRuqswh3ury.git
synced 2026-11-04 02:50:32 +08:00
627 lines
19 KiB
Svelte
627 lines
19 KiB
Svelte
<script lang="ts">
|
|
import type { Embed } from "@bindings/cob/thread/Embed";
|
|
import type { UnlistenFn } from "@tauri-apps/api/event";
|
|
import type { ComponentProps, Snippet } from "svelte";
|
|
|
|
import { listen } from "@tauri-apps/api/event";
|
|
import { open } from "@tauri-apps/plugin-dialog";
|
|
import debounce from "lodash/debounce";
|
|
import { onDestroy, onMount } from "svelte";
|
|
import { SvelteMap } from "svelte/reactivity";
|
|
|
|
import { hints } from "@app/lib/hints";
|
|
import { invoke } from "@app/lib/invoke";
|
|
import { matchesShortcut } from "@app/lib/shortcuts.svelte";
|
|
import * as utils from "@app/lib/utils";
|
|
|
|
import Button from "@app/components/Button.svelte";
|
|
import Icon from "@app/components/Icon.svelte";
|
|
import Markdown from "@app/components/Markdown.svelte";
|
|
import Popover, { closeFocused } from "@app/components/Popover.svelte";
|
|
import Textarea from "@app/components/Textarea.svelte";
|
|
|
|
interface Props {
|
|
textAreaSize?: ComponentProps<typeof Textarea>["size"];
|
|
styleMinHeight?: string;
|
|
rid: string;
|
|
placeholder?: string;
|
|
submitCaption?: string;
|
|
submitVariant?: ComponentProps<typeof Button>["variant"];
|
|
submitActiveVariant?: ComponentProps<typeof Button>["variant"];
|
|
focus?: boolean;
|
|
inline?: boolean;
|
|
body?: string;
|
|
embeds?: Map<string, Embed>;
|
|
submitInProgress?: boolean;
|
|
disableSubmit?: boolean;
|
|
disallowEmptyBody?: boolean;
|
|
emptyBodyTooltip?: string;
|
|
isValid?: () => boolean;
|
|
preview?: boolean;
|
|
stylePadding?: string;
|
|
borderVariant?: ComponentProps<typeof Textarea>["borderVariant"];
|
|
submit: (opts: {
|
|
comment: string;
|
|
embeds: Map<string, Embed>;
|
|
}) => Promise<void>;
|
|
submitDescription?: string;
|
|
secondarySubmit?: {
|
|
caption: string;
|
|
description?: string;
|
|
submit: (opts: {
|
|
comment: string;
|
|
embeds: Map<string, Embed>;
|
|
}) => Promise<void>;
|
|
};
|
|
close: () => void;
|
|
// If true, adding attachments through drag-and-drop is disabled and there
|
|
// is no "Attach" button. If a string is provided, the "Attach" button is
|
|
// visible but disabled and uses the string as the title to indicate the
|
|
// reason for disabling. Defaults to `false`
|
|
disableAttachments?: boolean | string;
|
|
hideDiscard?: boolean;
|
|
// If true, the whole actions row stays hidden until the body has content.
|
|
collapseActions?: boolean;
|
|
belowTextarea?: Snippet;
|
|
}
|
|
|
|
/* eslint-disable prefer-const */
|
|
let {
|
|
textAreaSize,
|
|
preview = $bindable(false),
|
|
styleMinHeight,
|
|
rid,
|
|
placeholder = "Leave your comment",
|
|
submitCaption = "Comment",
|
|
submitVariant = "ghost",
|
|
submitActiveVariant = undefined,
|
|
focus = false,
|
|
inline = false,
|
|
body = $bindable(""),
|
|
embeds = new SvelteMap<string, Embed>(),
|
|
submitInProgress = false,
|
|
disableSubmit = false,
|
|
disallowEmptyBody = false,
|
|
emptyBodyTooltip,
|
|
isValid = () => true,
|
|
stylePadding,
|
|
borderVariant = "float",
|
|
submit,
|
|
submitDescription,
|
|
secondarySubmit,
|
|
close,
|
|
disableAttachments: attachDisabled = false,
|
|
hideDiscard = false,
|
|
collapseActions = false,
|
|
belowTextarea,
|
|
}: Props = $props();
|
|
/* eslint-enable prefer-const */
|
|
|
|
const attachEnabled = $derived(attachDisabled === false);
|
|
const attachDisabledReason = $derived(
|
|
typeof attachDisabled === "string" ? attachDisabled : undefined,
|
|
);
|
|
const effectiveSubmitVariant = $derived(
|
|
body.trim().length > 0 && submitActiveVariant !== undefined
|
|
? submitActiveVariant
|
|
: submitVariant,
|
|
);
|
|
const submitDisabled = $derived(
|
|
!isValid() ||
|
|
submitInProgress ||
|
|
disableSubmit ||
|
|
(disallowEmptyBody && body.trim() === ""),
|
|
);
|
|
|
|
let selectionStart = $state(body.length);
|
|
let selectionEnd = $state(body.length);
|
|
let draggingOver = $state(false);
|
|
let embedUploadError: string | undefined = $state();
|
|
let dragEnterUnlistenFn: UnlistenFn | undefined = undefined;
|
|
let dragLeaveUnlistenFn: UnlistenFn | undefined = undefined;
|
|
let dragDropUnlistenFn: UnlistenFn | undefined = undefined;
|
|
|
|
const restoreDragDropText = debounce(() => {
|
|
embedUploadError = undefined;
|
|
}, 5000);
|
|
|
|
function updateBodyAndSelection(input: string[], pre: string, after: string) {
|
|
const allEmbeds = input.join("");
|
|
body = pre.concat(allEmbeds, after);
|
|
selectionStart = pre.length + allEmbeds.length;
|
|
selectionEnd = pre.length + allEmbeds.length;
|
|
}
|
|
|
|
function splitBody() {
|
|
return [body.substring(0, selectionStart), body.substring(selectionStart)];
|
|
}
|
|
|
|
onMount(async () => {
|
|
if (window.__TAURI_INTERNALS__) {
|
|
if (attachEnabled) {
|
|
dragEnterUnlistenFn = await listen("tauri://drag-enter", () => {
|
|
draggingOver = true;
|
|
});
|
|
|
|
dragLeaveUnlistenFn = await listen("tauri://drag-leave", () => {
|
|
draggingOver = false;
|
|
});
|
|
|
|
dragDropUnlistenFn = await listen<{
|
|
paths: string[];
|
|
position: { x: number; y: number };
|
|
}>("tauri://drag-drop", async event => {
|
|
draggingOver = false;
|
|
const [preBody, afterBody] = splitBody();
|
|
|
|
return Promise.all(
|
|
event.payload.paths.map(async path => {
|
|
const pathSegments = path.split("/");
|
|
const name = pathSegments[pathSegments.length - 1];
|
|
const uploadLabel = `[Uploading ${name}...]()\n`;
|
|
|
|
body = preBody.concat(uploadLabel, afterBody);
|
|
try {
|
|
const oid = await invoke<string>("save_embed_by_path", {
|
|
rid,
|
|
path,
|
|
});
|
|
embeds.set(oid, { name, content: `git:${oid}` });
|
|
return `[${name}](${oid})\n`;
|
|
} catch {
|
|
embedUploadError = "Upload failed, embed exceeded 10Mb.";
|
|
restoreDragDropText();
|
|
return "";
|
|
}
|
|
}),
|
|
).then(texts => updateBodyAndSelection(texts, preBody, afterBody));
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (dragEnterUnlistenFn) dragEnterUnlistenFn();
|
|
if (dragLeaveUnlistenFn) dragLeaveUnlistenFn();
|
|
if (dragDropUnlistenFn) dragDropUnlistenFn();
|
|
});
|
|
|
|
async function attachEmbedsByPaths(paths: string[]) {
|
|
const [preBody, afterBody] = splitBody();
|
|
|
|
return Promise.all(
|
|
paths.map(async path => {
|
|
const pathSegments = path.split("/");
|
|
const name = pathSegments[pathSegments.length - 1];
|
|
const uploadLabel = `[Uploading ${name}...]()\n`;
|
|
body = preBody.concat(uploadLabel, afterBody);
|
|
try {
|
|
const oid = await invoke<string>("save_embed_by_path", {
|
|
rid,
|
|
path,
|
|
});
|
|
embeds.set(oid, { name: name ?? path, content: `git:${oid}` });
|
|
return `[${name}](${oid})\n`;
|
|
} catch {
|
|
embedUploadError = "Upload failed, embed exceeded 10Mb.";
|
|
restoreDragDropText();
|
|
return "";
|
|
}
|
|
}),
|
|
).then(texts => updateBodyAndSelection(texts, preBody, afterBody));
|
|
}
|
|
|
|
async function handlePaste(e: ClipboardEvent) {
|
|
if (!attachEnabled) {
|
|
return;
|
|
}
|
|
|
|
if (e.clipboardData?.files && e.clipboardData.files.length > 0) {
|
|
e.preventDefault();
|
|
const [preBody, afterBody] = splitBody();
|
|
// We read the buffer on the backend, if it's a image buffer.
|
|
if (e.clipboardData.items.length === 1) {
|
|
const file = e.clipboardData.files[0];
|
|
const uploadLabel = `[Uploading...]()\n`;
|
|
body = preBody.concat(uploadLabel, afterBody);
|
|
try {
|
|
const oid = await invoke<string>("save_embed_by_clipboard", {
|
|
name: file.name,
|
|
rid,
|
|
});
|
|
|
|
embeds.set(oid, { name: file.name, content: `git:${oid}` });
|
|
body = preBody.concat(`[${file.name}](${oid})\n`, afterBody);
|
|
} catch {
|
|
body = preBody.concat(``, afterBody);
|
|
embedUploadError = "Upload failed, embed exceeded 10Mb.";
|
|
restoreDragDropText();
|
|
}
|
|
} else {
|
|
return Promise.all(
|
|
Array.from(e.clipboardData.files).map(async file => {
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
const bytes = new Uint8Array(arrayBuffer);
|
|
const uploadLabel = `[Uploading ${file.name}...]()\n`;
|
|
body = preBody.concat(uploadLabel, afterBody);
|
|
try {
|
|
const oid = await invoke<string>("save_embed_by_bytes", {
|
|
rid,
|
|
name: file.name,
|
|
bytes,
|
|
});
|
|
embeds.set(oid, { name: file.name, content: `git:${oid}` });
|
|
return `[${file.name}](${oid})\n`;
|
|
} catch {
|
|
embedUploadError = "Upload failed, embed exceeded 10Mb.";
|
|
restoreDragDropText();
|
|
return "";
|
|
}
|
|
}),
|
|
).then(texts => updateBodyAndSelection(texts, preBody, afterBody));
|
|
}
|
|
} else {
|
|
// In case that the clipboard data isn't an array of files,
|
|
// we want to make use of the default behavior and insert the clipboard content.
|
|
}
|
|
}
|
|
|
|
function selectFiles() {
|
|
void open({ multiple: true }).then(paths => {
|
|
if (paths) {
|
|
void attachEmbedsByPaths(paths);
|
|
}
|
|
});
|
|
}
|
|
|
|
let activeSubmitIndex = $state<0 | 1>(0);
|
|
let dropdownExpanded = $state(false);
|
|
|
|
const activeSubmit = $derived(
|
|
activeSubmitIndex === 1 && secondarySubmit
|
|
? {
|
|
caption: secondarySubmit.caption,
|
|
submit: secondarySubmit.submit,
|
|
}
|
|
: { caption: submitCaption, submit },
|
|
);
|
|
|
|
function submitFn() {
|
|
void activeSubmit
|
|
.submit({ comment: body, embeds })
|
|
.then(() => (preview = false))
|
|
.catch(e => {
|
|
console.error(e);
|
|
});
|
|
}
|
|
|
|
export function triggerSubmit() {
|
|
if (submitDisabled) {
|
|
return;
|
|
}
|
|
submitFn();
|
|
}
|
|
</script>
|
|
|
|
<style>
|
|
.comment-section {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
gap: 1rem;
|
|
width: 100%;
|
|
flex: 1;
|
|
}
|
|
.inline {
|
|
border: 0;
|
|
padding: 0;
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
width: 100%;
|
|
gap: 1rem;
|
|
}
|
|
.buttons {
|
|
display: flex;
|
|
margin-left: auto;
|
|
gap: 0.5rem;
|
|
}
|
|
.shortcut {
|
|
opacity: 0.6;
|
|
margin-left: 0.25rem;
|
|
}
|
|
.split-submit {
|
|
display: flex;
|
|
align-items: stretch;
|
|
gap: 0.25rem;
|
|
}
|
|
.submit-menu {
|
|
border: 1px solid var(--color-border-subtle);
|
|
border-radius: var(--border-radius-sm);
|
|
background-color: var(--color-surface-canvas);
|
|
width: 22rem;
|
|
overflow: hidden;
|
|
}
|
|
.submit-option {
|
|
display: grid;
|
|
grid-template-columns: 1.5rem 1fr;
|
|
column-gap: 0.5rem;
|
|
align-items: start;
|
|
padding: 0.625rem 0.75rem;
|
|
cursor: pointer;
|
|
border-bottom: 1px solid var(--color-border-subtle);
|
|
}
|
|
.submit-option:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.submit-option:hover {
|
|
background-color: var(--color-surface-subtle);
|
|
}
|
|
.submit-check {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 1.25rem;
|
|
color: var(--color-text-primary);
|
|
}
|
|
.submit-text {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.125rem;
|
|
}
|
|
.submit-label {
|
|
font: var(--txt-body-m-medium);
|
|
color: var(--color-text-primary);
|
|
}
|
|
.submit-description {
|
|
font: var(--txt-body-s-regular);
|
|
color: var(--color-text-tertiary);
|
|
white-space: normal;
|
|
}
|
|
|
|
.preview {
|
|
width: 100%;
|
|
font: var(--txt-body-m-regular);
|
|
min-height: 109px;
|
|
flex: 1;
|
|
border: 1px solid var(--color-border-subtle);
|
|
border-radius: var(--border-radius-sm);
|
|
background-color: var(--color-surface-canvas);
|
|
padding: 0.75rem;
|
|
}
|
|
.inline .preview {
|
|
border: 0;
|
|
padding: 0;
|
|
background-color: transparent;
|
|
}
|
|
.textarea-wrap {
|
|
position: relative;
|
|
display: flex;
|
|
width: 100%;
|
|
flex: 1;
|
|
}
|
|
.markdown-hint {
|
|
position: absolute;
|
|
bottom: 0.375rem;
|
|
right: 0.5rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
color: var(--color-text-quaternary);
|
|
opacity: 0.6;
|
|
-webkit-user-select: none;
|
|
user-select: none;
|
|
}
|
|
.markdown-hint-dismiss {
|
|
background: none;
|
|
border: 1px solid var(--color-border-subtle);
|
|
border-radius: var(--border-radius-sm);
|
|
color: inherit;
|
|
cursor: pointer;
|
|
font: var(--txt-body-s-regular);
|
|
padding: 0 0.375rem;
|
|
line-height: 1.25rem;
|
|
}
|
|
.markdown-hint-dismiss:hover {
|
|
background-color: var(--color-surface-subtle);
|
|
color: var(--color-text-secondary);
|
|
}
|
|
</style>
|
|
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
class="comment-section"
|
|
aria-label="extended-textarea"
|
|
class:inline
|
|
onkeydown={event => {
|
|
if (!preview) return;
|
|
if (matchesShortcut(event, "submit")) {
|
|
event.preventDefault();
|
|
triggerSubmit();
|
|
}
|
|
}}>
|
|
<div class="textarea-wrap">
|
|
{#if preview}
|
|
<div class="preview" style:min-height={styleMinHeight}>
|
|
{#if body.trim().length === 0}
|
|
<span class="txt-missing">Nothing to preview.</span>
|
|
{:else}
|
|
<Markdown {rid} breaks content={body} />
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<Textarea
|
|
size={textAreaSize}
|
|
styleAlignItems="flex-start"
|
|
{draggingOver}
|
|
{borderVariant}
|
|
{stylePadding}
|
|
{styleMinHeight}
|
|
bind:selectionEnd
|
|
bind:selectionStart
|
|
onpaste={handlePaste}
|
|
{focus}
|
|
submit={async () => triggerSubmit()}
|
|
bind:value={body}
|
|
{placeholder} />
|
|
{#if !hints.isDismissed("markdown")}
|
|
<div class="markdown-hint txt-body-s-regular">
|
|
<Icon
|
|
name="markdown"
|
|
styleDisplay="inline"
|
|
styleVerticalAlign="text-top" />
|
|
Markdown is supported
|
|
<button
|
|
type="button"
|
|
class="markdown-hint-dismiss"
|
|
title="Don't show again"
|
|
onclick={() => hints.dismiss("markdown")}>
|
|
Hide
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{@render belowTextarea?.()}
|
|
{#if !collapseActions || body.trim() !== ""}
|
|
<div class="actions">
|
|
{#if !hideDiscard}
|
|
<Button
|
|
variant="outline"
|
|
disabled={submitInProgress}
|
|
onclick={() => {
|
|
preview = false;
|
|
close();
|
|
}}>
|
|
<Icon name="close" />
|
|
<span class="global-hide-on-small-desktop-down">Discard</span>
|
|
</Button>
|
|
{/if}
|
|
{#if !preview && embedUploadError}
|
|
<div class="txt-overflow txt-body-m-regular">
|
|
<span style:color="var(--color-feedback-error-text)">
|
|
<Icon
|
|
styleDisplay="inline"
|
|
styleVerticalAlign="text-top"
|
|
name="warning" />
|
|
{embedUploadError}
|
|
</span>
|
|
</div>
|
|
{/if}
|
|
<div class="buttons">
|
|
{#if attachEnabled || attachDisabledReason}
|
|
<Button
|
|
variant="outline"
|
|
onclick={selectFiles}
|
|
disabled={preview || attachDisabledReason !== undefined}
|
|
title={attachDisabledReason}>
|
|
<Icon name="attach" />
|
|
Attach
|
|
</Button>
|
|
{/if}
|
|
<Button variant="outline" onclick={() => (preview = !preview)}>
|
|
<Icon name={preview ? "edit" : "eye"} />
|
|
{preview ? "Edit" : "Preview"}
|
|
</Button>
|
|
{#if secondarySubmit}
|
|
<div class="split-submit">
|
|
<Button
|
|
variant={effectiveSubmitVariant}
|
|
title={emptyBodyTooltip}
|
|
disabled={submitDisabled}
|
|
onclick={submitFn}>
|
|
{#if submitInProgress}
|
|
Saving…
|
|
{:else}
|
|
{activeSubmit.caption}
|
|
<span class="shortcut">{utils.modifierKey()}↵</span>
|
|
{/if}
|
|
</Button>
|
|
<Popover
|
|
popoverPadding="0"
|
|
placement="top-end"
|
|
bind:expanded={dropdownExpanded}>
|
|
{#snippet toggle(onclick)}
|
|
<Button
|
|
variant={effectiveSubmitVariant}
|
|
disabled={submitInProgress}
|
|
{onclick}>
|
|
<Icon
|
|
name={dropdownExpanded ? "chevron-up" : "chevron-down"} />
|
|
</Button>
|
|
{/snippet}
|
|
{#snippet popover()}
|
|
<div class="submit-menu">
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<div
|
|
role="button"
|
|
tabindex="0"
|
|
class="submit-option"
|
|
onclick={() => {
|
|
activeSubmitIndex = 0;
|
|
closeFocused();
|
|
}}>
|
|
<div class="submit-check">
|
|
{#if activeSubmitIndex === 0}
|
|
<Icon name="checkmark" />
|
|
{/if}
|
|
</div>
|
|
<div class="submit-text">
|
|
<span class="submit-label">{submitCaption}</span>
|
|
{#if submitDescription}
|
|
<span class="submit-description">
|
|
{submitDescription}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<div
|
|
role="button"
|
|
tabindex="0"
|
|
class="submit-option"
|
|
onclick={() => {
|
|
activeSubmitIndex = 1;
|
|
closeFocused();
|
|
}}>
|
|
<div class="submit-check">
|
|
{#if activeSubmitIndex === 1}
|
|
<Icon name="checkmark" />
|
|
{/if}
|
|
</div>
|
|
<div class="submit-text">
|
|
<span class="submit-label">
|
|
{secondarySubmit.caption}
|
|
</span>
|
|
{#if secondarySubmit.description}
|
|
<span class="submit-description">
|
|
{secondarySubmit.description}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/snippet}
|
|
</Popover>
|
|
</div>
|
|
{:else}
|
|
<Button
|
|
variant={effectiveSubmitVariant}
|
|
title={emptyBodyTooltip}
|
|
disabled={submitDisabled}
|
|
onclick={submitFn}>
|
|
{#if submitInProgress}
|
|
Saving…
|
|
{:else}
|
|
{submitCaption}
|
|
<span class="shortcut">{utils.modifierKey()}↵</span>
|
|
{/if}
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|