mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
feat(regular-expressions): integrate MarkdownInput for description field with markdown support
This commit is contained in:
294
src/lib/client/ui/form/MarkdownInput.svelte
Normal file
294
src/lib/client/ui/form/MarkdownInput.svelte
Normal file
@@ -0,0 +1,294 @@
|
||||
<script lang="ts">
|
||||
import { Bold, Italic, List, ListOrdered, Link, Code, Eye, Edit3 } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
// Props
|
||||
export let value: string = '';
|
||||
export let placeholder: string = '';
|
||||
export let label: string = '';
|
||||
export let description: string = '';
|
||||
export let rows: number = 4;
|
||||
export let minRows: number = 2;
|
||||
export let multiline: boolean = true;
|
||||
export let markdown: boolean = true;
|
||||
export let required: boolean = false;
|
||||
export let disabled: boolean = false;
|
||||
export let name: string = '';
|
||||
export let id: string = name;
|
||||
export let autoResize: boolean = true;
|
||||
export let onchange: ((value: string) => void) | undefined = undefined;
|
||||
|
||||
// State
|
||||
let showPreview = true;
|
||||
let textareaElement: HTMLTextAreaElement;
|
||||
let inputElement: HTMLInputElement;
|
||||
|
||||
// Auto-resize textarea to fit content
|
||||
function resizeTextarea() {
|
||||
if (!textareaElement || !autoResize || !multiline) return;
|
||||
|
||||
// Reset height to auto to get the correct scrollHeight
|
||||
textareaElement.style.height = 'auto';
|
||||
|
||||
// Calculate min height based on minRows
|
||||
const lineHeight = parseFloat(getComputedStyle(textareaElement).lineHeight) || 20;
|
||||
const paddingTop = parseFloat(getComputedStyle(textareaElement).paddingTop) || 8;
|
||||
const paddingBottom = parseFloat(getComputedStyle(textareaElement).paddingBottom) || 8;
|
||||
const minHeight = lineHeight * minRows + paddingTop + paddingBottom;
|
||||
|
||||
// Set height to scrollHeight (actual content height) or minHeight
|
||||
const newHeight = Math.max(textareaElement.scrollHeight, minHeight);
|
||||
textareaElement.style.height = `${newHeight}px`;
|
||||
}
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement | HTMLTextAreaElement;
|
||||
value = target.value;
|
||||
onchange?.(value);
|
||||
resizeTextarea();
|
||||
}
|
||||
|
||||
// Resize on mount and when value changes externally
|
||||
onMount(() => {
|
||||
resizeTextarea();
|
||||
});
|
||||
|
||||
$: if (textareaElement && value !== undefined) {
|
||||
// Use requestAnimationFrame to ensure DOM is updated
|
||||
requestAnimationFrame(resizeTextarea);
|
||||
}
|
||||
|
||||
function insertMarkdown(before: string, after: string = '') {
|
||||
const element = multiline ? textareaElement : inputElement;
|
||||
if (!element) return;
|
||||
|
||||
const start = element.selectionStart ?? 0;
|
||||
const end = element.selectionEnd ?? 0;
|
||||
const selectedText = value.substring(start, end);
|
||||
|
||||
const newValue =
|
||||
value.substring(0, start) + before + selectedText + after + value.substring(end);
|
||||
|
||||
value = newValue;
|
||||
onchange?.(value);
|
||||
|
||||
// Restore cursor position and resize
|
||||
requestAnimationFrame(() => {
|
||||
element.focus();
|
||||
element.setSelectionRange(
|
||||
start + before.length,
|
||||
start + before.length + selectedText.length
|
||||
);
|
||||
resizeTextarea();
|
||||
});
|
||||
}
|
||||
|
||||
function insertBold() {
|
||||
insertMarkdown('**', '**');
|
||||
}
|
||||
|
||||
function insertItalic() {
|
||||
insertMarkdown('*', '*');
|
||||
}
|
||||
|
||||
function insertCode() {
|
||||
insertMarkdown('`', '`');
|
||||
}
|
||||
|
||||
function insertLink() {
|
||||
const element = multiline ? textareaElement : inputElement;
|
||||
if (!element) return;
|
||||
|
||||
const start = element.selectionStart ?? 0;
|
||||
const end = element.selectionEnd ?? 0;
|
||||
const selectedText = value.substring(start, end);
|
||||
|
||||
if (selectedText) {
|
||||
insertMarkdown('[', '](url)');
|
||||
} else {
|
||||
insertMarkdown('[link text](url)');
|
||||
}
|
||||
}
|
||||
|
||||
function insertList() {
|
||||
const element = multiline ? textareaElement : inputElement;
|
||||
if (!element) return;
|
||||
|
||||
const start = element.selectionStart ?? 0;
|
||||
const lines = value.substring(0, start).split('\n');
|
||||
const isStartOfLine = lines[lines.length - 1].length === 0 || start === 0;
|
||||
|
||||
if (isStartOfLine) {
|
||||
insertMarkdown('- ');
|
||||
} else {
|
||||
insertMarkdown('\n- ');
|
||||
}
|
||||
}
|
||||
|
||||
function insertOrderedList() {
|
||||
const element = multiline ? textareaElement : inputElement;
|
||||
if (!element) return;
|
||||
|
||||
const start = element.selectionStart ?? 0;
|
||||
const lines = value.substring(0, start).split('\n');
|
||||
const isStartOfLine = lines[lines.length - 1].length === 0 || start === 0;
|
||||
|
||||
if (isStartOfLine) {
|
||||
insertMarkdown('1. ');
|
||||
} else {
|
||||
insertMarkdown('\n1. ');
|
||||
}
|
||||
}
|
||||
|
||||
// Simple markdown to HTML renderer for preview
|
||||
function renderMarkdown(text: string): string {
|
||||
if (!text) return '<p class="text-neutral-400 dark:text-neutral-500 italic">Nothing to preview</p>';
|
||||
|
||||
let html = text
|
||||
// Escape HTML
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// Bold
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
// Italic
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
// Inline code
|
||||
.replace(/`(.+?)`/g, '<code class="px-1 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-sm font-mono">$1</code>')
|
||||
// Links
|
||||
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2" class="text-accent-600 dark:text-accent-400 underline" target="_blank" rel="noopener">$1</a>')
|
||||
// Escaped newlines (literal \n)
|
||||
.replace(/\\n/g, '\n')
|
||||
// Line breaks
|
||||
.replace(/\n/g, '<br>');
|
||||
|
||||
// Unordered lists
|
||||
html = html.replace(/(?:^|<br>)- (.+?)(?=<br>|$)/g, '<li class="ml-4 list-disc">$1</li>');
|
||||
|
||||
// Ordered lists
|
||||
html = html.replace(/(?:^|<br>)\d+\. (.+?)(?=<br>|$)/g, '<li class="ml-4 list-decimal">$1</li>');
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
const toolbarButtons = [
|
||||
{ action: insertBold, icon: Bold, title: 'Bold (Ctrl+B)', shortcut: 'b' },
|
||||
{ action: insertItalic, icon: Italic, title: 'Italic (Ctrl+I)', shortcut: 'i' },
|
||||
{ action: insertCode, icon: Code, title: 'Code', shortcut: null },
|
||||
{ action: insertLink, icon: Link, title: 'Link', shortcut: null },
|
||||
{ action: insertList, icon: List, title: 'Bullet List', shortcut: null },
|
||||
{ action: insertOrderedList, icon: ListOrdered, title: 'Numbered List', shortcut: null }
|
||||
];
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!markdown) return;
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'b':
|
||||
e.preventDefault();
|
||||
insertBold();
|
||||
break;
|
||||
case 'i':
|
||||
e.preventDefault();
|
||||
insertItalic();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#if label}
|
||||
<label for={id} class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
{label}
|
||||
{#if required}
|
||||
<span class="text-red-500">*</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
{#if description}
|
||||
<p class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Input container - no gap between toolbar and input -->
|
||||
<div>
|
||||
{#if markdown}
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-between rounded-t-lg border border-neutral-300 bg-neutral-50 px-2 py-1 dark:border-neutral-700 dark:bg-neutral-800/50 {showPreview ? 'border-b-0' : ''}">
|
||||
<div class="flex items-center gap-1">
|
||||
{#each toolbarButtons as btn}
|
||||
<button
|
||||
type="button"
|
||||
onclick={btn.action}
|
||||
title={btn.title}
|
||||
disabled={disabled || showPreview}
|
||||
class="rounded p-1.5 text-neutral-600 transition-colors hover:bg-neutral-200 hover:text-neutral-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-100"
|
||||
>
|
||||
<svelte:component this={btn.icon} size={16} />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPreview = !showPreview)}
|
||||
title={showPreview ? 'Edit' : 'Preview'}
|
||||
class="flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium transition-colors {showPreview
|
||||
? 'bg-accent-100 text-accent-700 dark:bg-accent-900/30 dark:text-accent-400'
|
||||
: 'text-neutral-600 hover:bg-neutral-200 dark:text-neutral-400 dark:hover:bg-neutral-700'}"
|
||||
>
|
||||
{#if showPreview}
|
||||
<Edit3 size={14} />
|
||||
Edit
|
||||
{:else}
|
||||
<Eye size={14} />
|
||||
Preview
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showPreview && markdown}
|
||||
<!-- Preview -->
|
||||
<div
|
||||
class="rounded-b-lg border border-neutral-300 bg-white px-3 py-2 text-sm leading-loose text-neutral-900 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
style="min-height: {minRows * 1.5}rem"
|
||||
>
|
||||
{@html renderMarkdown(value)}
|
||||
</div>
|
||||
{:else if multiline}
|
||||
<!-- Textarea -->
|
||||
<textarea
|
||||
bind:this={textareaElement}
|
||||
{id}
|
||||
{name}
|
||||
{value}
|
||||
{placeholder}
|
||||
rows={autoResize ? minRows : rows}
|
||||
{disabled}
|
||||
{required}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeydown}
|
||||
class="{markdown ? 'rounded-b-lg rounded-t-none border-t-0' : 'rounded-lg'} {autoResize ? 'resize-none overflow-hidden' : ''} block w-full border border-neutral-300 bg-white px-3 py-2 text-sm leading-loose text-neutral-900 placeholder-neutral-400 transition-colors focus:outline-none disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:text-neutral-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500 dark:disabled:bg-neutral-900 dark:disabled:text-neutral-600"
|
||||
></textarea>
|
||||
{:else}
|
||||
<!-- Single-line input -->
|
||||
<input
|
||||
bind:this={inputElement}
|
||||
type="text"
|
||||
{id}
|
||||
{name}
|
||||
{value}
|
||||
{placeholder}
|
||||
{disabled}
|
||||
{required}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeydown}
|
||||
class="{markdown ? 'rounded-b-lg rounded-t-none border-t-0' : 'rounded-lg'} block w-full border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 transition-colors focus:outline-none disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:text-neutral-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500 dark:disabled:bg-neutral-900 dark:disabled:text-neutral-600"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { enhance } from '$app/forms';
|
||||
import { tick } from 'svelte';
|
||||
import TagInput from '$ui/form/TagInput.svelte';
|
||||
import MarkdownInput from '$ui/form/MarkdownInput.svelte';
|
||||
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
|
||||
import RegexPatternField from './RegexPatternField.svelte';
|
||||
import { alertStore } from '$alerts/store';
|
||||
@@ -182,21 +183,16 @@
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Description
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Supports markdown formatting
|
||||
</p>
|
||||
<textarea
|
||||
<MarkdownInput
|
||||
id="description"
|
||||
name="description"
|
||||
label="Description"
|
||||
description="Describe what this pattern matches"
|
||||
value={$current.description}
|
||||
oninput={(e) => update('description', e.currentTarget.value)}
|
||||
rows="3"
|
||||
onchange={(v) => update('description', v)}
|
||||
rows={3}
|
||||
placeholder="What does this pattern match?"
|
||||
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500"
|
||||
></textarea>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user