Replace the plain textarea in CreateReviewDialog with CommentEditor bound to the description. The dialog now passes language, disabled, rows, ariaLabel, previewLabel and placeholder so the description field gains markdown preview and consistent accessible labels/placeholders. Make CommentEditor props optional and configurable: - onSend is now (() => void | Promise<void>) | undefined; Enter/Cmd+Enter and the send button are guarded/hidden when onSend is not provided. - Add placeholder, ariaLabel, previewLabel and rows (default 5) to allow parent components to control appearance and accessibility. Also add a small documentation tweak in commit_ai's cloud template: remind authors to keep the title plain text and expand guidance on Markdown formatting.
114 lines
10 KiB
Svelte
114 lines
10 KiB
Svelte
<script lang="ts">
|
|
import { tick } from "svelte";
|
|
import { marked } from "marked";
|
|
import DOMPurify from "dompurify";
|
|
import { Heading1, Heading2, Heading3, Bold, Italic, Strikethrough, Quote, Code2, Link, List, ListOrdered, ListTodo, Table2, AtSign, MessageSquare, CaseSensitive, WrapText, Send } from "@lucide/svelte";
|
|
import { openInBrowser } from "../git";
|
|
export let value = "";
|
|
export let language: "de" | "en" = "en";
|
|
export let disabled = false;
|
|
export let busy = false;
|
|
export let onSend: (() => void | Promise<void>) | undefined = undefined;
|
|
export let placeholder: string | undefined = undefined;
|
|
export let ariaLabel: string | undefined = undefined;
|
|
export let previewLabel: string | undefined = undefined;
|
|
export let rows = 5;
|
|
let textarea: HTMLTextAreaElement;
|
|
let preview = false;
|
|
let monospace = false;
|
|
let wrap = true;
|
|
let linkError = "";
|
|
$: de = language === "de";
|
|
$: rendered = preview ? DOMPurify.sanitize(marked.parse(value, { async: false, gfm: true, breaks: true }), {
|
|
ALLOWED_TAGS: ["p","br","strong","em","del","s","blockquote","pre","code","h1","h2","h3","h4","h5","h6","ul","ol","li","a","table","thead","tbody","tr","th","td","hr","input"],
|
|
ALLOWED_ATTR: ["href","title","type","checked","disabled","align"],
|
|
}) : "";
|
|
$: tools = [
|
|
{icon:Heading1,label:de?"Überschrift 1":"Heading 1",prefix:"# ",line:true},
|
|
{icon:Heading2,label:de?"Überschrift 2":"Heading 2",prefix:"## ",line:true},
|
|
{icon:Heading3,label:de?"Überschrift 3":"Heading 3",prefix:"### ",line:true},
|
|
{icon:Bold,label:de?"Fett":"Bold",prefix:"**",suffix:"**",group:true},
|
|
{icon:Italic,label:de?"Kursiv":"Italic",prefix:"_",suffix:"_"},
|
|
{icon:Strikethrough,label:de?"Durchgestrichen":"Strikethrough",prefix:"~~",suffix:"~~"},
|
|
{icon:Quote,label:de?"Zitat":"Quote",prefix:"> ",line:true,group:true},
|
|
{icon:Code2,label:"Code",prefix:"`",suffix:"`"},
|
|
{icon:Link,label:"Link",prefix:"[",suffix:"](https://example.com)"},
|
|
{icon:List,label:de?"Aufzählung":"Bullet list",prefix:"- ",line:true,group:true},
|
|
{icon:ListOrdered,label:de?"Nummerierte Liste":"Numbered list",prefix:"1. ",line:true},
|
|
{icon:ListTodo,label:de?"Aufgabenliste":"Task list",prefix:"- [ ] ",line:true},
|
|
{icon:Table2,label:de?"Tabelle":"Table",prefix:"\n| A | B |\n| --- | --- |\n| | |\n"},
|
|
{icon:AtSign,label:de?"Erwähnung einfügen":"Insert mention",prefix:"@",suffix:"",group:true},
|
|
{icon:MessageSquare,label:de?"Issue-/PR-Verweis einfügen":"Insert issue/PR reference",prefix:"#",suffix:""},
|
|
];
|
|
async function format(prefix: string, suffix = "", line = false) {
|
|
if (disabled || busy) return;
|
|
preview = false;
|
|
await tick();
|
|
let start = textarea.selectionStart, end = textarea.selectionEnd;
|
|
if (line) {
|
|
start = value.lastIndexOf("\n", start - 1) + 1;
|
|
const selected = value.slice(start, end);
|
|
const replacement = selected.split("\n").map((text, i) => `${prefix === "1. " ? `${i + 1}. ` : prefix}${text}`).join("\n");
|
|
value = value.slice(0,start) + replacement + value.slice(end);
|
|
await tick(); textarea.focus(); textarea.setSelectionRange(start, start+replacement.length);
|
|
} else {
|
|
const selected = value.slice(start,end);
|
|
// Multi-line code selections become fenced blocks.
|
|
if (prefix === "`" && selected.includes("\n")) { prefix = "\n```\n"; suffix = "\n```\n"; }
|
|
value = value.slice(0,start)+prefix+selected+suffix+value.slice(end);
|
|
await tick(); textarea.focus(); textarea.setSelectionRange(start+prefix.length,start+prefix.length+selected.length);
|
|
}
|
|
}
|
|
function keyboard(event: KeyboardEvent) {
|
|
if (!(event.ctrlKey || event.metaKey)) return;
|
|
const key = event.key.toLowerCase();
|
|
if (key === "b" || key === "i" || key === "k") { event.preventDefault(); void format(key === "b" ? "**" : key === "i" ? "_" : "[",key === "b" ? "**" : key === "i" ? "_" : "](https://example.com)"); }
|
|
if (key === "enter" && onSend) { event.preventDefault(); if (value.trim() && !busy && !disabled) void onSend(); }
|
|
}
|
|
function previewLinks(node: HTMLElement) {
|
|
node.addEventListener("click", previewClick);
|
|
return { destroy() { node.removeEventListener("click", previewClick); } };
|
|
}
|
|
async function previewClick(event: MouseEvent) {
|
|
const anchor = event.target instanceof Element ? event.target.closest("a") : null;
|
|
if (!anchor) return;
|
|
event.preventDefault();
|
|
const href = anchor.getAttribute("href") ?? "";
|
|
if (!/^https?:\/\//i.test(href)) return;
|
|
try { await openInBrowser(href); } catch (cause) { linkError = String(cause); }
|
|
}
|
|
</script>
|
|
<div class="md-editor">
|
|
<div class="md-tabs" role="group" aria-label={de ? "Editor-Ansicht" : "Editor view"}>
|
|
<button type="button" class:active={!preview} aria-pressed={!preview} onclick={() => { preview = false; }}>{de ? "Schreiben" : "Write"}</button>
|
|
<button type="button" class:active={preview} aria-pressed={preview} onclick={() => { preview = true; }}>{de ? "Vorschau" : "Preview"}</button>
|
|
</div>
|
|
<div class="md-toolbar" role="group" aria-label={de ? "Markdown-Formatierung" : "Markdown formatting"}>
|
|
{#each tools as tool}<button type="button" class:group-start={tool.group} title={tool.label} aria-label={tool.label} disabled={disabled || busy || preview} onmousedown={event => event.preventDefault()} onclick={() => format(tool.prefix,tool.suffix,tool.line)}><svelte:component this={tool.icon} size={15} strokeWidth={1.7} /></button>{/each}
|
|
<span class="md-spacer"></span>
|
|
<button type="button" title={de ? "Monospace-Schrift" : "Monospace font"} aria-label={de ? "Monospace-Schrift" : "Monospace font"} aria-pressed={monospace} onclick={() => { monospace = !monospace; }}><CaseSensitive size={16} /></button>
|
|
<button type="button" title={de ? "Zeilenumbruch" : "Line wrap"} aria-label={de ? "Zeilenumbruch" : "Line wrap"} aria-pressed={wrap} onclick={() => { wrap = !wrap; }}><WrapText size={16} /></button>
|
|
</div>
|
|
{#if preview}
|
|
<!-- Rendered Markdown is restricted to safe content tags and sanitized before insertion. -->
|
|
<div class="md-preview" role="region" aria-label={previewLabel ?? (de ? "Kommentarvorschau" : "Comment preview")} use:previewLinks>
|
|
{#if value.trim()}{@html rendered}{:else}<span>{de ? "Noch nichts zum Anzeigen." : "Nothing to preview yet."}</span>{/if}
|
|
</div>
|
|
{:else}
|
|
<textarea bind:this={textarea} bind:value class:monospace wrap={wrap ? "soft" : "off"} maxlength="100000" {rows} disabled={disabled || busy} aria-label={ariaLabel ?? (de ? "Kommentar schreiben" : "Write comment")} placeholder={placeholder ?? (de ? "Kommentar hinzufügen …" : "Leave a comment …")} onkeydown={keyboard}></textarea>
|
|
{/if}
|
|
{#if linkError}<small role="alert">{linkError}</small>{/if}
|
|
<div class="md-footer"><small>Markdown {#if onSend}<span>· Ctrl/⌘ + Enter</span>{/if}</small>{#if onSend}<button class="md-send" type="button" disabled={!value.trim() || disabled || busy} onclick={() => onSend?.()}><Send size={13} />{busy ? (de ? "Wird gesendet …" : "Sending …") : (de ? "Kommentar senden" : "Post comment")}</button>{/if}</div>
|
|
</div>
|
|
<style>
|
|
.md-editor{width:100%;min-width:0;color:var(--color-ink);font:400 12px/1.5 var(--font-sans)}
|
|
.md-editor button{display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:0;padding:0;border:0;border-radius:0;color:var(--color-ink-muted);background:transparent;font:inherit;cursor:pointer;box-shadow:none}
|
|
.md-editor button:disabled{opacity:.4;cursor:default}.md-editor button:focus-visible{outline:2px solid var(--color-accent);outline-offset:1px}.md-editor button:hover:not(:disabled){color:var(--color-ink);background:var(--color-surface-hover)}
|
|
.md-tabs{display:flex;border-bottom:1px solid var(--color-border);gap:1px}.md-tabs button{position:relative;height:36px;padding:0 12px;border:1px solid transparent;border-bottom:0;font-size:12px}.md-tabs button.active{border-color:var(--color-border);color:var(--color-ink);background:var(--app-bg);font-weight:600}.md-tabs button.active:after{content:"";position:absolute;bottom:-1px;left:0;right:0;height:1px;background:var(--app-bg)}
|
|
.md-toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:2px;padding:10px 2px;row-gap:6px}.md-toolbar button{width:25px;height:25px;flex-shrink:0}.md-toolbar .group-start{border-left:1px solid var(--color-border);margin-left:4px;padding-left:5px;width:30px}.md-spacer{flex:1}.md-toolbar button[aria-pressed=true]{color:var(--color-accent)}
|
|
.md-editor textarea,.md-preview{box-sizing:border-box;width:100%;min-height:132px;max-height:300px;margin:0;padding:11px 12px;border:1px solid var(--color-border-input);border-radius:3px;color:var(--color-ink);background:var(--app-input-bg);font:400 12px/1.6 var(--font-sans);overflow:auto;resize:vertical}.md-editor textarea:focus{outline:none;box-shadow:none;border-color:var(--color-accent)}.md-editor textarea.monospace{font-family:var(--font-mono)}.md-editor textarea::placeholder{color:var(--color-ink-faint)}.md-preview{overflow-wrap:anywhere;resize:none}.md-preview>span{color:var(--color-ink-faint)}
|
|
.md-preview :global(p){margin:0 0 10px;white-space:normal}.md-preview :global(h1),.md-preview :global(h2),.md-preview :global(h3){margin:12px 0 8px;line-height:1.3}.md-preview :global(h1){font-size:22px}.md-preview :global(h2){font-size:18px}.md-preview :global(h3){font-size:15px}.md-preview :global(ul){list-style:disc;padding-left:24px}.md-preview :global(ol){list-style:decimal;padding-left:24px}.md-preview :global(blockquote){border-left:3px solid var(--color-accent);padding-left:12px;margin:10px 0;color:var(--color-ink-muted)}.md-preview :global(pre){overflow:auto;background:var(--color-surface);padding:10px}.md-preview :global(code){font-family:var(--font-mono)}.md-preview :global(table){border-collapse:collapse;max-width:100%;margin:10px 0}.md-preview :global(th),.md-preview :global(td){border:1px solid var(--color-border);padding:5px 9px;font:inherit}.md-preview :global(a){color:var(--color-accent);text-decoration:underline}.md-preview :global(input[type=checkbox]){width:auto;margin-right:6px}
|
|
.md-footer{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:9px}.md-footer small{font-size:10px;color:var(--color-ink-faint)}.md-editor .md-send{height:32px;padding:0 10px;border:1px solid var(--color-accent);color:var(--color-accent);font-size:11px;white-space:nowrap}.md-editor .md-send:hover:not(:disabled){background:color-mix(in srgb,var(--color-accent) 12%,transparent)}
|
|
@media(max-width:420px){.md-footer small span{display:none}}
|
|
</style>
|