Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • grady-corp/grady
1 result
Show changes
Commits on Source (2)
/nix/store/529mdhkrlkx33w8gpvrhv9jmi660l74f-pre-commit-config.json
\ No newline at end of file
/nix/store/lir2yrcyd25s3h1pxgnvv3adzrmby6bc-pre-commit-config.json
\ No newline at end of file
......@@ -56,7 +56,8 @@ 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 FIELD annotations ON evaluation TYPE array<record<annotation>> DEFAULT [] PERMISSIONS FULL;
DEFINE FIELD annotations ON evaluation TYPE array<object> DEFAULT [] PERMISSIONS FULL;
DEFINE INDEX one_per_submission ON evaluation FIELDS out UNIQUE;
......@@ -67,9 +68,9 @@ DEFINE INDEX one_per_submission ON evaluation FIELDS out UNIQUE;
-- 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;
-- 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
......
......@@ -18,10 +18,10 @@
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1741670053,
"lastModified": 1741953770,
"owner": "cachix",
"repo": "devenv",
"rev": "47abb5dfd5b7824a0979ef86f3986aea48847312",
"rev": "984272189d4c23e3663a4d7e83b3cf3a532aa53d",
"type": "github"
},
"original": {
......@@ -40,10 +40,10 @@
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1741761224,
"lastModified": 1741934023,
"owner": "nix-community",
"repo": "fenix",
"rev": "585b58faf23d956dc0c2aa819dc099c9f93200a0",
"rev": "4f956eacc9ec619bcd98f4580c663a8749978cc8",
"type": "github"
},
"original": {
......@@ -114,10 +114,31 @@
"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": [
"pre-commit-hooks",
"git-hooks",
"nixpkgs"
]
},
......@@ -170,10 +191,10 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1741708242,
"lastModified": 1741865919,
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b62d2a95c72fb068aecd374a7262b37ed92df82b",
"rev": "573c650e8a14b2faa0041645ab18aed7e60f0c9a",
"type": "github"
},
"original": {
......@@ -185,10 +206,10 @@
},
"nixpkgs-unstable": {
"locked": {
"lastModified": 1741708242,
"lastModified": 1741865919,
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b62d2a95c72fb068aecd374a7262b37ed92df82b",
"rev": "573c650e8a14b2faa0041645ab18aed7e60f0c9a",
"type": "github"
},
"original": {
......@@ -200,10 +221,10 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1741708606,
"lastModified": 1741969460,
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "2ab32cf9e0c5b55f0004ee9ab4697b17ddd0da94",
"rev": "68612419aa6c9fd5b178b81e6fabbdf46d300ea4",
"type": "github"
},
"original": {
......@@ -213,45 +234,27 @@
"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": "pre-commit-hooks",
"pre-commit-hooks": [
"git-hooks"
],
"surrealdb-git": "surrealdb-git"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1741703184,
"lastModified": 1741895161,
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "8d01570b5e812a49daa1f08404269f6ea5dd73a1",
"rev": "185f9deb452760f3abc2fde0500398e3198678cd",
"type": "github"
},
"original": {
......
......@@ -60,6 +60,7 @@
"tailwindcss": "^4.0.13",
"typescript": "^5.8.2",
"typescript-eslint": "^8.26.1",
"typescript-svelte-plugin": "^0.3.46",
"vite": "^6.2.1",
"vitest": "^3.0.8",
"zod": "^3.24.2"
......@@ -2705,6 +2706,13 @@
}
}
},
"node_modules/dedent-js": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dedent-js/-/dedent-js-1.0.1.tgz",
"integrity": "sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==",
"dev": true,
"license": "MIT"
},
"node_modules/deep-eql": {
"version": "5.0.2",
"dev": true,
......@@ -4030,6 +4038,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/lower-case": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
"integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.0.3"
}
},
"node_modules/lru-cache": {
"version": "11.0.2",
"dev": true,
......@@ -4313,6 +4331,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/no-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
"integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"lower-case": "^2.0.2",
"tslib": "^2.0.3"
}
},
"node_modules/node-releases": {
"version": "2.0.19",
"dev": true,
......@@ -4440,6 +4469,17 @@
"node": ">=6"
}
},
"node_modules/pascal-case": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz",
"integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"no-case": "^3.0.4",
"tslib": "^2.0.3"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"dev": true,
......@@ -5667,6 +5707,21 @@
"node": ">=4"
}
},
"node_modules/svelte2tsx": {
"version": "0.7.35",
"resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.7.35.tgz",
"integrity": "sha512-z2lnOnrfb5nrlRfFQI8Qdz03xQqMHUfPj0j8l/fQuydrH89cCeN+v9jgDwK9GyMtdTRUkE7Neu9Gh+vfXJAfuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"dedent-js": "^1.0.1",
"pascal-case": "^3.1.1"
},
"peerDependencies": {
"svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0",
"typescript": "^4.9.4 || ^5.0.0"
}
},
"node_modules/sveltekit-superforms": {
"version": "2.24.0",
"dev": true,
......@@ -6035,6 +6090,17 @@
"typescript": ">=4.8.4 <5.9.0"
}
},
"node_modules/typescript-svelte-plugin": {
"version": "0.3.46",
"resolved": "https://registry.npmjs.org/typescript-svelte-plugin/-/typescript-svelte-plugin-0.3.46.tgz",
"integrity": "sha512-6GUb+nafp00/WEOKztBddyCsrJNHbIqO32kPfhDFybWqhj89AxUhwedVE2g4rug2E48d40AgG2jLYW611esJAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"svelte2tsx": "~0.7.35"
}
},
"node_modules/undici": {
"version": "5.28.5",
"dev": true,
......
......@@ -53,6 +53,7 @@
"tailwindcss": "^4.0.13",
"typescript": "^5.8.2",
"typescript-eslint": "^8.26.1",
"typescript-svelte-plugin": "^0.3.46",
"vite": "^6.2.1",
"vitest": "^3.0.8",
"zod": "^3.24.2"
......
......@@ -2,31 +2,33 @@
import { createHighlighterCoreSync } from 'shiki/core';
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
// Themes
// https://shiki.style/themes
import themeDarkPlus from 'shiki/themes/dark-plus.mjs';
// Languages
// https://shiki.style/languages
import console from 'shiki/langs/console.mjs';
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(),
themes: [themeDarkPlus],
langs: [console, html, css, js]
langs: [html, css, js]
});
</script>
<script lang="ts">
import type { CodeBlockProps, SelectionInfo } from './types';
import { addLineNumber, addTokenIds } from './transformers';
import type {
CodeBlockProps,
SelectionInfo,
EnhancedSelectionInfo,
SourceSelection
} from './types';
import { sourceMappingTransformer, domSelectionToSourceSelection } from './transformers';
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
let {
code = '',
lang = 'console',
lang = 'js',
theme = 'dark-plus',
// Base Style Props
base = 'overflow-hidden',
......@@ -37,202 +39,45 @@
preBase = '',
prePadding = '[&>pre]:p-4',
preClasses = '',
// Selection props
onSelectionChange = (selInfo: any) => {},
// Debug view
onSelectionChange = (_) => {},
showDebug = false
}: CodeBlockProps = $props();
// Shiki convert to HTML
const generatedHtml = shiki.codeToHtml(code, {
lang,
theme,
transformers: [addLineNumber, addTokenIds]
transformers: [sourceMappingTransformer],
structure: 'classic'
});
// 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
}
}
});
let currentSelection: SelectionInfo | null = $state(null);
let sourceSelection: SourceSelection | null = $state(null);
$inspect(currentSelection);
$inspect(sourceSelection);
// 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)
};
// If we're in a text node, add the offset to column
if (node.nodeType === Node.TEXT_NODE) {
position.column += offset;
}
}
}
} 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;
function updateSelectionInfo() {
const sel = window.getSelection();
// If text node, start with parent
if (currentNode.nodeType === Node.TEXT_NODE && currentNode.parentElement) {
currentNode = currentNode.parentElement;
if (!sel || !isSelectionInCodeBlock(sel)) {
// currentSelection = null;
// sourceSelection = null;
return;
}
// 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;
}
currentSelection = getSelectionInfo(sel);
return null;
}
sourceSelection = domSelectionToSourceSelection(sel, codeBlockEl);
// 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 }
};
}
if (sourceSelection) {
onSelectionChange({ ...currentSelection, source: sourceSelection });
}
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;
......@@ -240,9 +85,30 @@
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) {
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;
......@@ -253,16 +119,12 @@
}
onMount(() => {
// Add global mouseup event to capture selections outside the immediate code block
document.addEventListener('mouseup', updateSelectionInfo);
document.addEventListener('keyup', updateSelectionInfo);
document.addEventListener('selectionchange', updateSelectionInfo);
});
onDestroy(() => {
if (!browser) return;
// Clean up event listeners
document.removeEventListener('mouseup', updateSelectionInfo);
document.removeEventListener('keyup', updateSelectionInfo);
document.removeEventListener('selectionchange', updateSelectionInfo);
});
</script>
......@@ -270,60 +132,66 @@
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}
{#if currentSelection !== null && !currentSelection.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>
<span class="value">{currentSelection.type}</span>
</div>
<div class="info-row">
<span class="label">Direction:</span>
<span class="value">{selectionInfo.direction}</span>
<span class="value">{currentSelection.direction}</span>
</div>
<div class="info-row">
<span class="label">Range Count:</span>
<span class="value">{selectionInfo.rangeCount}</span>
<span class="value">{currentSelection.rangeCount}</span>
</div>
<div class="info-row">
<span class="label">Is Collapsed:</span>
<span class="value">{String(selectionInfo.isCollapsed)}</span>
<span class="value">{String(currentSelection.isCollapsed)}</span>
</div>
<div class="info-row">
<span class="label">Anchor Node:</span>
<span class="value">{selectionInfo.anchorNode}</span>
<span class="value">{describeNode(currentSelection.anchorNode)}</span>
</div>
<div class="info-row">
<span class="label">Anchor Offset:</span>
<span class="value">{selectionInfo.anchorOffset}</span>
<span class="value">{currentSelection.anchorOffset}</span>
</div>
<div class="info-row">
<span class="label">Focus Node:</span>
<span class="value">{selectionInfo.focusNode}</span>
<span class="value">{describeNode(currentSelection.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>
<span class="value">{currentSelection.focusOffset}</span>
</div>
{#if sourceSelection}
<hr />
<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>
<span class="value text-value">{sourceSelection.text}</span>
</div>
{/if}
</div>
{:else}
<p>No selection active. Click and drag to select text in the code block.</p>
......
import type { ShikiTransformer } from 'shiki';
import type * as Hast from 'hast';
import type {
EnhancedSelectionInfo,
SelectionInfo,
SourcePosition,
SourceSelection
} from './types';
/**
* 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);
}
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;
}
};
/**
* 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'];
},
export function domSelectionToSourceSelection(
selection: Selection,
codeEl: HTMLElement
): SourceSelection | null {
if (!selection || selection.rangeCount === 0 || !codeEl) return null;
const range = selection.getRangeAt(0);
line(node, line) {
// Add a data attribute for the line number to help with selection mapping
node.properties['data-line'] = `${line}`;
},
if (!codeEl.contains(range.commonAncestorContainer)) return null;
const startPosition = findSourcePosition(range.startContainer, range.startOffset, codeEl);
const endPosition = findSourcePosition(range.endContainer, range.endOffset, codeEl);
span(node, line, col) {
// Add a data attribute to help with selection mapping
node.properties['data-token'] = `token:${line}:${col}`;
if (!startPosition || !endPosition) return null;
// Add the line and column as separate attributes for easier access
node.properties['data-line'] = `${line}`;
node.properties['data-column'] = `${col}`;
const selectedText = selection.toString(); // TODO get source text, not DOM text
return { start: startPosition, end: endPosition, text: selectedText };
}
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
};
}
......@@ -15,63 +15,61 @@ export interface CodeBlockProps {
prePadding?: string;
preClasses?: string;
// Selection props
onSelectionChange?: (selInfo: SelectionInfo) => void;
onSelectionChange?: (selInfo: EnhancedSelectionInfo) => void;
// Debug view
showDebug?: boolean;
}
// 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];
};
/**
* Information about a DOM Selection
* Enhanced SelectionInfo that includes source positions
*/
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;
};
};
export interface EnhancedSelectionInfo extends SelectionInfo {
source?: SourceSelection;
}
/**
* Represents a position in the text source
* Source code position (0-based)
*/
export interface SrcPosition {
column: number;
line: number;
export interface SourcePosition {
line: number; // 0-based line number
column: number; // 0-based column number
}
/**
* Represents a range in the text source
* startLineNumber, startColumn <= endLineNumber, endColumn
* Selection range in source code
*/
export interface SrcRange {
start: SrcPosition;
end: SrcPosition;
export interface SourceSelection {
start: SourcePosition;
end: SourcePosition;
text: string;
}
/**
* Represents an annotation added to the code
* Information about a DOM Selection
*/
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;
}
// 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 { 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';
import CodeBlock from '$lib/components/CodeBlock/CodeBlock.svelte';
import type { EnhancedSelectionInfo } from '$lib/components/CodeBlock/types';
const sampleCode = `function calculateTotal(items) {
return items
......@@ -20,67 +17,24 @@ const cart = [
const total = calculateTotal(cart);
console.log(\`Total: \$\${total.toFixed(2)}\`);`;
// Store the current selection info
let currentSelection: SelectionInfo | null = null;
let showDebug = $state(true);
let selectionInfo: EnhancedSelectionInfo | null = $state(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();
function handleSelectionChange(info: EnhancedSelectionInfo) {
selectionInfo = info;
}
// Save annotations to localStorage
function saveAnnotationsToStorage() {
localStorage.setItem('codeAnnotations', JSON.stringify(annotations));
function formatPosition(pos: { line: number; column: number } | undefined) {
if (!pos) return 'None';
return `Line ${pos.line}, Column ${pos.column + 1}`;
}
// 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>
<main>
<h1>Code Annotation Editor</h1>
<div class="code-container">
<SelectableCodeBlock
code={sampleCode}
lang="js"
onSelectionChange={handleSelectionChange}
{showDebug}
/>
<CodeBlock code={sampleCode} {showDebug} onSelectionChange={handleSelectionChange} />
</div>
<div class="controls">
......@@ -90,55 +44,83 @@ console.log(\`Total: \$\${total.toFixed(2)}\`);`;
</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} />
{#if showDebug}
<div class="debug-panel">
<h3>Enhanced Selection Info</h3>
{#if selectionInfo?.source}
<div class="source-info">
<div class="info-row">
<span class="label">Selection Start:</span>
<span class="value">{formatPosition(selectionInfo.source.start)}</span>
</div>
<div class="info-row">
<span class="label">Selection End:</span>
<span class="value">{formatPosition(selectionInfo.source.end)}</span>
</div>
<div class="info-row">
<span class="label">Selected Text:</span>
<pre class="selected-text">{selectionInfo.source.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>
</div>
{/if}
</main>
<style>
main {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
.code-container {
margin: 1rem 0;
border: 1px solid #ddd;
border-radius: 4px;
}
h1 {
margin-bottom: 2rem;
.debug-panel {
margin-top: 1rem;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 4px;
}
.code-container {
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 1rem;
.info-row {
margin: 0.5rem 0;
display: flex;
}
.controls {
.label {
font-weight: bold;
width: 120px;
}
.source-info {
margin-bottom: 1rem;
}
.debug-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
cursor: pointer;
.selected-text {
background-color: #f0f0f0;
padding: 0.5rem;
border-radius: 3px;
margin-top: 0.5rem;
font-family: monospace;
white-space: pre-wrap;
}
.editor-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
details {
margin-top: 1rem;
}
@media (max-width: 768px) {
.editor-container {
grid-template-columns: 1fr;
}
.selection-json {
max-height: 300px;
overflow: auto;
background-color: #f0f0f0;
padding: 0.5rem;
font-size: 0.8rem;
}
</style>
......@@ -9,6 +9,11 @@
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
"moduleResolution": "bundler",
"plugins": [
{
"name": "typescript-svelte-plugin"
}
]
}
}
}
\ No newline at end of file