From 06f8255df99c1f5f7daf52987a7c8fe258622153 Mon Sep 17 00:00:00 2001 From: erdfern <rexsomnia@pm.me> Date: Thu, 13 Mar 2025 16:19:21 +0100 Subject: [PATCH] --wip-- --- bun.lock | 11 + db/schema/schema.surql | 16 + devenv.lock | 49 ++- .../CodeBlock/AnnotationEditor.svelte | 147 +++++++ .../CodeBlock/AnnotationList.svelte | 137 +++++++ src/lib/components/CodeBlock/CodeBlock.svelte | 369 ++++++++++++++++-- src/lib/components/CodeBlock/transformers.ts | 65 ++- src/lib/components/CodeBlock/types.ts | 67 +++- src/routes/(app)/debug/editor/+page.svelte | 140 ++++++- 9 files changed, 920 insertions(+), 81 deletions(-) create mode 100644 src/lib/components/CodeBlock/AnnotationEditor.svelte create mode 100644 src/lib/components/CodeBlock/AnnotationList.svelte diff --git a/bun.lock b/bun.lock index 72be1ea8..95ec505b 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "highlight.js": "^11.11.1", "js-cookie": "^3.0.5", "lucide-svelte": "^0.479.0", + "monaco-editor": "^0.52.2", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "surrealdb": "^1.2.1", @@ -19,8 +20,10 @@ }, "devDependencies": { "@iconify/svelte": "^4.2.0", + "@monaco-editor/loader": "^1.5.0", "@playwright/test": "^1.51.0", "@sebastianwessel/surql-gen": "^2.7.1", + "@shikijs/monaco": "^3.2.1", "@skeletonlabs/skeleton": "3.0.0", "@skeletonlabs/skeleton-svelte": "1.0.0", "@sveltejs/adapter-auto": "^4.0.0", @@ -178,6 +181,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], + "@monaco-editor/loader": ["@monaco-editor/loader@1.5.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -240,6 +245,8 @@ "@shikijs/langs": ["@shikijs/langs@3.2.1", "", { "dependencies": { "@shikijs/types": "3.2.1" } }, "sha512-If0iDHYRSGbihiA8+7uRsgb1er1Yj11pwpX1c6HLYnizDsKAw5iaT3JXj5ZpaimXSWky/IhxTm7C6nkiYVym+A=="], + "@shikijs/monaco": ["@shikijs/monaco@3.2.1", "", { "dependencies": { "@shikijs/core": "3.2.1", "@shikijs/types": "3.2.1", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-9XaRuwETRRhi+4g1EdMsK1dx1mHuL1XnXWmDRFL2PkMrDIGqrzY9DGR+YnWlWuoEY0kU+vbCMxH7rog1yuWJvA=="], + "@shikijs/themes": ["@shikijs/themes@3.2.1", "", { "dependencies": { "@shikijs/types": "3.2.1" } }, "sha512-k5DKJUT8IldBvAm8WcrDT5+7GA7se6lLksR+2E3SvyqGTyFMzU2F9Gb7rmD+t+Pga1MKrYFxDIeyWjMZWM6uBQ=="], "@shikijs/types": ["@shikijs/types@3.2.1", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-/NTWAk4KE2M8uac0RhOsIhYQf4pdU0OywQuYDGIGAJ6Mjunxl2cGiuLkvu4HLCMn+OTTLRWkjZITp+aYJv60yA=="], @@ -876,6 +883,8 @@ "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + "monaco-editor": ["monaco-editor@0.52.2", "", {}, "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], "mrmime": ["mrmime@2.0.0", "", {}, "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw=="], @@ -1064,6 +1073,8 @@ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="], + "std-env": ["std-env@3.8.0", "", {}, "sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w=="], "streamx": ["streamx@2.22.0", "", { "dependencies": { "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" }, "optionalDependencies": { "bare-events": "^2.2.0" } }, "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw=="], diff --git a/db/schema/schema.surql b/db/schema/schema.surql index 687002ae..ab29d0a3 100644 --- a/db/schema/schema.surql +++ b/db/schema/schema.surql @@ -54,12 +54,28 @@ DEFINE TABLE evaluation TYPE RELATION IN tutor OUT submission SCHEMALESS PERMISS DEFINE FIELD score ON evaluation TYPE option<float> PERMISSIONS FULL; DEFINE FIELD final ON evaluation TYPE bool DEFAULT false PERMISSIONS FULL; +-- NOTE: making this an edge between evaluation->submission seems overkill +-- NOTE: but maybe make this a record reference? (https://surrealdb.com/docs/surrealql/datamodel/references) +DEFINE FIELD annotations ON evaluation TYPE array<record<annotation>> DEFAULT [] PERMISSIONS FULL; DEFINE INDEX one_per_submission ON evaluation FIELDS out UNIQUE; -- DEFINE EVENT ... +-- ------------------------------ +-- TABLE: annotation +-- ------------------------------ + +DEFINE TABLE annotation TYPE NORMAL SCHEMALESS PERMISSIONS NONE; +DEFINE FIELD color ON annotation TYPE option<string> PERMISSIONS FULL; +DEFINE FIELD content ON annotation TYPE string PERMISSIONS FULL; +-- TODO data schema for position +-- * can apply to a range (or ranges?) in the text source of the submission, shown at the range it applies to, OR +-- * can apply to the submission as a whole, shown in a dedicated window/list/view on the submission +-- DEFINE FIELD + + -- ------------------------------ -- TABLE: exercise_group -- ------------------------------ diff --git a/devenv.lock b/devenv.lock index 7a45f6f5..d9e34d9a 100644 --- a/devenv.lock +++ b/devenv.lock @@ -114,31 +114,10 @@ "type": "github" } }, - "git-hooks": { - "inputs": { - "flake-compat": "flake-compat", - "gitignore": "gitignore", - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1741379162, - "owner": "cachix", - "repo": "git-hooks.nix", - "rev": "b5a62751225b2f62ff3147d0a334055ebadcd5cc", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "git-hooks.nix", - "type": "github" - } - }, "gitignore": { "inputs": { "nixpkgs": [ - "git-hooks", + "pre-commit-hooks", "nixpkgs" ] }, @@ -234,17 +213,35 @@ "type": "github" } }, + "pre-commit-hooks": { + "inputs": { + "flake-compat": "flake-compat", + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1741379162, + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "rev": "b5a62751225b2f62ff3147d0a334055ebadcd5cc", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "type": "github" + } + }, "root": { "inputs": { "devenv": "devenv", - "git-hooks": "git-hooks", "mk-shell-bin": "mk-shell-bin", "nix2container": "nix2container", "nixpkgs": "nixpkgs", "nixpkgs-unstable": "nixpkgs-unstable", - "pre-commit-hooks": [ - "git-hooks" - ], + "pre-commit-hooks": "pre-commit-hooks", "surrealdb-git": "surrealdb-git" } }, diff --git a/src/lib/components/CodeBlock/AnnotationEditor.svelte b/src/lib/components/CodeBlock/AnnotationEditor.svelte new file mode 100644 index 00000000..73be5afc --- /dev/null +++ b/src/lib/components/CodeBlock/AnnotationEditor.svelte @@ -0,0 +1,147 @@ +<script lang="ts"> + import { createEventDispatcher } from 'svelte'; + import type { SelectionInfo, Annotation } from './types'; + + // Props + let { + selection = null + }: { + selection: SelectionInfo | null; + } = $props(); + + // Local state + let commentText = $state(''); + let annotationColor = $state('#ffeb3b'); // Default yellow + + // Event dispatcher + const dispatch = createEventDispatcher<{ + save: Annotation; + }>(); + + // Handle save button click + function handleSave() { + if (!selection || selection.isCollapsed || !commentText.trim()) return; + + const newAnnotation: Annotation = { + id: crypto.randomUUID(), + selection, + content: commentText.trim(), + color: annotationColor, + createdAt: new Date() + }; + + dispatch('save', newAnnotation); + + // Reset the form + commentText = ''; + } +</script> + +<div class="annotation-editor"> + <h3>Create Annotation</h3> + + {#if selection && !selection.isCollapsed} + <div class="selected-text"> + <strong>Selected Text:</strong> + <span class="text-snippet">{selection.text}</span> + </div> + + <div class="position-info"> + <strong>Position:</strong> + <span class="position-details"> + Lines {selection.position?.start.line}-{selection.position?.end.line}, Columns {selection + .position?.start.column}-{selection.position?.end.column} + </span> + </div> + + <div class="form-group"> + <label for="comment">Comment:</label> + <textarea + id="comment" + bind:value={commentText} + placeholder="Add your annotation comment here..." + rows="3" + ></textarea> + </div> + + <div class="form-group"> + <label for="color">Highlight Color:</label> + <input type="color" id="color" bind:value={annotationColor} /> + </div> + + <button class="save-btn" on:click={handleSave} disabled={!commentText.trim()}> + Save Annotation + </button> + {:else} + <p>Select text in the code to add an annotation</p> + {/if} +</div> + +<style> + .annotation-editor { + margin-top: 1rem; + padding: 1rem; + border: 1px solid #ccc; + border-radius: 4px; + background-color: #f5f5f5; + } + + .selected-text { + margin-bottom: 1rem; + padding: 0.5rem; + background-color: #e0e0e0; + border-radius: 4px; + } + + .text-snippet { + font-family: monospace; + word-break: break-all; + } + + .position-info { + margin-bottom: 1rem; + padding: 0.5rem; + background-color: #e0e0e0; + border-radius: 4px; + font-size: 0.9rem; + } + + .position-details { + font-family: monospace; + } + + .form-group { + margin-bottom: 1rem; + } + + label { + display: block; + margin-bottom: 0.5rem; + font-weight: bold; + } + + textarea { + width: 100%; + padding: 0.5rem; + border: 1px solid #ccc; + border-radius: 4px; + } + + .save-btn { + padding: 0.5rem 1rem; + background-color: #4caf50; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + } + + .save-btn:disabled { + background-color: #cccccc; + cursor: not-allowed; + } + + .save-btn:not(:disabled):hover { + background-color: #45a049; + } +</style> diff --git a/src/lib/components/CodeBlock/AnnotationList.svelte b/src/lib/components/CodeBlock/AnnotationList.svelte new file mode 100644 index 00000000..3faf5cdb --- /dev/null +++ b/src/lib/components/CodeBlock/AnnotationList.svelte @@ -0,0 +1,137 @@ +<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/CodeBlock/CodeBlock.svelte b/src/lib/components/CodeBlock/CodeBlock.svelte index c763432f..b09869be 100644 --- a/src/lib/components/CodeBlock/CodeBlock.svelte +++ b/src/lib/components/CodeBlock/CodeBlock.svelte @@ -1,5 +1,3 @@ -<!-- @component Code Block based on: https://shiki.style/ --> - <script module> import { createHighlighterCoreSync } from 'shiki/core'; import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'; @@ -12,71 +10,378 @@ import html from 'shiki/langs/html.mjs'; import css from 'shiki/langs/css.mjs'; import js from 'shiki/langs/javascript.mjs'; - // https://shiki.style/guide/sync-usage const shiki = createHighlighterCoreSync({ engine: createJavaScriptRegexEngine(), - // Implement your import theme. themes: [themeDarkPlus], - // Implement your imported and supported languages. langs: [console, html, css, js] }); - - const lineNumberTransformer = {}; </script> <script lang="ts"> - import type { CodeBlockProps } from './types'; + import type { CodeBlockProps, SelectionInfo } from './types'; + import { addLineNumber, addTokenIds } from './transformers'; + import { onMount, onDestroy } from 'svelte'; + import { browser } from '$app/environment'; let { code = '', lang = 'console', 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 = '' + preClasses = '', + // Selection props + onSelectionChange = (selInfo: any) => {}, + // Debug view + showDebug = false }: CodeBlockProps = $props(); // Shiki convert to HTML const generatedHtml = shiki.codeToHtml(code, { lang, theme, - transformers: [ - { - // code(node) { - // this.addClassToHast(node, 'language-js'); - // }, - line(node, line) { - // Create a span element for the line number - const lineNumberSpan: Element = { - type: 'element', - tagName: 'span', - properties: { - className: ['line-number'] - }, - children: [{ type: 'text', value: `${line}` }] + transformers: [addLineNumber, addTokenIds] + }); + + // Selection state + let selectionInfo: SelectionInfo = $state({ + anchorNode: null, + anchorOffset: 0, + focusNode: null, + focusOffset: 0, + isCollapsed: true, + rangeCount: 0, + type: '', + direction: '', + text: '', + position: { + start: { + line: 0, + column: 0 + }, + end: { + line: 0, + column: 0 + } + } + }); + + // Code block element reference + let codeBlockEl: HTMLElement; + + // Extract position (line, column) information from a node + function extractPositionInfo( + node: Node, + offset: number + ): { line: number; column: number } | null { + // Default position if we can't determine + let position = { line: 0, column: 0 }; + + // Try to find the closest element with data-token attribute + let currentNode = node; + let tokenElement = null; + + // If we're in a text node, get its parent + if (currentNode.nodeType === Node.TEXT_NODE && currentNode.parentElement) { + currentNode = currentNode.parentElement; + } + + // First, check if the current element has data-token + if (currentNode instanceof Element && currentNode.hasAttribute('data-token')) { + tokenElement = currentNode; + } else { + // Walk up the tree to find an element with data-token + let el = currentNode instanceof Element ? currentNode : currentNode.parentElement; + while (el && !tokenElement) { + if (el.hasAttribute('data-token')) { + tokenElement = el; + break; + } + + // Try to find it in previous siblings + let sibling = el.previousElementSibling; + while (sibling && !tokenElement) { + if (sibling.hasAttribute('data-token')) { + tokenElement = sibling; + break; + } + sibling = sibling.previousElementSibling; + } + + el = el.parentElement; + } + } + + // Extract position from data-token + if (tokenElement) { + const tokenAttr = tokenElement.getAttribute('data-token'); + if (tokenAttr) { + const parts = tokenAttr.split(':'); + if (parts.length >= 3 && parts[0] === 'token') { + position = { + line: parseInt(parts[1], 10), + column: parseInt(parts[2], 10) }; - // Add the line number span at the beginning of the line's children - if (node.children) { - node.children.unshift(lineNumberSpan); + // If we're in a text node, add the offset to column + if (node.nodeType === Node.TEXT_NODE) { + position.column += offset; } - }, - span(node, line, col) { - node.properties['data-token'] = `token:${line}:${col}`; } } - ] + } else { + // Try to find the line number by looking for the line-number span + const lineEl = findClosestLineElement(currentNode); + if (lineEl) { + const lineNumberEl = lineEl.querySelector('.line-number'); + if (lineNumberEl && lineNumberEl.textContent) { + position.line = parseInt(lineNumberEl.textContent, 10); + + // Estimate column based on text content before the selection + if (node.nodeType === Node.TEXT_NODE) { + // Count characters up to the offset + position.column = offset; + } + } + } + } + + return position; + } + + // Find the closest line element (container for a line of code) + function findClosestLineElement(node: Node): Element | null { + let currentNode = node; + + // If text node, start with parent + if (currentNode.nodeType === Node.TEXT_NODE && currentNode.parentElement) { + currentNode = currentNode.parentElement; + } + + // Look for a line element (assume it's a direct child of pre) + while (currentNode && currentNode.parentElement) { + const parent = currentNode.parentElement; + // If parent is a pre element, this is likely a line element + if (parent.tagName === 'PRE' && currentNode instanceof Element) { + return currentNode as Element; + } + currentNode = parent; + } + + return null; + } + + // Handle selection change + function updateSelectionInfo() { + const selection = window.getSelection(); + if (!selection) return; + + // Check if the selection is within our code block + if (!isSelectionInCodeBlock(selection)) return; + + // Extract position information + const startPosition = selection.anchorNode + ? extractPositionInfo(selection.anchorNode, selection.anchorOffset) + : { line: 0, column: 0 }; + + const endPosition = selection.focusNode + ? extractPositionInfo(selection.focusNode, selection.focusOffset) + : { line: 0, column: 0 }; + + // Determine actual start and end points based on selection direction + let position = { + start: { ...startPosition }, + end: { ...endPosition } + }; + + // If selection is backwards, swap start and end + if (selection.anchorNode && selection.focusNode) { + // Compare positions to determine actual start/end + if ( + startPosition.line > endPosition.line || + (startPosition.line === endPosition.line && startPosition.column > endPosition.column) + ) { + position = { + start: { ...endPosition }, + end: { ...startPosition } + }; + } + } + + selectionInfo = { + anchorNode: selection.anchorNode ? describeNode(selection.anchorNode) : null, + anchorOffset: selection.anchorOffset, + focusNode: selection.focusNode ? describeNode(selection.focusNode) : null, + focusOffset: selection.focusOffset, + isCollapsed: selection.isCollapsed, + rangeCount: selection.rangeCount, + type: selection.type, + direction: selection.direction, + text: selection.toString(), + position + }; + + // Call the onSelectionChange callback if provided + onSelectionChange(selectionInfo); + } + + // Helper to check if selection is within our code block + function isSelectionInCodeBlock(selection: Selection): boolean { + if (!selection.rangeCount) return false; + + const range = selection.getRangeAt(0); + return codeBlockEl.contains(range.commonAncestorContainer); + } + + // Helper to describe a DOM node in a readable way + function describeNode(node: Node): string { + 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}`; + } + } + + onMount(() => { + // Add global mouseup event to capture selections outside the immediate code block + document.addEventListener('mouseup', updateSelectionInfo); + document.addEventListener('keyup', updateSelectionInfo); + }); + + onDestroy(() => { + if (!browser) return; + // Clean up event listeners + document.removeEventListener('mouseup', updateSelectionInfo); + document.removeEventListener('keyup', updateSelectionInfo); }); </script> -<div class="{base} {rounded} {shadow} {classes} {preBase} {prePadding} {preClasses}"> +<div + bind:this={codeBlockEl} + class="{base} {rounded} {shadow} {classes} {preBase} {prePadding} {preClasses} code-block-selectable" +> <!-- Output Shiki's Generated HTML --> {@html generatedHtml} </div> + +<!-- Selection Debug View --> +{#if showDebug} + <div class="selection-debug"> + <h3>Selection Debug</h3> + {#if !selectionInfo.isCollapsed} + <div class="selection-info"> + <div class="info-row"> + <span class="label">Selected Text:</span> + <span class="value text-value">{selectionInfo.text}</span> + </div> + <div class="info-row"> + <span class="label">Type:</span> + <span class="value">{selectionInfo.type}</span> + </div> + <div class="info-row"> + <span class="label">Direction:</span> + <span class="value">{selectionInfo.direction}</span> + </div> + <div class="info-row"> + <span class="label">Range Count:</span> + <span class="value">{selectionInfo.rangeCount}</span> + </div> + <div class="info-row"> + <span class="label">Is Collapsed:</span> + <span class="value">{String(selectionInfo.isCollapsed)}</span> + </div> + <div class="info-row"> + <span class="label">Anchor Node:</span> + <span class="value">{selectionInfo.anchorNode}</span> + </div> + <div class="info-row"> + <span class="label">Anchor Offset:</span> + <span class="value">{selectionInfo.anchorOffset}</span> + </div> + <div class="info-row"> + <span class="label">Focus Node:</span> + <span class="value">{selectionInfo.focusNode}</span> + </div> + <div class="info-row"> + <span class="label">Focus Offset:</span> + <span class="value">{selectionInfo.focusOffset}</span> + </div> + <div class="info-row"> + <span class="label">Position:</span> + <span class="value"> + Start: Line {selectionInfo.position.start.line}, Column {selectionInfo.position.start + .column} | End: Line {selectionInfo.position.end.line}, Column {selectionInfo.position + .end.column} + </span> + </div> + </div> + {:else} + <p>No selection active. Click and drag to select text in the code block.</p> + {/if} + </div> +{/if} + +<style> + .code-block-selectable :global(pre) { + user-select: text; + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + cursor: text; + } + + .selection-debug { + margin-top: 1rem; + padding: 1rem; + border: 1px solid #ccc; + border-radius: 4px; + background-color: #f5f5f5; + } + + h3 { + margin-top: 0; + margin-bottom: 1rem; + } + + .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/lib/components/CodeBlock/transformers.ts b/src/lib/components/CodeBlock/transformers.ts index 75a3e5b6..15c3e8fd 100644 --- a/src/lib/components/CodeBlock/transformers.ts +++ b/src/lib/components/CodeBlock/transformers.ts @@ -1,21 +1,48 @@ -import { codeToHtml } from 'shiki'; -import { type Element } from 'hast'; +import type { ShikiTransformer } from 'shiki'; +import type * as Hast from 'hast'; -const code = await codeToHtml('foo\bar', { - lang: 'js', - theme: 'vitesse-dark', - transformers: [ - { - line(node, line) { - const lineNumberSpan: Element = { - type: 'element', - tagName: 'span', - properties: { - className: ['line-number'] - }, - children: [{ type: 'text', value: `${line}` }] - }; - } +/** + * Adds line numbers to the code display + */ +export const addLineNumber: ShikiTransformer = { + line(node, line) { + // Create a span element for the line number + const lineNumberSpan: Hast.Element = { + type: 'element', + tagName: 'span', + properties: { + className: ['line-number'] + }, + children: [{ type: 'text', value: `${line}` }] + }; + + // Add the line number span at the beginning of the line's children + if (node.children) { + node.children.unshift(lineNumberSpan); } - ] -}); + } +}; + +/** + * Adds data attributes to each token span to help with selection mapping + */ +export const addTokenIds: ShikiTransformer = { + code(node) { + // Add a class to the code element for easier selection + node.properties.className = [...(node.properties.className || []), 'annotatable-code']; + }, + + line(node, line) { + // Add a data attribute for the line number to help with selection mapping + node.properties['data-line'] = `${line}`; + }, + + span(node, line, col) { + // Add a data attribute to help with selection mapping + node.properties['data-token'] = `token:${line}:${col}`; + + // Add the line and column as separate attributes for easier access + node.properties['data-line'] = `${line}`; + node.properties['data-column'] = `${col}`; + } +}; diff --git a/src/lib/components/CodeBlock/types.ts b/src/lib/components/CodeBlock/types.ts index d086bc6c..a5de7646 100644 --- a/src/lib/components/CodeBlock/types.ts +++ b/src/lib/components/CodeBlock/types.ts @@ -1,7 +1,10 @@ +/** + * Props for the CodeBlock component + */ export interface CodeBlockProps { code?: string; - lang?: 'console' | 'html' | 'css' | 'js'; - theme?: 'dark-plus'; + lang?: string; + theme?: string; // Base Style Props base?: string; rounded?: string; @@ -11,4 +14,64 @@ export interface CodeBlockProps { preBase?: string; prePadding?: string; preClasses?: string; + // Selection props + onSelectionChange?: (selInfo: SelectionInfo) => void; + // Debug view + showDebug?: boolean; +} + +/** + * 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; + }; + }; +} + +/** + * Represents a position in the text source + */ +export interface SrcPosition { + column: number; + line: number; +} + +/** + * Represents a range in the text source + * startLineNumber, startColumn <= endLineNumber, endColumn + */ +export interface SrcRange { + start: SrcPosition; + end: SrcPosition; +} + +/** + * Represents an annotation added to the code + */ +export interface Annotation { + id: string; + // TODO completely replace selection with position + selection: SelectionInfo; + // should be translated from/to a DOM Selection + appliesTo: SrcRange; + content?: string; + color?: string; + createdAt: Date; } diff --git a/src/routes/(app)/debug/editor/+page.svelte b/src/routes/(app)/debug/editor/+page.svelte index 186a0946..f2316414 100644 --- a/src/routes/(app)/debug/editor/+page.svelte +++ b/src/routes/(app)/debug/editor/+page.svelte @@ -1,8 +1,144 @@ <script lang="ts"> - import Editor from '$lib/components/Editor/Editor.svelte'; + import { onMount } from 'svelte'; + import SelectableCodeBlock from '$lib/components/CodeBlock/CodeBlock.svelte'; + import AnnotationEditor from '$lib/components/CodeBlock/AnnotationEditor.svelte'; + import AnnotationsList from '$lib/components/CodeBlock/AnnotationList.svelte'; + import type { SelectionInfo, Annotation } from '$lib/components/CodeBlock/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)}\`);`; + + // Store the current selection info + let currentSelection: SelectionInfo | null = null; + + // Store annotations + let annotations: Annotation[] = []; + + // Show debug panel toggle + let showDebug = false; + + // Handle selection changes + function handleSelectionChange(selInfo: SelectionInfo) { + currentSelection = selInfo; + } + + // Save annotation + function handleSaveAnnotation(event: CustomEvent<Annotation>) { + const newAnnotation = event.detail; + annotations = [...annotations, newAnnotation]; + saveAnnotationsToStorage(); + } + + // Delete annotation + function handleDeleteAnnotation(event: CustomEvent<string>) { + const idToDelete = event.detail; + annotations = annotations.filter((a) => a.id !== idToDelete); + saveAnnotationsToStorage(); + } + + // Save annotations to localStorage + function saveAnnotationsToStorage() { + localStorage.setItem('codeAnnotations', JSON.stringify(annotations)); + } + + // Load annotations from localStorage on mount + onMount(() => { + const savedAnnotations = localStorage.getItem('codeAnnotations'); + if (savedAnnotations) { + try { + const parsed = JSON.parse(savedAnnotations); + // Convert string dates back to Date objects + annotations = parsed.map((a: any) => ({ + ...a, + createdAt: new Date(a.createdAt) + })); + } catch (error) { + console.error('Failed to load annotations from storage', error); + } + } + }); </script> -<Editor /> +<main> + <h1>Code Annotation Editor</h1> + + <div class="code-container"> + <SelectableCodeBlock + code={sampleCode} + lang="js" + onSelectionChange={handleSelectionChange} + {showDebug} + /> + </div> + + <div class="controls"> + <label class="debug-toggle"> + <input type="checkbox" bind:checked={showDebug} /> + Show Selection Debug + </label> + </div> + + <div class="editor-container"> + <div class="annotation-tools"> + <AnnotationEditor selection={currentSelection} on:save={handleSaveAnnotation} /> + </div> + + <div class="annotations-container"> + <AnnotationsList {annotations} on:delete={handleDeleteAnnotation} /> + </div> + </div> +</main> <style> + main { + max-width: 800px; + margin: 0 auto; + padding: 2rem; + } + + h1 { + margin-bottom: 2rem; + } + + .code-container { + border: 1px solid #ddd; + border-radius: 4px; + margin-bottom: 1rem; + } + + .controls { + margin-bottom: 1rem; + } + + .debug-toggle { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; + cursor: pointer; + } + + .editor-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + } + + @media (max-width: 768px) { + .editor-container { + grid-template-columns: 1fr; + } + } </style> -- GitLab