Spaces:
Running
Running
| <script lang="ts"> | |
| import { marked } from 'marked'; | |
| import { createEventDispatcher } from 'svelte'; | |
| import type { SelectData } from '@gradio/utils'; | |
| import { Copy } from '@gradio/icons'; | |
| export let markdown_content: string = ''; | |
| export let highlights: Array<{ | |
| term?: string; | |
| position?: number[]; | |
| title: string; | |
| content: string; | |
| category: string; | |
| color: string; | |
| }> = []; | |
| export let show_side_panel: boolean = true; | |
| export let panel_width: string = '300px'; | |
| export let interactive: boolean = false; | |
| export let edit_mode: string = 'split'; // 'split', 'tabs', 'overlay' | |
| export let show_preview: boolean = true; | |
| export const markdown_editor: string = 'textarea'; | |
| const dispatch = createEventDispatcher<{ | |
| select: SelectData; | |
| change: { markdown_content: string; highlights: any[] }; | |
| edit: { markdown_content: string; highlights: any[] }; | |
| save: { markdown_content: string; highlights: any[] }; | |
| cancel: { markdown_content: string; highlights: any[] }; | |
| }>(); | |
| let selectedHighlight: typeof highlights[0] | null = null; | |
| let processedHtml: string = ''; | |
| let panelContent: string = ''; | |
| let isEditing: boolean = false; | |
| let editingContent: string = ''; | |
| let originalContent: string = ''; | |
| let currentTab: 'edit' | 'preview' | 'highlights' = 'edit'; | |
| // Track original state for cancel functionality | |
| $: { | |
| if (!isEditing) { | |
| originalContent = markdown_content; | |
| } | |
| } | |
| // Process markdown and apply highlighting | |
| $: { | |
| const contentToRender = isEditing && edit_mode === 'split' && show_preview ? editingContent : markdown_content; | |
| if (contentToRender) { | |
| processMarkdown(contentToRender); | |
| } | |
| } | |
| // Process panel content | |
| $: { | |
| if (selectedHighlight) { | |
| processPanelContent(); | |
| } | |
| } | |
| async function processMarkdown(contentToRender: string) { | |
| // Separate position-based and term-based highlights | |
| const positionHighlights = highlights.filter(h => h.position && h.position.length === 2); | |
| const termHighlights = highlights.filter(h => h.term && h.term.trim()); | |
| // Apply position-based highlights first by inserting special markers | |
| // Note: Only apply position highlights if contentToRender matches original markdown_content | |
| let processedMarkdown = contentToRender; | |
| if (contentToRender === markdown_content) { | |
| processedMarkdown = applyPositionMarkers(contentToRender, positionHighlights); | |
| } | |
| // Then parse the markdown to HTML | |
| let html = await marked(processedMarkdown); | |
| // Replace position markers with actual highlight spans (only if we applied them) | |
| if (contentToRender === markdown_content) { | |
| html = replacePositionMarkers(html, positionHighlights); | |
| } | |
| // Apply term-based highlights to the HTML | |
| termHighlights.forEach(highlight => { | |
| if (highlight.term && highlight.term.trim()) { | |
| const index = highlights.indexOf(highlight); | |
| const regex = new RegExp(`\\b${escapeRegex(highlight.term)}\\b`, 'gi'); | |
| html = html.replace(regex, (match: string) => { | |
| const color = highlight.color || '#e3f2fd'; | |
| return `<span class="highlight-term" | |
| data-index="${index}" | |
| data-term="${highlight.term}" | |
| style="${getHighlightStyle(color)}" | |
| role="button" | |
| tabindex="0" | |
| aria-label="Highlighted term: ${highlight.term}"> | |
| ${match} | |
| `; | |
| }); | |
| } | |
| }); | |
| processedHtml = html; | |
| } | |
| function applyPositionMarkers(content: string, positionHighlights: typeof highlights): string { | |
| // Sort highlights by start position in descending order to avoid position shifts | |
| const sortedHighlights = [...positionHighlights].sort((a, b) => (b.position![0] - a.position![0])); | |
| let result = content; | |
| sortedHighlights.forEach(highlight => { | |
| const [start, end] = highlight.position!; | |
| if (start >= 0 && end <= result.length && start < end) { | |
| const targetText = result.substring(start, end); | |
| const index = highlights.indexOf(highlight); | |
| // Skip if the highlight crosses structural markdown boundaries | |
| if (crossesMarkdownBoundary(targetText)) { | |
| console.warn(`Position highlight [${start}, ${end}] crosses markdown boundary, skipping`); | |
| return; | |
| } | |
| // Create unique markers that won't interfere with markdown rendering | |
| const startMarker = `|||POSHL_START_${index}|||`; | |
| const endMarker = `|||POSHL_END_${index}|||`; | |
| result = result.substring(0, start) + startMarker + targetText + endMarker + result.substring(end); | |
| } | |
| }); | |
| return result; | |
| } | |
| function crossesMarkdownBoundary(text: string): boolean { | |
| // Check if the highlighted text crosses markdown structural boundaries | |
| // that would break the markdown parsing | |
| return ( | |
| text.includes('\n#') || // Header boundaries | |
| text.includes('\n\n') || // Paragraph boundaries | |
| text.includes('\n-') || // List boundaries | |
| text.includes('\n*') || // List boundaries | |
| text.includes('\n1.') || // Numbered list boundaries | |
| text.includes('\n>') || // Blockquote boundaries | |
| text.includes('\n```') || // Code block boundaries | |
| text.includes('\n|') || // Table boundaries | |
| !!text.match(/^\s*#/) || // Starts with header | |
| !!text.match(/\n\s*#/) // Contains header start | |
| ); | |
| } | |
| function replacePositionMarkers(html: string, positionHighlights: typeof highlights): string { | |
| positionHighlights.forEach(highlight => { | |
| const [start, end] = highlight.position!; | |
| if (start >= 0 && end <= markdown_content.length && start < end) { | |
| const targetText = markdown_content.substring(start, end); | |
| const color = highlight.color || '#e3f2fd'; | |
| const index = highlights.indexOf(highlight); | |
| const startMarker = `|||POSHL_START_${index}|||`; | |
| const endMarker = `|||POSHL_END_${index}|||`; | |
| // Find and replace the markers with highlight spans | |
| const markerRegex = new RegExp(`\\|\\|\\|POSHL_START_${index}\\|\\|\\|(.*?)\\|\\|\\|POSHL_END_${index}\\|\\|\\|`, 'g'); | |
| html = html.replace(markerRegex, (match, content) => { | |
| return `<span class="highlight-position" | |
| data-index="${index}" | |
| data-text="${encodeURIComponent(targetText)}" | |
| style="${getHighlightStyle(color)}" | |
| role="button" | |
| tabindex="0" | |
| aria-label="Highlighted text: ${targetText.replace(/"/g, '"')}"> | |
| ${content} | |
| </span>`; | |
| }); | |
| } | |
| }); | |
| return html; | |
| } | |
| async function processPanelContent() { | |
| if (selectedHighlight) { | |
| panelContent = await marked(selectedHighlight.content || 'No additional information available.'); | |
| } | |
| } | |
| function escapeRegex(string: string): string { | |
| return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| } | |
| function getHighlightStyle(color: string): string { | |
| return `background-color: ${color}; cursor: pointer; padding: 2px 2px; border-radius: 3px; transition: all 0.2s; padding-left: 4px;`; | |
| } | |
| function handleTermClick(event: Event) { | |
| const target = event.target as HTMLElement; | |
| if (target.classList.contains('highlight-term') || target.classList.contains('highlight-position')) { | |
| const index = parseInt(target.dataset.index || '0'); | |
| const highlight = highlights[index]; | |
| if (highlight) { | |
| selectedHighlight = highlight; | |
| dispatch('select', { | |
| index, | |
| value: highlight | |
| }); | |
| } | |
| } | |
| } | |
| function handleKeydown(event: KeyboardEvent) { | |
| const target = event.target as HTMLElement; | |
| if ((event.key === 'Enter' || event.key === ' ') && | |
| (target.classList.contains('highlight-term') || target.classList.contains('highlight-position'))) { | |
| event.preventDefault(); | |
| handleTermClick(event); | |
| } | |
| } | |
| function closeSidePanel() { | |
| selectedHighlight = null; | |
| } | |
| function startEditing() { | |
| isEditing = true; | |
| editingContent = markdown_content; | |
| originalContent = markdown_content; | |
| dispatch('edit', { markdown_content, highlights }); | |
| } | |
| function saveChanges() { | |
| markdown_content = editingContent; | |
| isEditing = false; | |
| dispatch('save', { markdown_content, highlights }); | |
| // Only dispatch change event on explicit save | |
| dispatch('change', { markdown_content, highlights }); | |
| } | |
| function cancelEditing() { | |
| editingContent = originalContent; | |
| isEditing = false; | |
| dispatch('cancel', { markdown_content: originalContent, highlights }); | |
| } | |
| function handleTextareaInput(event: Event) { | |
| const target = event.target as HTMLTextAreaElement; | |
| editingContent = target.value; | |
| // Don't dispatch change events in real-time to avoid loops | |
| // Changes will be dispatched only on save | |
| } | |
| function setTab(tab: typeof currentTab) { | |
| currentTab = tab; | |
| } | |
| async function copyToClipboard() { | |
| try { | |
| const contentToCopy = isEditing ? editingContent : markdown_content; | |
| if (navigator.clipboard && window.isSecureContext) { | |
| await navigator.clipboard.writeText(contentToCopy); | |
| } else { | |
| // Fallback for older browsers or non-secure contexts | |
| const textArea = document.createElement('textarea'); | |
| textArea.value = contentToCopy; | |
| textArea.style.position = 'fixed'; | |
| textArea.style.left = '-999999px'; | |
| textArea.style.top = '-999999px'; | |
| document.body.appendChild(textArea); | |
| textArea.focus(); | |
| textArea.select(); | |
| document.execCommand('copy'); | |
| textArea.remove(); | |
| } | |
| } catch (err) { | |
| console.error('Failed to copy text: ', err); | |
| } | |
| } | |
| </script> | |
| <div class="markdown-container" class:editing={isEditing} class:with-panel={show_side_panel && selectedHighlight}> | |
| <!-- View Mode Controls --> | |
| {#if !isEditing} | |
| <div class="edit-controls"> | |
| {#if interactive} | |
| <button class="edit-btn" on:click={startEditing} | |
| {/if} | |
| </div> | |
| {/if} | |
| {#if isEditing} | |
| <div class="edit-controls"> | |
| <button class="save-btn" on:click={saveChanges} | |
| <button class="cancel-btn" on:click={cancelEditing}> | |
| ❌ Cancel | |
| </button> | |
| {#if edit_mode === 'tabs'} | |
| <div class="tab-controls"> | |
| <button | |
| class="tab-btn" | |
| class:active={currentTab === 'edit'} | |
| on:click={() => setTab('edit')} | |
| <button | |
| class="tab-btn" | |
| class:active={currentTab === 'preview'} | |
| on:click={() => setTab('preview')} | |
| > | |
| Preview | |
| </button> | |
| </div> | |
| {/if} | |
| </div> | |
| {/if} | |
| <!-- Content Area --> | |
| <div class="content-area" class:split-mode={edit_mode === 'split' && isEditing}> | |
| {#if isEditing} | |
| {#if edit_mode === 'split'} | |
| <!-- Split Mode: Editor and Preview Side by Side --> | |
| <div class="editor-section"> | |
| <div class="editor-header"> | |
| <textarea | |
| class="markdown-editor" | |
| bind:value={editingContent} | |
| on:input={handleTextareaInput} | |
| placeholder="Enter your markdown content..." | |
| spellcheck="false" | |
| ></textarea> | |
| </div> | |
| {#if show_preview} | |
| <div class="preview-section"> | |
| <div class="preview-header"> | |
| {@html processedHtml} | |
| {/if} | |
| {:else if edit_mode === 'tabs'} | |
| <!-- Tab Mode: Switch between Edit and Preview --> | |
| <div class="tab-content"> | |
| {#if currentTab === 'edit'} | |
| <textarea | |
| class="markdown-editor fullwidth" | |
| bind:value={editingContent} | |
| on:input={handleTextareaInput} | |
| placeholder="Enter your markdown content..." | |
| spellcheck="false" | |
| ></textarea> | |
| {:else if currentTab === 'preview'} | |
| {@html processedHtml} | |
| {/if} | |
| </div> | |
| {/if} | |
| {:else} | |
| <!-- View Mode: Regular markdown display --> | |
| {@html processedHtml} | |
| {/if} | |
| </div> | |
| <!-- Side Panel (same as before) --> | |
| {#if show_side_panel && selectedHighlight} | |
| <div class="side-panel" style="width: {panel_width}"> | |
| <div class="panel-header"> | |
| {selectedHighlight.title || selectedHighlight.term || 'Highlighted Text'} | |
| <button class="close-btn" on:click={closeSidePanel} | |
| <div class="panel-content"> | |
| {#if selectedHighlight.category} | |
| <div class="category-badge" style="background-color: {selectedHighlight.color || '#e3f2fd'}"> | |
| {selectedHighlight.category} | |
| {/if} | |
| <div class="content-text"> | |
| {@html panelContent} | |
| </div> | |
| {/if} | |
| </div> | |
| { | |
| display: flex; | |
| flex-direction: column; | |
| height: 100%; | |
| position: relative; | |
| transition: all 0.3s ease; | |
| } | |
| .edit-controls { | |
| display: flex; | |
| gap: var(--spacing-sm); | |
| padding: var(--spacing-sm); | |
| background: var(--background-fill-secondary); | |
| border-bottom: 1px solid var(--border-color-primary); | |
| align-items: center; | |
| } | |
| .edit-btn, .save-btn, .cancel-btn { | |
| padding: var(--size-1) var(--size-2); | |
| border: 1px solid var(--border-color-primary); | |
| border-radius: var(--radius-sm); | |
| background: var(--background-fill-primary); | |
| cursor: pointer; | |
| font-size: var(--text-sm); | |
| transition: all 0.2s; | |
| } | |
| .edit-btn:hover { | |
| background: var(--background-fill-secondary); | |
| } | |
| .save-btn { | |
| background: var(--color-accent); | |
| color: white; | |
| border-color: var(--color-accent); | |
| } | |
| .save-btn:hover { | |
| opacity: 0.9; | |
| } | |
| .cancel-btn { | |
| background: var(--color-red-500); | |
| color: white; | |
| border-color: var(--color-red-500); | |
| } | |
| .cancel-btn:hover { | |
| opacity: 0.9; | |
| } | |
| .tab-controls { | |
| display: flex; | |
| gap: 2px; | |
| margin-left: auto; | |
| } | |
| .tab-btn { | |
| padding: var(--size-1) var(--size-3); | |
| border: 1px solid var(--border-color-primary); | |
| background: var(--background-fill-primary); | |
| cursor: pointer; | |
| font-size: var(--text-sm); | |
| border-radius: var(--radius-sm) var(--radius-sm) 0 0; | |
| transition: all 0.2s; | |
| } | |
| .tab-btn.active { | |
| background: var(--background-fill-secondary); | |
| border-bottom-color: var(--background-fill-secondary); | |
| } | |
| .tab-btn:hover:not(.active) { | |
| background: var(--background-fill-secondary); | |
| opacity: 0.7; | |
| } | |
| .copy-btn { | |
| padding: var(--size-1); | |
| border: 1px solid var(--border-color-primary); | |
| border-radius: var(--radius-sm); | |
| background: var(--background-fill-primary); | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| margin-left: var(--spacing-sm); | |
| width: 24px; | |
| height: 24px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| color: var(--body-text-color); | |
| } | |
| .copy-btn:hover { | |
| background: var(--background-fill-secondary); | |
| transform: scale(1.05); | |
| } | |
| .copy-btn:active { | |
| transform: scale(0.95); | |
| } | |
| .content-area { | |
| flex: 1; | |
| display: flex; | |
| overflow: hidden; | |
| } | |
| .content-area.split-mode { | |
| flex-direction: row; | |
| } | |
| .editor-section, .preview-section { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| border-right: 1px solid var(--border-color-primary); | |
| } | |
| .preview-section { | |
| border-right: none; | |
| border-left: 1px solid var(--border-color-primary); | |
| } | |
| .editor-header, .preview-header { | |
| padding: var(--size-2); | |
| background: var(--background-fill-secondary); | |
| border-bottom: 1px solid var(--border-color-primary); | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .editor-header h4, .preview-header h4 { | |
| margin: 0; | |
| font-size: var(--text-sm); | |
| font-weight: var(--weight-semibold); | |
| color: var(--body-text-color); | |
| } | |
| .markdown-editor { | |
| flex: 1; | |
| width: 100%; | |
| padding: var(--size-3); | |
| border: none; | |
| resize: none; | |
| font-family: var(--font-mono); | |
| font-size: var(--text-sm); | |
| line-height: 1.5; | |
| background: var(--background-fill-primary); | |
| color: var(--body-text-color); | |
| outline: none; | |
| } | |
| .markdown-editor.fullwidth { | |
| height: 400px; | |
| } | |
| .tab-content { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .markdown-content { | |
| flex: 1; | |
| padding: var(--block-padding); | |
| overflow-y: auto; | |
| transition: margin-right 0.3s ease; | |
| } | |
| .markdown-content.preview { | |
| background: var(--background-fill-primary); | |
| } | |
| .with-panel .markdown-content { | |
| margin-right: var(--spacing-md); | |
| } | |
| .side-panel { | |
| position: fixed; | |
| top: 0; | |
| right: 0; | |
| height: 100vh; | |
| background: var(--background-fill-primary); | |
| border-left: 1px solid var(--border-color-primary); | |
| box-shadow: -2px 0 10px rgba(0, 0, 0, 0.1); | |
| z-index: 1000; | |
| overflow-y: auto; | |
| transform: translateX(0); | |
| transition: transform 0.3s ease; | |
| } | |
| .panel-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: var(--size-4); | |
| border-bottom: 1px solid var(--border-color-primary); | |
| background: var(--background-fill-secondary); | |
| } | |
| .panel-header h3 { | |
| margin: 0; | |
| font-size: var(--text-lg); | |
| font-weight: var(--weight-semibold); | |
| color: var(--body-text-color); | |
| } | |
| .close-btn { | |
| background: none; | |
| border: none; | |
| font-size: var(--text-xl); | |
| cursor: pointer; | |
| color: var(--body-text-color); | |
| padding: var(--size-1); | |
| border-radius: var(--radius-sm); | |
| transition: background-color 0.2s; | |
| } | |
| .close-btn:hover { | |
| background-color: var(--background-fill-primary); | |
| } | |
| .panel-content { | |
| padding: var(--size-4); | |
| } | |
| .category-badge { | |
| display: inline-block; | |
| padding: var(--size-1) var(--size-2); | |
| border-radius: var(--radius-full); | |
| font-size: var(--text-sm); | |
| font-weight: var(--weight-medium); | |
| margin-bottom: var(--size-3); | |
| color: var(--body-text-color); | |
| } | |
| .content-text { | |
| line-height: 1.6; | |
| color: var(--body-text-color); | |
| } | |
| /* Markdown styling (same as before) */ | |
| .markdown-content :global(h1) { | |
| font-size: var(--text-2xl); | |
| font-weight: var(--weight-bold); | |
| margin: var(--size-4) 0 var(--size-2) 0; | |
| color: var(--body-text-color); | |
| } | |
| .markdown-content :global(h2) { | |
| font-size: var(--text-xl); | |
| font-weight: var(--weight-semibold); | |
| margin: var(--size-3) 0 var(--size-2) 0; | |
| color: var(--body-text-color); | |
| } | |
| .markdown-content :global(h3) { | |
| font-size: var(--text-lg); | |
| font-weight: var(--weight-medium); | |
| margin: var(--size-3) 0 var(--size-1) 0; | |
| color: var(--body-text-color); | |
| } | |
| .markdown-content :global(p) { | |
| margin: var(--size-2) 0; | |
| line-height: 1.6; | |
| color: var(--body-text-color); | |
| } | |
| .markdown-content :global(strong) { | |
| font-weight: var(--weight-bold); | |
| } | |
| .markdown-content :global(em) { | |
| font-style: italic; | |
| } | |
| .markdown-content :global(ul), .markdown-content :global(ol) { | |
| margin: var(--size-2) 0; | |
| padding-left: var(--size-4); | |
| color: var(--body-text-color); | |
| } | |
| .markdown-content :global(li) { | |
| margin: var(--size-1) 0; | |
| } | |
| .markdown-content :global(code) { | |
| background-color: var(--background-fill-secondary); | |
| padding: var(--size-1); | |
| border-radius: var(--radius-sm); | |
| font-family: var(--font-mono); | |
| font-size: var(--text-sm); | |
| } | |
| .markdown-content :global(pre) { | |
| background-color: var(--background-fill-secondary); | |
| padding: var(--size-3); | |
| border-radius: var(--radius-md); | |
| overflow-x: auto; | |
| margin: var(--size-2) 0; | |
| } | |
| .markdown-content :global(pre code) { | |
| background: none; | |
| padding: 0; | |
| } | |
| .markdown-content :global(.highlight-term:hover), | |
| .markdown-content :global(.highlight-position:hover), | |
| .markdown-content :global(.highlight-term:focus), | |
| .markdown-content :global(.highlight-position:focus) { | |
| opacity: 0.8; | |
| transform: scale(1.02); | |
| outline: 2px solid var(--color-accent); | |
| outline-offset: 1px; | |
| } | |
| /* Responsive design */ | |
| @media (max-width: 768px) { | |
| .side-panel { | |
| width: 100% !important; | |
| } | |
| .with-panel .markdown-content { | |
| margin-right: 0; | |
| } | |
| .content-area.split-mode { | |
| flex-direction: column; | |
| } | |
| .editor-section, .preview-section { | |
| border-right: none; | |
| border-bottom: 1px solid var(--border-color-primary); | |
| } | |
| .preview-section { | |
| border-left: none; | |
| border-top: 1px solid var(--border-color-primary); | |
| } | |