diff --git a/src/lib/components/CodeBlock/CodeBlock.svelte b/src/lib/components/CodeBlock/CodeBlock.svelte index 18018c19dd1d99dfa8cc607b39456ec8fcd0bc2f..bb93421639b8bc0ba8fdd48b366ab0dc1c30be85 100644 --- a/src/lib/components/CodeBlock/CodeBlock.svelte +++ b/src/lib/components/CodeBlock/CodeBlock.svelte @@ -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 { 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,35 +39,45 @@ preBase = '', prePadding = '[&>pre]:p-4', preClasses = '', - // Selection props - onSelectionChange = (selInfo: Selection) => {}, - // Debug view + + onSelectionChange = (_) => {}, showDebug = false }: CodeBlockProps = $props(); - // Shiki convert to HTML const generatedHtml = shiki.codeToHtml(code, { lang, theme, - transformers: [addTokenIds] + transformers: [sourceMappingTransformer], + structure: 'classic' }); - // Selection state - let currentSelection: Selection| null = $state(null); + let currentSelection: SelectionInfo | null = $state(null); + let sourceSelection: SourceSelection | null = $state(null); + $inspect(currentSelection); + $inspect(sourceSelection); // Code block element reference let codeBlockEl: HTMLElement; function updateSelectionInfo() { - const sel= window.getSelection(); - if (!sel|| !isSelectionInCodeBlock(sel)) { currentSelection = null; return; } + const sel = window.getSelection(); + + if (!sel || !isSelectionInCodeBlock(sel)) { + // currentSelection = null; + // sourceSelection = null; + return; + } - currentSelection = sel; - onSelectionChange(currentSelection); + currentSelection = getSelectionInfo(sel); + + sourceSelection = domSelectionToSourceSelection(sel, codeBlockEl); + + if (sourceSelection) { + onSelectionChange({ ...currentSelection, source: sourceSelection }); + } } - // Helper to check if selection is within our code block function isSelectionInCodeBlock(selection: Selection): boolean { if (!selection.rangeCount) return false; @@ -73,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; @@ -86,19 +119,12 @@ } onMount(() => { - document.addEventListener("selectionchange", updateSelectionInfo); - codeBlockEl.addEventListener('mouseup', updateSelectionInfo); - codeBlockEl.addEventListener('keyup', updateSelectionInfo); + document.addEventListener('selectionchange', updateSelectionInfo); }); onDestroy(() => { if (!browser) return; - document.removeEventListener('selectionchange', updateSelectionInfo); - if (codeBlockEl) { - codeBlockEl.removeEventListener('mouseup', updateSelectionInfo); - codeBlockEl.removeEventListener('keyup', updateSelectionInfo); - } }); </script> @@ -106,23 +132,14 @@ 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> - <pre>{currentSelection? currentSelection.toString() : "No selection" }</pre> {#if currentSelection !== null && !currentSelection.isCollapsed} <div class="selection-info"> - <!-- - <div class="info-row"> - <span class="label">Selected Text:</span> - <span class="value text-value">{currentSelection.text}</span> - </div> - --> <div class="info-row"> <span class="label">Type:</span> <span class="value">{currentSelection.type}</span> @@ -141,7 +158,7 @@ </div> <div class="info-row"> <span class="label">Anchor Node:</span> - <span class="value">{currentSelection.anchorNode}</span> + <span class="value">{describeNode(currentSelection.anchorNode)}</span> </div> <div class="info-row"> <span class="label">Anchor Offset:</span> @@ -149,22 +166,32 @@ </div> <div class="info-row"> <span class="label">Focus Node:</span> - <span class="value">{currentSelection.focusNode}</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> - <!-- - <div class="info-row"> - <span class="label">Position:</span> - <span class="value"> - Start: Line {currentSelection.position.start.line}, Column {currentSelection.position.start - .column} | End: Line {currentSelection.position.end.line}, Column {currentSelection.position - .end.column} - </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> diff --git a/src/lib/components/CodeBlock/transformers.ts b/src/lib/components/CodeBlock/transformers.ts index 15c3e8fde184dafc9ea371dd5bff585cd91f04ff..aef87bb5fb62cdb2731d688949fd5c50511e8563 100644 --- a/src/lib/components/CodeBlock/transformers.ts +++ b/src/lib/components/CodeBlock/transformers.ts @@ -1,48 +1,123 @@ 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 + }; +} diff --git a/src/lib/components/CodeBlock/types.ts b/src/lib/components/CodeBlock/types.ts index 84fc0f099a98456cd1b3407cdb8e9c78559688f7..3c81f944fd5a117238dd4494b4dca92b0da5e174 100644 --- a/src/lib/components/CodeBlock/types.ts +++ b/src/lib/components/CodeBlock/types.ts @@ -15,15 +15,40 @@ export interface CodeBlockProps { prePadding?: string; preClasses?: string; // Selection props - onSelectionChange?: (selInfo: Selection) => 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]; }; +/** + * Enhanced SelectionInfo that includes source positions + */ +export interface EnhancedSelectionInfo extends SelectionInfo { + source?: SourceSelection; +} + +/** + * Source code position (0-based) + */ +export interface SourcePosition { + line: number; // 0-based line number + column: number; // 0-based column number +} + +/** + * Selection range in source code + */ +export interface SourceSelection { + start: SourcePosition; + end: SourcePosition; + text: string; +} + /** * Information about a DOM Selection */ @@ -48,34 +73,3 @@ export type SelectionInfo = { // }; // }; // } - -/** - * 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 2b9935fddbca8ce7d7fe28f3168187289a0d8cc0..109d49f0c1f61f58197ff67f9d951e7ee8d25a83 100644 --- a/src/routes/(app)/debug/editor/+page.svelte +++ b/src/routes/(app)/debug/editor/+page.svelte @@ -1,62 +1,40 @@ <script lang="ts"> -import { browser } from '$app/environment'; -import { onDestroy, onMount } from 'svelte'; + import CodeBlock from '$lib/components/CodeBlock/CodeBlock.svelte'; + import type { EnhancedSelectionInfo } 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 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 } + { 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 showDebug = $state(true); + let selectionInfo: EnhancedSelectionInfo | null = $state(null); -let selection: Selection | null = $state(null); - -let codeArea: Element | null = $state(null); - -function updateSelectionInfo() { - selection = window.getSelection(); - console.log(selection); - console.log(`In codeArea: ${isSelectionInCodeBlock(selection!)}`) -} - -function isSelectionInCodeBlock(selection: Selection): boolean { - if (!codeArea) return false; - if (!selection.rangeCount) return false; - - const range = selection.getRangeAt(0); - return codeArea.contains(range.commonAncestorContainer); -} - -onMount(() => { - document.addEventListener("selectionchange", updateSelectionInfo); - - //if (!codeArea) return; - //codeArea.addEventListener('mouseup', updateSelectionInfo); -}) + function handleSelectionChange(info: EnhancedSelectionInfo) { + selectionInfo = info; + } -onDestroy(() => { - if (!browser) return; - document.removeEventListener("selectionchange", updateSelectionInfo); - //if (!codeArea) return; - //codeArea.removeEventListener('mouseup', updateSelectionInfo); -}) + function formatPosition(pos: { line: number; column: number } | undefined) { + if (!pos) return 'None'; + return `Line ${pos.line}, Column ${pos.column + 1}`; + } </script> <main> <h1>Code Annotation Editor</h1> <div class="code-container"> - <pre bind:this={codeArea}>{sampleCode}</pre> + <CodeBlock code={sampleCode} {showDebug} onSelectionChange={handleSelectionChange} /> </div> <div class="controls"> @@ -65,34 +43,84 @@ onDestroy(() => { Show Selection Debug </label> </div> + + {#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> + {/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; + } + + details { + margin-top: 1rem; + } + + .selection-json { + max-height: 300px; + overflow: auto; + background-color: #f0f0f0; + padding: 0.5rem; + font-size: 0.8rem; } </style>