21 changed files with 473 additions and 646 deletions
@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
|
||||
<script lang="ts"> |
||||
import { Label, Input, Button, Modal } from "flowbite-svelte"; |
||||
import { parseAdvancedmarkup } from "$lib/utils/markup/advancedMarkupParser"; |
||||
import { ATextareaWithPreview } from "$lib/a/index.ts"; |
||||
|
||||
let { |
||||
subject = $bindable(""), |
||||
content = $bindable(""), |
||||
isSubmitting = false, |
||||
clearSignal = 0, |
||||
onSubmit = async (_subject: string, _content: string) => {}, |
||||
} = $props<{ |
||||
subject?: string; |
||||
content?: string; |
||||
isSubmitting?: boolean; |
||||
clearSignal?: number; |
||||
onSubmit?: (subject: string, content: string) => Promise<void> | void; |
||||
}>(); |
||||
|
||||
// Local UI state |
||||
let showConfirmDialog = $state(false); |
||||
|
||||
// Track last clear signal to avoid clearing on mount if default matches |
||||
let _lastClearSignal = $state<number | null>(null); |
||||
$effect(() => { |
||||
if (clearSignal !== _lastClearSignal) { |
||||
if (_lastClearSignal !== null) { |
||||
subject = ""; |
||||
content = ""; |
||||
} |
||||
_lastClearSignal = clearSignal; |
||||
} |
||||
}); |
||||
|
||||
function clearForm() { |
||||
subject = ""; |
||||
content = ""; |
||||
} |
||||
|
||||
function handleSubmit(e: Event) { |
||||
e.preventDefault(); |
||||
showConfirmDialog = true; |
||||
} |
||||
|
||||
async function confirmSubmit() { |
||||
showConfirmDialog = false; |
||||
await onSubmit(subject.trim(), content.trim()); |
||||
} |
||||
|
||||
function cancelSubmit() { |
||||
showConfirmDialog = false; |
||||
} |
||||
</script> |
||||
|
||||
<form class="space-y-4" onsubmit={handleSubmit} autocomplete="off"> |
||||
<div> |
||||
<Label for="subject" class="mb-2">Subject</Label> |
||||
<Input |
||||
id="subject" |
||||
class="w-full" |
||||
placeholder="Issue subject" |
||||
bind:value={subject} |
||||
required |
||||
autofocus |
||||
/> |
||||
</div> |
||||
|
||||
<div class="relative"> |
||||
<ATextareaWithPreview |
||||
id="content" |
||||
label="Description" |
||||
bind:value={content} |
||||
placeholder="Describe your issue. Use the Eye toggle to preview rendered markup." |
||||
parser={parseAdvancedmarkup} |
||||
/> |
||||
</div> |
||||
|
||||
<div class="flex justify-end space-x-4"> |
||||
<Button type="button" color="alternative" onclick={clearForm}>Clear Form</Button> |
||||
<Button type="submit" tabindex={0} disabled={isSubmitting}> |
||||
{#if isSubmitting} |
||||
Submitting... |
||||
{:else} |
||||
Submit Issue |
||||
{/if} |
||||
</Button> |
||||
</div> |
||||
</form> |
||||
|
||||
<!-- Confirmation Dialog --> |
||||
<Modal bind:open={showConfirmDialog} size="sm" autoclose={false} class="w-full"> |
||||
<div class="text-center"> |
||||
<h3 class="mb-5 text-lg font-normal text-gray-700 dark:text-gray-300"> |
||||
Would you like to submit the issue? |
||||
</h3> |
||||
<div class="flex justify-center gap-4"> |
||||
<Button color="alternative" onclick={cancelSubmit}>Cancel</Button> |
||||
<Button color="primary" onclick={confirmSubmit}>Submit</Button> |
||||
</div> |
||||
</div> |
||||
</Modal> |
||||
@ -0,0 +1,158 @@
@@ -0,0 +1,158 @@
|
||||
<script lang="ts"> |
||||
import { Textarea, Toolbar, ToolbarGroup, ToolbarButton, Label, Button } from "flowbite-svelte"; |
||||
import { Bold, Italic, Strikethrough, Quote, Link2, Image, Hash, List, ListOrdered, Eye, EyeClosed } from "@lucide/svelte"; |
||||
|
||||
// Reusable editor with toolbar (from ACommentForm) and toolbar-only Preview |
||||
let { |
||||
value = $bindable(""), |
||||
id = "editor", |
||||
label = "", |
||||
rows = 10, |
||||
placeholder = "", |
||||
// async parser that returns HTML string |
||||
parser = async (s: string) => s, |
||||
// optional snippet renderer (e.g., (html) => basicMarkup(html, ndk)) |
||||
previewRenderer, |
||||
// extra toolbar extensions (snippet returning toolbar buttons) |
||||
extensions, |
||||
} = $props<{ |
||||
value?: string; |
||||
id?: string; |
||||
label?: string; |
||||
rows?: number; |
||||
placeholder?: string; |
||||
parser?: (s: string) => Promise<string> | string; |
||||
previewRenderer?: (html: string) => any; |
||||
extensions?: any; |
||||
}>(); |
||||
|
||||
let preview = $state(""); |
||||
let activeTab = $state<"write" | "preview">("write"); |
||||
let wrapper: HTMLElement | null = null; |
||||
let isExpanded = $state(false); |
||||
|
||||
const markupButtons = [ |
||||
{ label: "Bold", icon: Bold, action: () => insertMarkup("**", "**") }, |
||||
{ label: "Italic", icon: Italic, action: () => insertMarkup("_", "_") }, |
||||
{ label: "Strike", icon: Strikethrough, action: () => insertMarkup("~~", "~~") }, |
||||
{ label: "Link", icon: Link2, action: () => insertMarkup("[", "](url)") }, |
||||
{ label: "Image", icon: Image, action: () => insertMarkup("") }, |
||||
{ label: "Quote", icon: Quote, action: () => insertMarkup("> ", "") }, |
||||
{ label: "List", icon: List, action: () => insertMarkup("* ", "") }, |
||||
{ label: "Numbered List", icon: ListOrdered, action: () => insertMarkup("1. ", "") }, |
||||
{ label: "Hashtag", icon: Hash, action: () => insertMarkup("#", "") }, |
||||
]; |
||||
|
||||
function insertMarkup(prefix: string, suffix: string) { |
||||
const textarea = wrapper?.querySelector("textarea") as HTMLTextAreaElement | null; |
||||
if (!textarea) return; |
||||
|
||||
const start = textarea.selectionStart; |
||||
const end = textarea.selectionEnd; |
||||
const selectedText = value.substring(start, end); |
||||
|
||||
value = value.substring(0, start) + prefix + selectedText + suffix + value.substring(end); |
||||
|
||||
// Set cursor position after the inserted markup |
||||
setTimeout(() => { |
||||
textarea.focus(); |
||||
textarea.selectionStart = textarea.selectionEnd = start + prefix.length + selectedText.length + suffix.length; |
||||
}, 0); |
||||
} |
||||
|
||||
function togglePreview() { |
||||
activeTab = activeTab === "write" ? "preview" : "write"; |
||||
} |
||||
|
||||
function toggleSize() { |
||||
isExpanded = !isExpanded; |
||||
} |
||||
|
||||
$effect(() => { |
||||
if (activeTab !== "preview") return; |
||||
const src = value.trim(); |
||||
if (!src) { |
||||
preview = ""; |
||||
return; |
||||
} |
||||
Promise.resolve(parser(src)).then((html) => { |
||||
preview = html || ""; |
||||
}); |
||||
}); |
||||
</script> |
||||
|
||||
{#if label} |
||||
<Label for={id} class="mb-2">{label}</Label> |
||||
{/if} |
||||
|
||||
<div bind:this={wrapper} class="rounded-lg"> |
||||
<div class="min-h-[180px] relative"> |
||||
{#if activeTab === 'write'} |
||||
<div class="inset-0"> |
||||
<Textarea |
||||
id={id} |
||||
rows={isExpanded ? 30 : rows} |
||||
bind:value={value} |
||||
class="!m-0 p-0 h-full" |
||||
innerClass="!m-0 !bg-transparent !dark:bg-transparent" |
||||
headerClass="!m-0 !bg-transparent !dark:bg-transparent" |
||||
footerClass="!m-0 !bg-transparent" |
||||
addonClass="!m-0 top-3 hidden md:flex" |
||||
textareaClass="!m-0 !bg-transparent !dark:bg-transparent !border-0 !rounded-none !shadow-none !focus:ring-0" |
||||
placeholder={placeholder} |
||||
> |
||||
{#snippet header()} |
||||
<Toolbar embedded class="flex-row !m-0 !dark:bg-transparent !bg-transparent"> |
||||
<ToolbarGroup class="flex-row flex-wrap !m-0"> |
||||
{#each markupButtons as button} |
||||
{@const TheIcon = button.icon} |
||||
<ToolbarButton title={button.label} color="dark" size="md" onclick={button.action}> |
||||
<TheIcon size={24} /> |
||||
</ToolbarButton> |
||||
{/each} |
||||
{#if extensions} |
||||
{@render extensions()} |
||||
{/if} |
||||
<ToolbarButton title="Toggle preview" color="dark" size="md" onclick={togglePreview}> |
||||
<Eye size={24} /> |
||||
</ToolbarButton> |
||||
</ToolbarGroup> |
||||
</Toolbar> |
||||
{/snippet} |
||||
</Textarea> |
||||
<Button |
||||
type="button" |
||||
size="xs" |
||||
class="absolute bottom-2 right-2 z-10 opacity-60 hover:opacity-100" |
||||
color="light" |
||||
onclick={toggleSize} |
||||
> |
||||
{isExpanded ? "⌃" : "⌄"} |
||||
</Button> |
||||
</div> |
||||
{:else} |
||||
<div class="absolute rounded-lg inset-0 flex flex-col bg-white"> |
||||
<div class="py-2 px-3 border-gray-200 dark:border-gray-500 border-b"> |
||||
<Toolbar embedded class="flex-row !m-0 !dark:bg-transparent !bg-transparent"> |
||||
<ToolbarGroup class="flex-row flex-wrap !m-0"> |
||||
<ToolbarButton title="Back to editor" color="dark" size="md" onclick={togglePreview}> |
||||
<EyeClosed size={24} /> |
||||
</ToolbarButton> |
||||
</ToolbarGroup> |
||||
</Toolbar> |
||||
</div> |
||||
<div class="flex-1 overflow-auto px-4 max-w-none bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 prose-content markup-content rounded-b-lg"> |
||||
{#if preview} |
||||
{#if previewRenderer} |
||||
{@render previewRenderer(preview)} |
||||
{:else} |
||||
{@html preview} |
||||
{/if} |
||||
{:else} |
||||
<p class="text-xs text-gray-500">Nothing to preview</p> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
@ -1,7 +0,0 @@
@@ -1,7 +0,0 @@
|
||||
<script lang="ts"> |
||||
let { className = '' , content } = $props(); |
||||
</script> |
||||
|
||||
<article class="mx-auto max-w-measure font-reading leading-reading text-[1rem] {className}"> |
||||
{@render content()} |
||||
</article> |
||||
@ -1,70 +0,0 @@
@@ -1,70 +0,0 @@
|
||||
<script lang="ts"> |
||||
import { onMount } from 'svelte'; |
||||
import type { TocItem } from './toc-utils'; |
||||
import { ATocNode } from "$lib/a"; |
||||
|
||||
let { |
||||
items = [] as TocItem[], |
||||
activeId = null as string | null, |
||||
collapsible = true, |
||||
expandDepth = 1, |
||||
class: className = '', |
||||
onnavigate = undefined as undefined | ((href: string) => void) |
||||
} = $props(); |
||||
|
||||
let expanded = $state(new Set<string>()); |
||||
let parentOf = new Map<string, string | null>(); |
||||
|
||||
function mapParents(list: TocItem[], parent: string | null = null) { |
||||
for (const it of list) { |
||||
parentOf.set(it.id, parent); |
||||
if (it.children?.length) mapParents(it.children, it.id); |
||||
} |
||||
} |
||||
|
||||
function initExpansion(list: TocItem[], depth = 0) { |
||||
for (const it of list) { |
||||
if (depth < expandDepth) expanded.add(it.id); |
||||
if (it.children?.length) initExpansion(it.children, depth + 1); |
||||
} |
||||
expanded = new Set(expanded); |
||||
} |
||||
|
||||
function expandAncestors(id: string | null) { |
||||
if (!id) return; |
||||
let cur: string | null | undefined = id; |
||||
while (cur) { |
||||
expanded.add(cur); |
||||
cur = parentOf.get(cur) ?? null; |
||||
} |
||||
expanded = new Set(expanded); |
||||
} |
||||
|
||||
onMount(() => { |
||||
initExpansion(items, 0); |
||||
expandAncestors(activeId); |
||||
if (activeId) { |
||||
const el = document.querySelector(`[data-toc-id="${activeId}"]`); |
||||
if (el instanceof HTMLElement) el.scrollIntoView({ block: 'nearest' }); |
||||
} |
||||
}); |
||||
|
||||
function toggle(id: string) { |
||||
if (!collapsible) return; |
||||
if (expanded.has(id)) expanded.delete(id); |
||||
else expanded.add(id); |
||||
expanded = new Set(expanded); |
||||
} |
||||
|
||||
const pad = (depth: number) => `padding-left: calc(var(--space-4) * ${Math.max(depth, 0)})`; |
||||
const onNavigate = (href: string) => onnavigate?.(href); |
||||
|
||||
</script> |
||||
|
||||
<nav aria-label="Table of contents" class={`text-sm ${className}`}> |
||||
<ul class="space-y-1 max-h-[calc(100vh-6rem)] overflow-auto pr-1"> |
||||
{#each items as item (item.id)} |
||||
<ATocNode {item} depth={0} {activeId} {collapsible} {expanded} {toggle} {pad} {onNavigate} /> |
||||
{/each} |
||||
</ul> |
||||
</nav> |
||||
@ -1,47 +0,0 @@
@@ -1,47 +0,0 @@
|
||||
<script lang="ts"> |
||||
import { theme, setTheme } from '$lib/stores/themeStore.ts'; |
||||
let size = 16; |
||||
let line = 1.7; |
||||
function applySize() { |
||||
document.documentElement.style.fontSize = size + 'px'; |
||||
} |
||||
function incSize() { |
||||
size = Math.min(22, size + 1); |
||||
applySize(); |
||||
} |
||||
function decSize() { |
||||
size = Math.max(14, size - 1); |
||||
applySize(); |
||||
} |
||||
function incLine() { |
||||
line = Math.min(2, Math.round((line + 0.05) * 100) / 100); |
||||
document.documentElement.style.setProperty('--leading-reading', String(line)); |
||||
} |
||||
function decLine() { |
||||
line = Math.max(1.4, Math.round((line - 0.05) * 100) / 100); |
||||
document.documentElement.style.setProperty('--leading-reading', String(line)); |
||||
} |
||||
</script> |
||||
|
||||
<div class="flex items-center gap-3 p-2 border-b border-muted/20 bg-surface sticky top-0 z-10"> |
||||
<label class="text-sm opacity-70">Theme</label> |
||||
<select |
||||
class="h-9 px-2 rounded-md border border-muted/30 bg-surface" |
||||
bind:value={$theme} |
||||
on:change={(e) => setTheme((e.target as HTMLSelectElement).value)} |
||||
> |
||||
<option value="light">Light</option> |
||||
<option value="dark">Dark</option> |
||||
<option value="sepia">Sepia</option> |
||||
</select> |
||||
<div class="mx-2 h-6 w-px bg-muted/30" /> |
||||
<label class="text-sm opacity-70">Text size</label> |
||||
<Button variant="outline" size="sm" on:click={decSize}>−</Button> |
||||
<span class="text-sm w-8 text-center">{size}px</span> |
||||
<Button variant="outline" size="sm" on:click={incSize}>+</Button> |
||||
<div class="mx-2 h-6 w-px bg-muted/30" /> |
||||
<label class="text-sm opacity-70">Line height</label> |
||||
<Button variant="outline" size="sm" on:click={decLine}>−</Button> |
||||
<span class="text-sm w-10 text-center">{line}</span> |
||||
<Button variant="outline" size="sm" on:click={incLine}>+</Button> |
||||
</div> |
||||
@ -1,32 +0,0 @@
@@ -1,32 +0,0 @@
|
||||
<script lang="ts"> |
||||
import type { TocItem } from './toc-utils'; |
||||
import TocNode from './ATocNode.svelte'; |
||||
let { item, depth = 0, activeId = null as string | null, collapsible = true, expanded, toggle, pad, onNavigate }: { item: TocItem; depth?: number; activeId?: string|null; collapsible?: boolean; expanded: Set<string>; toggle: (id:string)=>void; pad: (depth:number)=>string; onNavigate: (href:string)=>void; } = $props(); |
||||
const hasChildren = !!(item.children && item.children.length > 0); |
||||
let isOpen = $derived(expanded.has(item.id)); |
||||
let isActive = $derived(activeId === item.id); |
||||
</script> |
||||
<li> |
||||
<div class="flex items-center gap-1 rounded-md hover:bg-primary/10" style={pad(depth)}> |
||||
{#if collapsible && hasChildren} |
||||
<button class="shrink-0 h-6 w-6 grid place-items-center rounded-md hover:bg-primary/10" aria-label={isOpen?`Collapse ${item.title}`:`Expand ${item.title}`} aria-expanded={isOpen} onclick={() => toggle(item.id)}> |
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={isOpen ? 'rotate-90 transition-transform' : 'transition-transform'}><path d="M9 18l6-6-6-6" /></svg> |
||||
</button> |
||||
{:else} |
||||
<span class="shrink-0 h-6 w-6" /> |
||||
{/if} |
||||
<a href={item.href ?? `#${item.id}`} data-toc-id={item.id} |
||||
class="flex-1 min-w-0 rounded-md px-2 py-1.5 hover:bg-primary/10 focus:outline-none focus:ring-2 focus:ring-primary/40" |
||||
class:text-primary={isActive} |
||||
onclick={() => onNavigate(item.href ?? `#${item.id}`)}> |
||||
<span class="truncate">{item.title}</span> |
||||
</a> |
||||
</div> |
||||
{#if hasChildren} |
||||
<ul id={`sub-${item.id}`} aria-hidden={!isOpen} class={isOpen ? 'mt-1 space-y-1' : 'hidden'}> |
||||
{#each item.children as child (child.id)} |
||||
<TocNode item={child} depth={depth + 1} {activeId} {collapsible} {expanded} {toggle} {pad} {onNavigate} /> |
||||
{/each} |
||||
</ul> |
||||
{/if} |
||||
</li> |
||||
@ -1 +0,0 @@
@@ -1 +0,0 @@
|
||||
import { writable } from 'svelte/store'; export function createScrollSpy(ids:string[], opts:{ rootMargin?:string; threshold?:number[] }={}){ const active=writable<string|null>(null); let observer:IntersectionObserver|null=null; function start(){ if(typeof window==='undefined' || typeof IntersectionObserver==='undefined') return; observer=new IntersectionObserver((entries)=>{ const visible=entries.filter(e=>e.isIntersecting).sort((a,b)=>a.target.getBoundingClientRect().top - b.target.getBoundingClientRect().top); if(visible[0]) active.set((visible[0].target as HTMLElement).id); }, { rootMargin: opts.rootMargin ?? '-30% 0px -60% 0px', threshold: opts.threshold ?? [0,1] }); for(const id of ids){ const el=document.getElementById(id); if(el) observer.observe(el); } } function stop(){ observer?.disconnect(); observer=null; } return { active, start, stop }; } |
||||
@ -1,4 +0,0 @@
@@ -1,4 +0,0 @@
|
||||
export type TocItem = { id:string; title:string; href?:string; children?: TocItem[]; }; |
||||
const slugify=(s:string)=>s.toLowerCase().trim().replace(/[^\\w\\s-]/g,'').replace(/\\s+/g,'-').slice(0,80); |
||||
export function buildTocFromDocument(root:ParentNode=document, levels:number[]=[2,3,4]):TocItem[]{ const selector=levels.map(l=>`h${'${'}l}`).join(','); const nodes=Array.from(root.querySelectorAll(selector)) as HTMLElement[]; const toc:TocItem[]=[]; const stack:{level:number; item:TocItem}[]=[]; for(const el of nodes){ const level=Number(el.tagName.slice(1)); const text=(el.textContent||'').trim(); if(!text) continue; if(!el.id) el.id=slugify(text); const entry: TocItem = { id:el.id, title:text, href:'#'+el.id, children:[] }; while(stack.length && stack[stack.length-1].level>=level) stack.pop(); if(stack.length===0) toc.push(entry); else stack[stack.length-1].item.children!.push(entry); stack.push({level,item:entry}); } return toc; } |
||||
export function idsFromToc(items:TocItem[]):string[]{ const out:string[]=[]; const walk=(arr:TocItem[])=>{ for(const it of arr){ out.push(it.id); if(it.children?.length) walk(it.children); } }; walk(items); return out; } |
||||
Loading…
Reference in new issue