Skip to content
Snippets Groups Projects
Commit 0f8700f5 authored by Linus Keiser's avatar Linus Keiser :speech_balloon:
Browse files

--wip--

parent 2b82ff06
No related branches found
No related tags found
No related merge requests found
Pipeline #590970 passed
Showing
with 529 additions and 798 deletions
<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>
<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>
export { default as AnnotationEditor } from './AnnotationEditor.svelte';
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
};
}
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;
// };
// };
// }
<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>
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 };
};
<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>
......@@ -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>
<script lang="ts">
</script>
<Docume
<style>
</style>
export { default as Editor } from './Editor.svelte';
# 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
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
// };
// }
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;
};
}
///! 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) {}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment