diff --git a/ivette/src/dome/renderer/text/richtext.tsx b/ivette/src/dome/renderer/text/richtext.tsx index b42b85a6dafdf96ed9b9689cf39bba2c832cd5c8..93d1934a6fc93e802cfcaa4e72795f5085e7b855 100644 --- a/ivette/src/dome/renderer/text/richtext.tsx +++ b/ivette/src/dome/renderer/text/richtext.tsx @@ -20,6 +20,7 @@ /* */ /* ************************************************************************ */ +import _ from 'lodash'; import React, { CSSProperties } from 'react'; import { classes } from 'dome/misc/utils'; import * as CS from '@codemirror/state'; @@ -35,7 +36,8 @@ export interface Range extends Offset { length: number } export interface Position extends Offset { line: number } export interface Selection extends Range { fromLine: number, toLine: number } -export const emptySelection : Range & Selection = +export const emptyPosition : Position = { offset: 0, line: 1 }; +export const emptySelection : Selection = { offset: 0, length: 0, fromLine: 1, toLine: 1 }; export function byDepth(a : Range, b : Range): number @@ -433,6 +435,42 @@ Viewport.pack( } )); +/* -------------------------------------------------------------------------- */ +/* --- Hovering Listener --- */ +/* -------------------------------------------------------------------------- */ + +export type HoverCallback = (pos: Position | null) => void; + +const OnHover = new Field<HoverCallback|null>(null); + +function getPosition(evt: MouseEvent, view: CM.EditorView): Position | null +{ + const { x, y } = evt; + const offset = view.posAtCoords({ x, y }, false); + const line = view.state.doc.lineAt(offset); + const p = view.coordsAtPos(line.from); + const q = view.coordsAtPos(line.to); + if (p !== null && q !== null) { + const left = Math.trunc(p.left); + const right = Math.trunc(q.right + 0.5); + const top = Math.trunc(p.top); + const bottom = Math.trunc(q.bottom + 0.5); + const ok = left <= x && x <= right && top <= y && y <= bottom; + if (ok) return { offset, line: line.number }; + } + return null; +} + +OnHover.pack( + CM.EditorView.domEventHandlers({ + mousemove: _.debounce( + (evt: MouseEvent, view: CM.EditorView) => { + const fn = view.state.field(OnHover.field); + if (fn !== null) fn(getPosition(evt, view)); + return false; + }, 10) +})); + /* -------------------------------------------------------------------------- */ /* --- Decorations --- */ /* -------------------------------------------------------------------------- */ @@ -503,7 +541,44 @@ function isGutterDecoration(d : Decoration) : d is GutterDecoration } /* -------------------------------------------------------------------------- */ -/* --- Gutter Builder --- */ +/* --- Decorations Cache --- */ +/* -------------------------------------------------------------------------- */ + +const DecorationCache : Map<string, CM.Decoration> = new Map(); + +function markDecoration(spec: MarkDecoration): CM.Decoration { + const { className='', title='', inclusive=false } = spec; + const key = `M${className}@T${title}@I{inclusive}`; + let mark = DecorationCache.get(key); + if (!mark) { + const attributes = title ? { title } : undefined; + mark = CM.Decoration.mark({ + 'class': className, + attributes, + inclusive + }); + DecorationCache.set(key, mark); + } + return mark; +} + +function lineDecoration(spec: LineDecoration): CM.Decoration { + const { className='', title='' } = spec; + const key = `L${className}@T${title}`; + let line = DecorationCache.get(key); + if (!line) { + const attributes = title ? { title } : undefined; + line = CM.Decoration.line({ + 'class': className, + attributes, + }); + DecorationCache.set(key, line); + } + return line; +} + +/* -------------------------------------------------------------------------- */ +/* --- Gutter Cache --- */ /* -------------------------------------------------------------------------- */ class GutterMark extends CM.GutterMarker { @@ -527,15 +602,15 @@ class GutterMark extends CM.GutterMarker { } -const GutterMarks : Map<string, GutterMark> = new Map(); +const GutterCache : Map<string, GutterMark> = new Map(); function gutterMark(spec: GutterDecoration) : CM.GutterMarker { const { gutter, className='', title='' } = spec; const key = `G${gutter}@C${className}@T${title}`; - let marker = GutterMarks.get(key); + let marker = GutterCache.get(key); if (!marker) { marker = new GutterMark(spec); - GutterMarks.set(key, marker); + GutterCache.set(key, marker); } return marker; } @@ -544,26 +619,10 @@ function gutterMark(spec: GutterDecoration) : CM.GutterMarker { /* --- Decorations Builder --- */ /* -------------------------------------------------------------------------- */ -interface RangeValue<A> extends Range { value: A } - -function byRange<A>(a: RangeValue<A>, b: RangeValue<A>): number { - return (a.offset - b.offset) || (a.length - b.length); -} - -function toRangeSet<A extends CS.RangeValue>( - ranges: RangeValue<A>[] -): CS.RangeSet<A> { - const buffer = new CS.RangeSetBuilder<A>(); - ranges.sort(byRange).forEach((r) => - buffer.add(r.offset, r.offset + r.length, r.value) - ); - return buffer.finish(); -} - class DecorationsBuilder { - private ranges : RangeValue<CM.Decoration>[] = []; - private gutters : RangeValue<CM.GutterMarker>[] = []; + private ranges : CS.Range<CM.Decoration>[] = []; + private gutters : CS.Range<CM.GutterMarker>[] = []; protected readonly doc : CS.Text; constructor(doc: CS.Text) { @@ -572,29 +631,19 @@ class DecorationsBuilder { } addMark(spec: MarkDecoration): void { - const { offset, length, inclusive, className, title } = spec; + const { offset, length } = spec; if (offset < 0) return; - if (offset + length > this.doc.length) return; - const attributes = title ? { title } : undefined; - const value = CM.Decoration.mark({ - 'class': className, - attributes, - inclusive, - }); - this.ranges.push({ offset, length, value }); + const endOffset = offset + length; + if (endOffset > this.doc.length) return; + this.ranges.push(markDecoration(spec).range(offset, endOffset)); } addLine(spec: LineDecoration): void { - const { line, className, title } = spec; + const { line } = spec; if (line < 1) return; if (line > this.doc.lines) return; const offset = this.doc.line(line).from; - const attributes = title ? { title } : undefined; - const value = CM.Decoration.line({ - 'class': className, - attributes, - }); - this.ranges.push({ offset, length: 0, value }); + this.ranges.push(lineDecoration(spec).range(offset)); } addGutter(spec: GutterDecoration): void { @@ -602,8 +651,7 @@ class DecorationsBuilder { if (line < 1) return; if (line > this.doc.lines) return; const offset = this.doc.line(line).from; - const value = gutterMark(spec); - this.gutters.push({ offset, length: 0, value }); + this.gutters.push(gutterMark(spec).range(offset)); } addSpec(spec : Decorations): void { @@ -617,11 +665,11 @@ class DecorationsBuilder { } getRanges(): CS.RangeSet<CM.Decoration> { - return toRangeSet(this.ranges); + return CS.RangeSet.of(this.ranges, true); } getGutters(): CS.RangeSet<CM.GutterMarker> { - return toRangeSet(this.gutters); + return CS.RangeSet.of(this.gutters, true); } } @@ -715,6 +763,7 @@ function createView(parent: Element): CM.EditorView { ReadOnly, OnChange, OnSelect, + OnHover, Viewport, Decorations, ]; @@ -733,6 +782,7 @@ export interface TextViewProps { selection?: Range; onViewport?: SelectionCallback; onSelection?: SelectionCallback; + onHover?: HoverCallback; decorations?: Decorations; lineNumbers?: boolean; showCurrentLine?: boolean; @@ -755,21 +805,23 @@ export function TextView(props: TextViewProps) : JSX.Element { return undefined; }, [text, view]); - // ---- readOnly, onChange, onSelection, lineNumbers + // ---- Fields Props const { - readOnly = false, + onHover = null, onChange = null, + readOnly = false, onViewport: onReview = null, onSelection: onSelect = null, lineNumbers: lines, showCurrentLine: active, } = props; + React.useEffect(() => OnHover.dispatch(view, onHover), [view, onHover]); React.useEffect(() => ReadOnly.dispatch(view, readOnly), [view, readOnly]); React.useEffect(() => OnChange.dispatch(view, onChange), [view, onChange]); React.useEffect(() => OnSelect.dispatch(view, onSelect), [view, onSelect]); React.useEffect(() => Viewport.dispatch(view, onReview), [view, onReview]); - React.useEffect(() => LineNumbers.dispatch(view, lines), [view, lines]); React.useEffect(() => ActiveLine.dispatch(view, active), [view, active]); + React.useEffect(() => LineNumbers.dispatch(view, lines), [view, lines]); // ---- Decorations const { decorations: decors = null } = props; diff --git a/ivette/src/sandbox/sandbox.css b/ivette/src/sandbox/sandbox.css index 305b7d9465bb0442433370f03d38a96e624913c9..31606aeb0ba22be1cab2ad0569c25a16acb86d8b 100644 --- a/ivette/src/sandbox/sandbox.css +++ b/ivette/src/sandbox/sandbox.css @@ -15,3 +15,7 @@ .cm-global-box .line-decoration { background: lightgreen; } + +.cm-global-box .hover { + background: yellow; +} diff --git a/ivette/src/sandbox/text.tsx b/ivette/src/sandbox/text.tsx index 7d678b4971221d59922e3e6719ee050f851eee15..4da3fd984a0e46fdf79fcfb6cfd37473352de8df 100644 --- a/ivette/src/sandbox/text.tsx +++ b/ivette/src/sandbox/text.tsx @@ -34,6 +34,7 @@ import { TextView, TextProxy, TextBuffer, + Position, emptySelection, Decoration, } from 'dome/text/richtext'; @@ -54,6 +55,7 @@ function UseText(): JSX.Element { const [lines, setLines] = React.useState(1); const [s, onSelection] = React.useState(emptySelection); const [v, onViewport] = React.useState(emptySelection); + const [h, onHover] = React.useState<Position | null>(null); const proxy = React.useMemo(() => new TextProxy(), []); const buffer = React.useMemo(() => new TextBuffer(), []); const text = useProxy ? proxy : buffer; @@ -113,6 +115,10 @@ function UseText(): JSX.Element { const isLine = s.fromLine === s.toLine && s.toLine <= lines; const isRange = s.length > 0 && s.offset + s.length <= length; + const allDecorations = React.useMemo(() => { + if (h===null) return decorations; + return [...decorations, { line: h.line, className: 'hover' }]; + }, [decorations, h]); return ( <> @@ -177,8 +183,9 @@ function UseText(): JSX.Element { readOnly={readOnly} onChange={onChange} onSelection={onSelection} + onHover={onHover} onViewport={onViewport} - decorations={decorations} + decorations={allDecorations} lineNumbers={useLines} showCurrentLine={useCurrent} /> @@ -186,6 +193,7 @@ function UseText(): JSX.Element { <Code label={`Offset ${s.offset}-${s.offset + s.length} / ${length}`} /> <Code label={`Line ${s.fromLine}-${s.toLine} / ${lines}`} /> <Code label={`View ${v.fromLine}-${v.toLine}`} /> + <Code label={`Hover ${h ? h.offset : '-'}:${h ? h.line : '-'}`} /> <Filler /> <Code>{`Changes: ${changes}`}</Code> </ToolBar>