From 0f8700f575c571ba079a28746c6bf730ad4659ae Mon Sep 17 00:00:00 2001 From: erdfern <rexsomnia@pm.me> Date: Tue, 18 Mar 2025 20:38:31 +0100 Subject: [PATCH] --wip-- --- .../AnnotationEditor/AnnotationEditor.svelte | 175 --------------- .../AnnotationEditor/AnnotationList.svelte | 137 ----------- src/lib/components/AnnotationEditor/index.ts | 1 - .../AnnotationEditor/transformers.ts | 126 ----------- src/lib/components/AnnotationEditor/types.ts | 66 ------ src/lib/components/Editor/Editor.svelte | 46 ---- src/lib/components/Editor/editor.ts | 60 ----- src/routes/(app)/debug/editor/+page.svelte | 122 +--------- .../editor/Editor/DocumentRender.svelte} | 79 ++----- .../(app)/debug/editor/Editor/Editor.svelte | 7 + src/routes/(app)/debug/editor/Editor/index.ts | 1 + .../(app)/debug/editor/Editor}/live.svelte.ts | 0 .../(app)/debug/editor/Editor}/schema.ts | 0 src/routes/(app)/debug/editor/Editor/todo.md | 180 +++++++++++++++ .../(app)/debug/editor/Editor/transformers.ts | 212 ++++++++++++++++++ src/routes/(app)/debug/editor/Editor/types.ts | 74 ++++++ src/routes/(app)/debug/editor/db.tmp.ts | 41 ++++ 17 files changed, 529 insertions(+), 798 deletions(-) delete mode 100644 src/lib/components/AnnotationEditor/AnnotationEditor.svelte delete mode 100644 src/lib/components/AnnotationEditor/AnnotationList.svelte delete mode 100644 src/lib/components/AnnotationEditor/index.ts delete mode 100644 src/lib/components/AnnotationEditor/transformers.ts delete mode 100644 src/lib/components/AnnotationEditor/types.ts delete mode 100644 src/lib/components/Editor/Editor.svelte delete mode 100644 src/lib/components/Editor/editor.ts rename src/{lib/components/AnnotationEditor/CodeBlock.svelte => routes/(app)/debug/editor/Editor/DocumentRender.svelte} (50%) create mode 100644 src/routes/(app)/debug/editor/Editor/Editor.svelte create mode 100644 src/routes/(app)/debug/editor/Editor/index.ts rename src/{lib/components/AnnotationEditor => routes/(app)/debug/editor/Editor}/live.svelte.ts (100%) rename src/{lib/components/AnnotationEditor => routes/(app)/debug/editor/Editor}/schema.ts (100%) create mode 100644 src/routes/(app)/debug/editor/Editor/todo.md create mode 100644 src/routes/(app)/debug/editor/Editor/transformers.ts create mode 100644 src/routes/(app)/debug/editor/Editor/types.ts create mode 100644 src/routes/(app)/debug/editor/db.tmp.ts diff --git a/src/lib/components/AnnotationEditor/AnnotationEditor.svelte b/src/lib/components/AnnotationEditor/AnnotationEditor.svelte deleted file mode 100644 index 34afb754..00000000 --- a/src/lib/components/AnnotationEditor/AnnotationEditor.svelte +++ /dev/null @@ -1,175 +0,0 @@ -<script lang="ts"> - import type { AnnotationEditorProps, SourceSelection } from './types'; - import CodeBlock from './CodeBlock.svelte'; - import { onDestroy, onMount } from 'svelte'; - import { liveEvaluation } from './live.svelte'; - - let { submission, evaluation, onSave = (_) => {} }: AnnotationEditorProps = $props(); - - let showDebug = $state(true); - let sourceSelection: SourceSelection | null = $state(null); - let comment = $state(''); - let scoreDelta = $state(0); - let annotationColor = $state('#ffeb3b'); // Default yellow - - let liveQueryId: string | null = $state(null); - $inspect(liveQueryId); - - const handleSelectionChange = (srcSelection: SourceSelection) => { - sourceSelection = srcSelection; - }; - - const handleSave = () => { - if (!sourceSelection || !comment.trim()) return; - - const date = new Date().toISOString(); - - const annotationData = { scoreDelta, comment, color: annotationColor, createdAt: date }; - - if (sourceSelection.type === 'Caret') { - onSave({ - kind: 'line', - line: sourceSelection.start.line, - ...annotationData - }); - } else if (sourceSelection.type === 'Range') { - onSave({ - kind: 'range', - selection: sourceSelection, - ...annotationData - }); - } - // else { - // // TODO global annotation case - // onSave({ - // kind: 'global', - // ...annotationData - // }); - // } - - comment = ''; - scoreDelta = 0; - }; - - onMount(async () => { - if (!evaluation) return; - liveQueryId = (await liveEvaluation(evaluation.id.toString())).toString(); - }); - onDestroy(() => {}); - - /* - // helper to determine if the selection is a point, in which case we want to annotate the whole line it's on - function isSelectionPoint(s: SourceSelection): boolean { - return s.start.line === s.end.line && s.start.column === s.end.column; - } - function getSelectionInfo(sel: Selection): SelectionInfo { - const info: Record<string, any> = {}; - - function addPropertiesFrom(obj: object) { - Object.getOwnPropertyNames(obj).forEach((prop) => { - try { - const value = (sel as any)[prop]; - if (typeof value !== 'function') { - info[prop] = value; - } - } catch (e) {} - }); - } - - addPropertiesFrom(sel); - addPropertiesFrom(Object.getPrototypeOf(sel)); - - return info as SelectionInfo; - } - - function describeNode(node: Node | null): string { - if (!node) { - return 'NULL'; - } else if (node.nodeType === Node.TEXT_NODE) { - return `TEXT: "${node.textContent?.slice(0, 20)}${node.textContent && node.textContent.length > 20 ? '...' : ''}"`; - } else if (node.nodeType === Node.ELEMENT_NODE) { - const el = node as Element; - return `ELEMENT: <${el.tagName.toLowerCase()}${el.id ? ' id="' + el.id + '"' : ''}${el.className ? ' class="' + el.className + '"' : ''}>`; - } else { - return `NODE: Type ${node.nodeType}`; - } - } - */ -</script> - -<CodeBlock code={submission.code} lang="markdown" onSelectionChange={handleSelectionChange} /> - -{#if showDebug} - <div class="selection-debug"> - <h3>Selection Debug</h3> - {#if sourceSelection} - <hr /> - <div> - <span class="label" - ><button class="btn preset-filled-primary" onclick={handleSave}>Save</button></span - > - </div> - <div class="info-row"> - <span class="label">Type:</span> - <span class="value">{sourceSelection.type}</span> - </div> - <div class="info-row"> - <span class="label">Source Start:</span> - <span class="value" - >Line {sourceSelection.start.line}, Col {sourceSelection.start.column}</span - > - </div> - <div class="info-row"> - <span class="label">Source End:</span> - <span class="value">Line {sourceSelection.end.line}, Col {sourceSelection.end.column}</span> - </div> - <div class="info-row"> - <span class="label">Selected Text:</span> - <pre class="value border-2">{sourceSelection.text}</pre> - </div> - {/if} - </div> -{/if} - -<!-- - {#if currentSelection !== null && !currentSelection.isCollapsed} - <div class="selection-info"> - <div class="info-row"> - <span class="label">Type:</span> - <span class="value">{currentSelection.type}</span> - </div> - <div class="info-row"> - <span class="label">Direction:</span> - <span class="value">{currentSelection.direction}</span> - </div> - <div class="info-row"> - <span class="label">Range Count:</span> - <span class="value">{currentSelection.rangeCount}</span> - </div> - <div class="info-row"> - <span class="label">Is Collapsed:</span> - <span class="value">{String(currentSelection.isCollapsed)}</span> - </div> - <div class="info-row"> - <span class="label">Anchor Node:</span> - <span class="value">{describeNode(currentSelection.anchorNode)}</span> - </div> - <div class="info-row"> - <span class="label">Anchor Offset:</span> - <span class="value">{currentSelection.anchorOffset}</span> - </div> - <div class="info-row"> - <span class="label">Focus Node:</span> - <span class="value">{describeNode(currentSelection.focusNode)}</span> - </div> - <div class="info-row"> - <span class="label">Focus Offset:</span> - <span class="value">{currentSelection.focusOffset}</span> - </div> - {:else} - <p>No selection active. Click and drag to select text in the code block.</p> - {/if} ---> - -<style> -</style> diff --git a/src/lib/components/AnnotationEditor/AnnotationList.svelte b/src/lib/components/AnnotationEditor/AnnotationList.svelte deleted file mode 100644 index 3faf5cdb..00000000 --- a/src/lib/components/AnnotationEditor/AnnotationList.svelte +++ /dev/null @@ -1,137 +0,0 @@ -<script lang="ts"> - import { createEventDispatcher } from 'svelte'; - import type { Annotation } from './types'; - - // Props - let { - annotations = [] - }: { - annotations: Annotation[]; - } = $props(); - - // Event dispatcher - const dispatch = createEventDispatcher<{ - delete: string; - edit: Annotation; - }>(); - - // Format date - function formatDate(date: Date): string { - return new Intl.DateTimeFormat('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }).format(date); - } - - // Handle deletion - function handleDelete(id: string) { - dispatch('delete', id); - } -</script> - -<div class="annotations-list"> - <h3>Annotations ({annotations.length})</h3> - - {#if annotations.length === 0} - <p>No annotations yet</p> - {:else} - <ul> - {#each annotations as annotation (annotation.id)} - <li style="border-left-color: {annotation.color}"> - <div class="annotation-text">"{annotation.selection.text}"</div> - <div class="annotation-position"> - Position: Line {annotation.selection.position?.start.line}-{annotation.selection - .position?.end.line}, Column {annotation.selection.position?.start.column}-{annotation - .selection.position?.end.column} - </div> - <div class="annotation-comment">{annotation.content}</div> - <div class="annotation-meta"> - Created: {formatDate(annotation.createdAt)} - </div> - <div class="annotation-actions"> - <button - class="delete-btn" - on:click={() => handleDelete(annotation.id)} - title="Delete annotation" - > - Delete - </button> - </div> - </li> - {/each} - </ul> - {/if} -</div> - -<style> - .annotations-list { - margin-top: 1rem; - padding: 1rem; - border: 1px solid #ccc; - border-radius: 4px; - background-color: #f5f5f5; - } - - ul { - list-style: none; - padding: 0; - margin: 0; - } - - li { - margin-bottom: 1rem; - padding: 0.75rem; - border-left: 4px solid #ccc; - background-color: white; - border-radius: 0 4px 4px 0; - } - - .annotation-text { - font-family: monospace; - margin-bottom: 0.5rem; - font-style: italic; - word-break: break-all; - } - - .annotation-position { - font-family: monospace; - font-size: 0.8rem; - background-color: #f0f0f0; - padding: 0.25rem 0.5rem; - border-radius: 2px; - margin-bottom: 0.5rem; - display: inline-block; - } - - .annotation-comment { - margin-bottom: 0.5rem; - } - - .annotation-meta { - font-size: 0.8rem; - color: #666; - margin-bottom: 0.5rem; - } - - .annotation-actions { - display: flex; - justify-content: flex-end; - } - - .delete-btn { - padding: 0.25rem 0.5rem; - background-color: #f44336; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - font-size: 0.8rem; - } - - .delete-btn:hover { - background-color: #d32f2f; - } -</style> diff --git a/src/lib/components/AnnotationEditor/index.ts b/src/lib/components/AnnotationEditor/index.ts deleted file mode 100644 index 2625bc4d..00000000 --- a/src/lib/components/AnnotationEditor/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as AnnotationEditor } from './AnnotationEditor.svelte'; diff --git a/src/lib/components/AnnotationEditor/transformers.ts b/src/lib/components/AnnotationEditor/transformers.ts deleted file mode 100644 index 23085160..00000000 --- a/src/lib/components/AnnotationEditor/transformers.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { ShikiTransformer } from 'shiki'; -import type * as Hast from 'hast'; -import type { SourcePosition, SourceSelection } from './types'; -import { sourceSelection } from './schema'; - -export const sourceMappingTransformer: ShikiTransformer = { - name: 'source-mapping-transformer', - span(hast, line, col, lineElement, token) { - hast.properties['data-line'] = line; - hast.properties['data-col'] = col; // the tokens start column on the given line - // token.offset is the start offset of the token, relative to the input code. 0-indexed. - hast.properties['data-token-offset'] = token.offset; - hast.properties['data-token-len'] = token.content.length; - - hast.properties['data-token-id'] = `${line}:${col}:${token.offset}`; - - // if (!hast.position) return; - // hast.properties['data-position'] = JSON.stringify(hast.position); - }, - line(hast, line) { - hast.properties['data-source-line'] = line; - } -}; - -export function domSelectionToSourceSelection( - selection: Selection, - codeEl: HTMLElement -): SourceSelection | null { - if (!selection || selection.rangeCount === 0 || !codeEl) return null; - const range = selection.getRangeAt(0); - - if (!codeEl.contains(range.commonAncestorContainer)) return null; - - const startPosition = findSourcePosition(range.startContainer, range.startOffset, codeEl); - const endPosition = findSourcePosition(range.endContainer, range.endOffset, codeEl); - - if (!startPosition || !endPosition) return null; - - const selectedText = selection.toString(); // TODO get source text, not DOM text - - const result = sourceSelection.safeParse({ - start: startPosition, - end: endPosition, - text: selectedText, - type: selection.type - }); - - return result.success ? result.data : null; -} - -function findSourcePosition( - node: Node, - offset: number, - codeEl: HTMLElement -): SourcePosition | null { - // Special case for text nodes - find their parent element - let element: Element | null = null; - let textOffset = offset; - - if (node.nodeType === Node.TEXT_NODE) { - element = node.parentElement; - textOffset = offset; - } else if (node.nodeType === Node.ELEMENT_NODE) { - element = node as Element; - - // If the offset is pointing to a child node, adjust accordingly - if (offset > 0 && offset <= element.childNodes.length) { - const childNode = element.childNodes[offset - 1]; - if (childNode.nodeType === Node.TEXT_NODE) { - return findSourcePosition(childNode, (childNode as Text).length, codeEl); - } - } - } - - if (!element) { - return null; - } - - // find the closest ancestor that has position data - let current: Element | null = element; - while (current && current !== codeEl) { - if (current.hasAttribute('data-line') && current.hasAttribute('data-col')) { - break; - } - current = current.parentElement; - } - - if (!current || current === codeEl) { - return null; - } - - const line = parseInt(current.getAttribute('data-line') || '0', 10); - const col = parseInt(current.getAttribute('data-col') || '0', 10); - // TODO use these rather than line/col, since the latter don't guarantee any relation to the source input - const tokenOffset = parseInt(current.getAttribute('data-token-offset') || '0', 10); - const tokenLen = parseInt(current.getAttribute('data-token-len') || '0', 10); - - // Calculate the actual character offset within the token - let charOffset = 0; - - if (node.nodeType === Node.TEXT_NODE && node.parentElement === current) { - // Direct text child of the token span - charOffset = textOffset; - } else { - // need to calculate offset based on preceding text nodes - // TODO: more complex DOM structures might need refinement - let textContentBeforeOffset = ''; - - if (node.nodeType === Node.TEXT_NODE) { - // count characters in previous sibling text nodes - let prevNode = node.previousSibling; - while (prevNode) { - if (prevNode.nodeType === Node.TEXT_NODE) { - textContentBeforeOffset = prevNode.textContent + textContentBeforeOffset; - } - prevNode = prevNode.previousSibling; - } - charOffset = textContentBeforeOffset.length + textOffset; - } - } - - return { - line: line, - column: col + charOffset - }; -} diff --git a/src/lib/components/AnnotationEditor/types.ts b/src/lib/components/AnnotationEditor/types.ts deleted file mode 100644 index 34b133c6..00000000 --- a/src/lib/components/AnnotationEditor/types.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { Evaluation, Submission, SubmissionCreate } from '$lib/surreal/schema'; -import type { Annotation, sourceSelection, SourceSelection } from './schema'; - -export type { SourcePosition, SourceSelection, Annotation } from './schema.ts'; - -export interface CodeBlockProps { - code?: string; - lang?: string; - theme?: string; - // Base Style Props - base?: string; - rounded?: string; - shadow?: string; - classes?: string; - // Pre Style Props - preBase?: string; - prePadding?: string; - preClasses?: string; - // Selection props - onSelectionChange?: (sourceSelection: SourceSelection) => void; - // Debug view - showDebug?: boolean; -} - -export interface AnnotationEditorProps { - submission: Submission; - evaluation?: Evaluation; - onSave?: (annotation: Annotation) => void; -} - -// TODO text position for all ranges (startLine, startColumn, endLine, endColumn) -export type SelectionInfo = { - readonly [K in keyof Selection as Selection[K] extends Function ? never : K]: Selection[K]; -}; - -/** - * Enhanced SelectionInfo that includes source positions - */ -// export interface EnhancedSelectionInfo extends SelectionInfo { -// source?: SourceSelection; -// } - -/** - * Information about a DOM Selection - */ -// export interface SelectionInfo { -// anchorNode: string | null; -// anchorOffset: number; -// focusNode: string | null; -// focusOffset: number; -// isCollapsed: boolean; -// rangeCount: number; -// type: string; -// direction: string; -// text: string; -// position?: { -// start: { -// line: number; -// column: number; -// }; -// end: { -// line: number; -// column: number; -// }; -// }; -// } diff --git a/src/lib/components/Editor/Editor.svelte b/src/lib/components/Editor/Editor.svelte deleted file mode 100644 index 258ec3b3..00000000 --- a/src/lib/components/Editor/Editor.svelte +++ /dev/null @@ -1,46 +0,0 @@ -<script lang="ts"> - import { onDestroy, onMount } from 'svelte'; - import type * as Monaco from 'monaco-editor/esm/vs/editor/editor.api'; - import { createEditor } from './editor'; - - const { code, lang = 'latex' }: { code: string; lang?: string } = $props(); - - let editor: Monaco.editor.IStandaloneCodeEditor; - let monaco: typeof Monaco; - let editorContainer: HTMLElement; - - let currentSelection: Monaco.Selection | null = null; - - function handleSelectionChange(selection: Monaco.Selection) { - currentSelection = selection; - console.log('Selection changed:', selection); - } - - onMount(async () => { - const result = await createEditor( - editorContainer, - { value: code, lang: lang }, - { - onSelectionChange: handleSelectionChange - } - ); - monaco = result?.monaco!; - editor = result?.editor!; - }); - - onDestroy(() => { - monaco?.editor.getModels().forEach((model) => model.dispose()); - editor?.dispose(); - }); -</script> - -<div> - <div class="container" bind:this={editorContainer} /> -</div> - -<style> - .container { - width: 100%; - height: 600px; - } -</style> diff --git a/src/lib/components/Editor/editor.ts b/src/lib/components/Editor/editor.ts deleted file mode 100644 index f5932502..00000000 --- a/src/lib/components/Editor/editor.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { browser } from '$app/environment'; -import loader from '@monaco-editor/loader'; -import type * as Monaco from 'monaco-editor/esm/vs/editor/editor.api'; - -export const createEditor = async ( - editorContainer: HTMLElement, - content: { value: string; lang: string }, - options: { onSelectionChange?: (selection: Monaco.Selection) => void } = {} -) => { - if (!browser) return; - - const monacoEditor = await import('monaco-editor'); - loader.config({ monaco: monacoEditor.default }); - - const monaco = await loader.init(); - - const editor = monaco.editor.create(editorContainer); - - editor.updateOptions({ - domReadOnly: true, - readOnly: true, - minimap: { enabled: false }, - scrollBeyondLastLine: false, - lineNumbers: 'on', - scrollbar: { vertical: 'hidden' }, - overviewRulerLanes: 0, - hideCursorInOverviewRuler: true, - folding: false, - glyphMargin: false - }); - - const keepIds = ['editor.action.clipboardCopyAction']; - const contextmenu = editor.getContribution('editor.contrib.contextmenu')!; - const realMethod = contextmenu._getMenuActions; - contextmenu._getMenuActions = function () { - const items = realMethod.apply(contextmenu, arguments); - return items.filter(function (item) { - console.log(item.id); - return keepIds.includes(item.id) || item.id.includes('ICodeEditor'); - }); - }; - - editor.addAction({ - id: 'annotate', - label: 'Add Comment', - keybindings: undefined, - contextMenuGroupId: 'grady', - run: (editor) => { - const sel = editor.getSelection(); - if (options.onSelectionChange && sel !== null) { - options.onSelectionChange(sel); - } - } - }); - - const model = monaco.editor.createModel(content.value, content.lang); - editor.setModel(model); - - return { editor, monaco }; -}; diff --git a/src/routes/(app)/debug/editor/+page.svelte b/src/routes/(app)/debug/editor/+page.svelte index 0b140938..9e5f34d7 100644 --- a/src/routes/(app)/debug/editor/+page.svelte +++ b/src/routes/(app)/debug/editor/+page.svelte @@ -1,126 +1,6 @@ <script lang="ts"> - import CodeBlock from '$lib/components/AnnotationEditor/CodeBlock.svelte'; - import type { SourceSelection } from '$lib/components/AnnotationEditor/types'; - - const sampleCode = `function calculateTotal(items) { - return items - .map(item => item.price * item.quantity) - .reduce((total, itemTotal) => total + itemTotal, 0); -} - -const cart = [ - { name: 'Keyboard', price: 49.99, quantity: 1 }, - { name: 'Mouse', price: 29.99, quantity: 1 }, - { name: 'Monitor', price: 199.99, quantity: 2 } -]; - -const total = calculateTotal(cart); -console.log(\`Total: \$\${total.toFixed(2)}\`);`; - - let showDebug = $state(true); - let selectionInfo: SourceSelection | null = $state(null); - - function handleSelectionChange(info: SourceSelection) { - selectionInfo = info; - } - - function formatPosition(pos: { line: number; column: number } | undefined) { - if (!pos) return 'None'; - return `Line ${pos.line}, Column ${pos.column + 1}`; - } + import type { Editor } from './Editor'; </script> -<main> - <h1>Code Annotation Editor</h1> - - <div class="code-container"> - <CodeBlock code={sampleCode} {showDebug} onSelectionChange={handleSelectionChange} /> - </div> - - <div class="controls"> - <label class="debug-toggle"> - <input type="checkbox" bind:checked={showDebug} /> - Show Selection Debug - </label> - </div> - - {#if showDebug} - <div class="debug-panel"> - <h3>Enhanced Selection Info</h3> - - {#if selectionInfo} - <div class="source-info"> - <div class="info-row"> - <span class="label">Selection Start:</span> - <span class="value">{formatPosition(selectionInfo.start)}</span> - </div> - <div class="info-row"> - <span class="label">Selection End:</span> - <span class="value">{formatPosition(selectionInfo.end)}</span> - </div> - <div class="info-row"> - <span class="label">Selected Text:</span> - <pre class="selected-text">{selectionInfo.text}</pre> - </div> - </div> - {:else} - <p>No selection or source mapping available. Select some code in the editor above.</p> - {/if} - - <details> - <summary>Raw Selection Data</summary> - <pre class="selection-json">{JSON.stringify(selectionInfo, null, 2)}</pre> - </details> - </div> - {/if} -</main> - <style> - .code-container { - margin: 1rem 0; - border: 1px solid #ddd; - border-radius: 4px; - } - - .debug-panel { - margin-top: 1rem; - padding: 1rem; - background-color: #f8f9fa; - border-radius: 4px; - } - - .info-row { - margin: 0.5rem 0; - display: flex; - } - - .label { - font-weight: bold; - width: 120px; - } - - .source-info { - margin-bottom: 1rem; - } - - .selected-text { - background-color: #f0f0f0; - padding: 0.5rem; - border-radius: 3px; - margin-top: 0.5rem; - font-family: monospace; - white-space: pre-wrap; - } - - details { - margin-top: 1rem; - } - - .selection-json { - max-height: 300px; - overflow: auto; - background-color: #f0f0f0; - padding: 0.5rem; - font-size: 0.8rem; - } </style> diff --git a/src/lib/components/AnnotationEditor/CodeBlock.svelte b/src/routes/(app)/debug/editor/Editor/DocumentRender.svelte similarity index 50% rename from src/lib/components/AnnotationEditor/CodeBlock.svelte rename to src/routes/(app)/debug/editor/Editor/DocumentRender.svelte index 379da20d..2a75c399 100644 --- a/src/lib/components/AnnotationEditor/CodeBlock.svelte +++ b/src/routes/(app)/debug/editor/Editor/DocumentRender.svelte @@ -18,38 +18,35 @@ </script> <script lang="ts"> - import type { CodeBlockProps, SelectionInfo } from './types'; - import { sourceMappingTransformer, domSelectionToSourceSelection } from './transformers'; import { onMount, onDestroy } from 'svelte'; import { browser } from '$app/environment'; let { code = '', - lang = 'js', + lang = 'javascript', theme = 'dark-plus', // Base Style Props - base = 'overflow-hidden', + base = ' overflow-hidden', rounded = 'rounded-container', shadow = '', classes = '', // Pre Style Props preBase = '', prePadding = '[&>pre]:p-4', - preClasses = '', - - onSelectionChange = (_) => {}, - showDebug = false - }: CodeBlockProps = $props(); + preClasses = '' + } = $props(); const generatedHtml = shiki.codeToHtml(code, { lang, theme, - transformers: [sourceMappingTransformer], + //meta: + //transformers: [sourceMappingTransformer], structure: 'classic' }); let codeBlockEl: HTMLElement; + /* function handleSelectionChange() { const sel = window.getSelection(); if (!sel || !isSelectionInCodeBlock(sel)) return; @@ -57,79 +54,29 @@ const sourceSelection = domSelectionToSourceSelection(sel, codeBlockEl); if (sourceSelection) onSelectionChange(sourceSelection); } + */ function isSelectionInCodeBlock(selection: Selection): boolean { - if (!selection.anchorNode) return false; - return codeBlockEl.contains(selection.anchorNode); - // if (!selection.rangeCount) return false; - - // const range = selection.getRangeAt(0); - // return codeBlockEl.contains(range.commonAncestorContainer); + if (!selection.focusNode) return false; + return codeBlockEl.contains(selection.focusNode); } onMount(() => { - document.addEventListener('selectionchange', handleSelectionChange); + //document.addEventListener('selectionchange', handleSelectionChange); }); onDestroy(() => { if (!browser) return; - document.removeEventListener('selectionchange', handleSelectionChange); + //document.removeEventListener('selectionchange', handleSelectionChange); }); </script> <div bind:this={codeBlockEl} - class="{base} {rounded} {shadow} {classes} {preBase} {prePadding} {preClasses} code-block-selectable" + class="{base} {rounded} {shadow} {classes} {preBase} {prePadding} {preClasses}" > {@html generatedHtml} </div> <style> - .code-block-selectable :global(pre) { - user-select: text; - -webkit-user-select: text; - -moz-user-select: text; - -ms-user-select: text; - cursor: crosshair; - } - - .selection-debug { - margin-top: 1rem; - padding: 1rem; - border: 1px solid #ccc; - border-radius: 4px; - background-color: #f5f5f5; - } - - .selection-info { - display: grid; - grid-template-columns: 1fr; - gap: 0.5rem; - } - - .info-row { - display: grid; - grid-template-columns: 150px 1fr; - gap: 0.5rem; - } - - .label { - font-weight: bold; - } - - .text-value { - font-family: monospace; - word-break: break-all; - background-color: #e0e0e0; - padding: 0.25rem; - border-radius: 2px; - } - - :global(.line-number) { - display: inline-block; - width: 1.5rem; - margin-right: 0.5rem; - color: #888; - text-align: right; - } </style> diff --git a/src/routes/(app)/debug/editor/Editor/Editor.svelte b/src/routes/(app)/debug/editor/Editor/Editor.svelte new file mode 100644 index 00000000..179e1160 --- /dev/null +++ b/src/routes/(app)/debug/editor/Editor/Editor.svelte @@ -0,0 +1,7 @@ +<script lang="ts"> +</script> + +<Docume + +<style> +</style> diff --git a/src/routes/(app)/debug/editor/Editor/index.ts b/src/routes/(app)/debug/editor/Editor/index.ts new file mode 100644 index 00000000..ae06672f --- /dev/null +++ b/src/routes/(app)/debug/editor/Editor/index.ts @@ -0,0 +1 @@ +export { default as Editor } from './Editor.svelte'; diff --git a/src/lib/components/AnnotationEditor/live.svelte.ts b/src/routes/(app)/debug/editor/Editor/live.svelte.ts similarity index 100% rename from src/lib/components/AnnotationEditor/live.svelte.ts rename to src/routes/(app)/debug/editor/Editor/live.svelte.ts diff --git a/src/lib/components/AnnotationEditor/schema.ts b/src/routes/(app)/debug/editor/Editor/schema.ts similarity index 100% rename from src/lib/components/AnnotationEditor/schema.ts rename to src/routes/(app)/debug/editor/Editor/schema.ts diff --git a/src/routes/(app)/debug/editor/Editor/todo.md b/src/routes/(app)/debug/editor/Editor/todo.md new file mode 100644 index 00000000..38317ce3 --- /dev/null +++ b/src/routes/(app)/debug/editor/Editor/todo.md @@ -0,0 +1,180 @@ +# 1. Component Overview + +## Core Purpose and Value Proposition +The custom text editor component serves as the primary interface within the "Grady" web application for University of Göttingen tutors to perform qualitative and quantitative assessment of student submissions. The component renders source material (code or Markdown) while providing an interactive annotation layer that maintains a bidirectional relationship with the underlying document structure. This allows precise feedback attribution to specific document segments without modifying the original submission content. + +## Target Audience and Integration Scenarios +The primary users are academic tutors with varying levels of technical proficiency who need to efficiently evaluate student work. The component will integrate within the broader Grady application architecture, interfacing with: +- Backend storage systems (SurrealDB graph/document database) +- User authentication and authorization mechanisms + +# 2. Requirements + +## Functional Requirements + +### Document Rendering +- Render source text with preservation of whitespace, indentation, and line breaks +- Support multiple content types with appropriate rendering: + - Programming languages (Python, Haskell, C, etc.) with syntax highlighting + - Markdown with structural rendering +- Maintain line numbering consistent with original document + +### Annotation Capabilities +- Support three distinct annotation scopes: + 1. **Document-level annotations**: Comments applying to the entire submission + 2. **Line-level annotations**: Comments targeting specific individual lines + 3. **Range-level annotations**: Comments applying to precise text ranges defined by a start and an end position: + - Start line number + - Start column position + - End line number + - End column position +- Provide visual differentiation between annotation types +- Enable annotation creation through intuitive selection mechanisms +- Support rich text formatting within annotation comments +- Annotations persist as serializable JSON objects with document position references +- Position references use character offsets in addition to line/column coordinates to maintain stability during document updates +- Timestamp and authorship metadata attached to all annotation operations + +### Visual Feedback Systems +- Syntax highlighting for supported programming languages +- Visual highlighting of annotated lines/ranges with distinct styling per annotation type +- Hover interactions that reveal annotation content with attribution metadata +- Visual indicators for annotations with score impact + +### Grading Functionality +- Support numerical score assignment to the overall submission +- Enable score delta associations with individual annotations (positive or negative) +- Provide real-time calculation of cumulative score based on annotation deltas +- Include validation to ensure final score remains within defined bounds + +## Non-Functional Requirements + +### Performance Considerations +- Efficient rendering for documents +- Minimal input latency (<50ms) during annotation creation +- Optimization for concurrent display of multiple annotations +- Memory-efficient representation of annotations and document state + +### Technical Constraints +- Browser compatibility with modern evergreen browsers (Chrome, Firefox, Safari, Edge) +- Offline annotation capability with synchronization on reconnection + +# 3. Architecture Design + +## Component Composition Strategy +The editor implements a hierarchical composition pattern with: +- Core text rendering engine ($state-driven document model) +- Selection management subsystem (leveraging Svelte actions) +- Annotation overlay system (DOM-synchronized positional model) +- Command interface layer (for keybindings and programmatic control) + +## Architectural Principles and Design Philosophy +The component architecture adheres to the following principles: +- **Immutable source material**: Student submissions remain unmodified while annotations exist as overlay metadata +- **Separation of concerns**: Rendering, interaction handling, and annotation management are decoupled +- **Fine-grained reactivity**: Leveraging Svelte 5's runes for optimal rendering performance +- **Progressive enhancement**: Core functionality works with minimal dependencies, with optional advanced features + +# 4. Data Model + +## Document Representation +The document model follows a hybrid approach with two synchronized representations: + +### Source Document Model +- Immutable source text stored as a normalized string +- Document is parsed into a line-based data structure: + ```typescript + interface DocumentLine { + id: string; // Stable line identifier + content: string; // Line content + lineNumber: number; // 1-based line number + offset: number; // Character offset from start of document + length: number; // Line length including EOL characters + metadata?: { // Optional language-specific metadata + language: string; // Language identifier + tokenization?: Token[]; // Syntax tokens if syntax highlighting is enabled + } + } + ``` +- Character-based offset mapping allows for stable position references even if document structure changes + +### View Model +- Computed projections of the source model with rendering metadata +- Support for collapsible code regions while maintaining position integrity +- Virtual rendering for performance optimization with large documents +- Integration with syntax highlighter shiki + +## Selection State Management +- Selection represented as a positional range with the following structure: + ```typescript + // Mapped from a DOM `Selection`, where anchor and focus are `Node`s, to a source selection + interface SelectionState { + type: 'caret' | 'range'; // depending on whether the caret is placed at a single point in the text, or a range has been selected + anchor: Position; // MAPPED from Selection.anchorNode/anchorOffset - Starting position (may be after focus) + focus: Position; // MAPPED from Selection.focusNode/anchorOffset - Ending position (may be before anchor) + isCollapsed: boolean; // True when anchor === focus (caret only) + direction: 'forward' | 'backward' | 'none'; + timestamp: number; // For selection history tracking + } + + interface Position { + line: number; // 1-based line number + column: number; // 0-based column number + offset: number; // Absolute character offset in source document + } + ``` +- Separate overlay selection state for annotation range visualization +- DOM selection synchronized via bidirectional mapping functions + +## Annotation Model +- Annotations stored as standalone entities with document position references: + ```typescript + type Annotation = DocumentAnnotation | LineAnnotation | RangeAnnotation; + + interface BaseAnnotation { + type: 'document' | 'line' | 'range'; // Annotation scope + id: string; + content: RichTextContent; // Structured annotation content + metadata: { + author: string; // Author identifier + createdAt: string; // Creation timestamp, ISO 8601 + scoreDelta?: number; // Optional score impact + tags?: string[]; // Optional categorization + }; + } + + interface DocumentAnnotation extends BasicAnnotation { + type: 'document'; + visualSettings?: { + color?: string; + }; + } + + interface LineAnnotation extends BasicAnnotation { + type: 'line'; + line: Position; // column of Position is 0, i.e. first on line + visualSettings?: { + color?: string; // line highlight color + priority?: number; + }; + } + + interface RangeAnnotation extends BasicAnnotation { + type: 'range'; + range: { + start: Position; + end: Position; + }; + visualSettings?: { + color?: string; + style?: 'solid' | 'dashed' | 'dotted'; // underline style + priority?: number; + }; + } + ``` +- Position references use both line/column and absolute character offsets for stability +- Annotations stored in an indexed collection for efficient lookup by position + +# Implementation Plan + +TODO diff --git a/src/routes/(app)/debug/editor/Editor/transformers.ts b/src/routes/(app)/debug/editor/Editor/transformers.ts new file mode 100644 index 00000000..7b22ae8d --- /dev/null +++ b/src/routes/(app)/debug/editor/Editor/transformers.ts @@ -0,0 +1,212 @@ +import type { CodeToHastOptions, ShikiTransformer, ThemedToken } from 'shiki'; +import type * as Hast from 'hast'; + +/* +Decorations + +We provide a decorations API allowing you to wrap custom classes and attributes around ranges of your code. + +``` +import { codeToHtml } from 'shiki' + +const code = ` +const x = 10 +console.log(x) +`.trim() + +const html = await codeToHtml(code, { + theme: 'vitesse-light', + lang: 'ts', + decorations: [ + { + // line and character are 0-indexed + start: { line: 1, character: 0 }, + end: { line: 1, character: 11 }, + properties: { class: 'highlighted-word' } + } + ] +}) +``` +The positions can also be 0-indexed offsets relative to the code: + +``` +const html = await codeToHtml(code, { + theme: 'vitesse-light', + lang: 'ts', + decorations: [ + { + start: 21, + end: 24, + properties: { class: 'highlighted-word' } + } + ] +}) +``` + +# Use Decorations in Transformers + +For advanced use cases, you can use the Transformers API to have full access to the tokens and the HAST tree. + +Meanwhile, if you want to append decorations within a transformer, you can do that with: + +``` +import { codeToHtml, ShikiTransformer } from 'shiki' + +const myTransformer: ShikiTransformer = { + name: 'my-transformer', + preprocess(code, options) { + // Generate the decorations somehow + const decorations = doSomethingWithCode(code) + + // Make sure the decorations array exists + options.decorations ||= [] + // Append the decorations + options.decorations.push(...decorations) + } +} + +const html = await codeToHtml(code, { + theme: 'vitesse-light', + lang: 'ts', + transformers: [ + myTransformer + ] +}) +``` + +Note that you can only provide decorations in or before the preprocess hook. In later hooks, changes to the decorations arrary will be ignored. +*/ +export const sourceMappingTransformer: ShikiTransformer = { + name: 'source-mapping-transformer', + // Called before the code is tokenized + preprocess(code: string, options: CodeToHastOptions<string, string>) {}, + // Called after the code has been tokenized + tokens(tokens: ThemedToken[][]) {}, + // Called for each <span> tag for each token + span( + spanElement: Hast.Element, + line: number, + col: number, + lineElement: Hast.Element, + token: ThemedToken + ) {}, + // Called for each line <span> tag + line(lineElement: Hast.Element, line: number) { + lineElement.properties['data-source-line'] = line; + }, + // Called for each <code> tag, wraps all the lines + // Returning a new Node will replace the original one. + code(codeElement: Hast.Element) {}, + // Called for each <pre> tag, wraps the <code> tag + // Returning a new Node will replace the original one. + pre(preElement: Hast.Element) {}, + // Called on the root of the HAST tree, which usually only has one <pre> tag. + // Returning a new Node will replace the original one. + root(hast: Hast.Root) {}, + // Called on the generated HTML string + // Hook will only be called in codeToHtml + postprocess(html: string, options) {} +}; + +// export function domSelectionToSourceSelection( +// selection: Selection, +// codeEl: HTMLElement +// ): SourceSelection | null { +// if (!selection || selection.rangeCount === 0 || !codeEl) return null; +// const range = selection.getRangeAt(0); + +// if (!codeEl.contains(range.commonAncestorContainer)) return null; + +// const startPosition = findSourcePosition(range.startContainer, range.startOffset, codeEl); +// const endPosition = findSourcePosition(range.endContainer, range.endOffset, codeEl); + +// if (!startPosition || !endPosition) return null; + +// const selectedText = selection.toString(); // TODO get source text, not DOM text + +// const result = sourceSelection.safeParse({ +// start: startPosition, +// end: endPosition, +// text: selectedText, +// type: selection.type +// }); + +// return result.success ? result.data : null; +// } + +// function findSourcePosition( +// node: Node, +// offset: number, +// codeEl: HTMLElement +// ): SourcePosition | null { +// // Special case for text nodes - find their parent element +// let element: Element | null = null; +// let textOffset = offset; + +// if (node.nodeType === Node.TEXT_NODE) { +// element = node.parentElement; +// textOffset = offset; +// } else if (node.nodeType === Node.ELEMENT_NODE) { +// element = node as Element; + +// // If the offset is pointing to a child node, adjust accordingly +// if (offset > 0 && offset <= element.childNodes.length) { +// const childNode = element.childNodes[offset - 1]; +// if (childNode.nodeType === Node.TEXT_NODE) { +// return findSourcePosition(childNode, (childNode as Text).length, codeEl); +// } +// } +// } + +// if (!element) { +// return null; +// } + +// // find the closest ancestor that has position data +// let current: Element | null = element; +// while (current && current !== codeEl) { +// if (current.hasAttribute('data-line') && current.hasAttribute('data-col')) { +// break; +// } +// current = current.parentElement; +// } + +// if (!current || current === codeEl) { +// return null; +// } + +// const line = parseInt(current.getAttribute('data-line') || '0', 10); +// const col = parseInt(current.getAttribute('data-col') || '0', 10); +// // TODO use these rather than line/col, since the latter don't guarantee any relation to the source input +// const tokenOffset = parseInt(current.getAttribute('data-token-offset') || '0', 10); +// const tokenLen = parseInt(current.getAttribute('data-token-len') || '0', 10); + +// // Calculate the actual character offset within the token +// let charOffset = 0; + +// if (node.nodeType === Node.TEXT_NODE && node.parentElement === current) { +// // Direct text child of the token span +// charOffset = textOffset; +// } else { +// // need to calculate offset based on preceding text nodes +// // TODO: more complex DOM structures might need refinement +// let textContentBeforeOffset = ''; + +// if (node.nodeType === Node.TEXT_NODE) { +// // count characters in previous sibling text nodes +// let prevNode = node.previousSibling; +// while (prevNode) { +// if (prevNode.nodeType === Node.TEXT_NODE) { +// textContentBeforeOffset = prevNode.textContent + textContentBeforeOffset; +// } +// prevNode = prevNode.previousSibling; +// } +// charOffset = textContentBeforeOffset.length + textOffset; +// } +// } + +// return { +// line: line, +// column: col + charOffset +// }; +// } diff --git a/src/routes/(app)/debug/editor/Editor/types.ts b/src/routes/(app)/debug/editor/Editor/types.ts new file mode 100644 index 00000000..68ca1218 --- /dev/null +++ b/src/routes/(app)/debug/editor/Editor/types.ts @@ -0,0 +1,74 @@ +import type { ThemedToken } from 'shiki'; + +// Mapped from a DOM `Selection`, where anchor and focus are `Node`s, to a source selection +interface SelectionState { + type: 'caret' | 'range'; // depending on whether the caret is placed at a single point in the text, or a range has been selected + anchor: Position; // MAPPED from Selection.anchorNode/anchorOffset - Starting position (may be after focus) + focus: Position; // MAPPED from Selection.focusNode/anchorOffset - Ending position (may be before anchor) + isCollapsed: boolean; // True when anchor === focus (caret only) + direction: 'forward' | 'backward' | 'none'; + timestamp: number; // For selection history tracking +} + +// Position in the source document +interface Position { + line: number; // 1-based line number + column: number; // 0-based column number + offset: number; // Absolute character offset in source document +} + +interface DocumentLine { + id: string; // Stable line identifier + content: string; // Line content + lineNumber: number; // 1-based line number + offset: number; // Character offset from start of document + length: number; // Line length including EOL characters + metadata?: { + // Optional language-specific metadata + language: string; // Language identifier + // tokenization?: ThemedToken[]; // Syntax tokens if syntax highlighting is enabled + }; +} + +type Annotation = DocumentAnnotation | LineAnnotation | RangeAnnotation; + +interface BaseAnnotation { + type: 'document' | 'line' | 'range'; // Annotation scope + id: string; + content: string; // Structured annotation content + metadata: { + author: string; // Author identifier + createdAt: string; // Creation timestamp, ISO 8601 + scoreDelta?: number; // Optional score impact + tags?: string[]; // Optional categorization + }; +} + +interface DocumentAnnotation extends BaseAnnotation { + type: 'document'; + visualSettings?: { + color?: string; + }; +} + +interface LineAnnotation extends BaseAnnotation { + type: 'line'; + line: Position; // column of Position is 0, i.e. first on line + visualSettings?: { + color?: string; // line highlight color + priority?: number; + }; +} + +interface RangeAnnotation extends BaseAnnotation { + type: 'range'; + range: { + start: Position; + end: Position; + }; + visualSettings?: { + color?: string; + style?: 'solid' | 'dashed' | 'dotted'; // underline style + priority?: number; + }; +} diff --git a/src/routes/(app)/debug/editor/db.tmp.ts b/src/routes/(app)/debug/editor/db.tmp.ts new file mode 100644 index 00000000..03a1541c --- /dev/null +++ b/src/routes/(app)/debug/editor/db.tmp.ts @@ -0,0 +1,41 @@ +///! temporary dummy data store +import type { Evaluation, Submission } from '$lib/surreal/schema'; +import { RecordId } from 'surrealdb'; + +// this is modeled as a relation between a 'student' and an 'assigment'n in the database! +let submission: Submission = { + id: new RecordId('submission', '1'), + in: 'student:1', + out: 'assignment:1', + code: `const example = () => { + const a = 1; + const b = 2; + let c = a / b; + c += 1; + console.log(c); +}; +`, + tests: [] // NOTE: irrelevant for editor +}; + +// this is modeled as a relation between a 'tutor' and 'submission' in the database! +let evaluation: Evaluation = { + id: new RecordId('evaluation', '1'), + in: 'tutor:1', + out: 'submission:1', + final: false, + score: 0, + annotations: [] +}; + +let annotations: Annotation & { id: string; in: string; out: string } = []; + +export async function getSubmission(_id?: string) { + return submission; +} + +export async function getEvaluation(_id?: string) { + return evaluation; +} + +export async function annotate(annotation: Annotation) {} -- GitLab