diff --git a/demo/src/stories/examples/markup-line-numbers/Editor.tsx b/demo/src/stories/examples/markup-line-numbers/Editor.tsx new file mode 100644 index 000000000..d2ceebc8c --- /dev/null +++ b/demo/src/stories/examples/markup-line-numbers/Editor.tsx @@ -0,0 +1,200 @@ +import {memo, useState} from 'react'; + +import { + MarkdownEditorView, + NumberInput, + setHighlightedLine, + useMarkdownEditor, +} from '@gravity-ui/markdown-editor'; +import type {MarkupLineNumbersConfig} from '@gravity-ui/markdown-editor'; +import {Button, Flex} from '@gravity-ui/uikit'; + +import {PlaygroundLayout} from '../../../components/PlaygroundLayout'; + +const longMarkup = [ + '# Markup Line Numbers Demo', + '', + 'This document demonstrates the new markup-mode features:', + 'line numbers, line highlighting, and scroll-to-line.', + '', + '## Getting Started', + '', + 'The editor below is running in **markup mode** (CodeMirror 6).', + 'You can see line numbers in the gutter on the left side.', + '', + '### Line Numbers', + '', + 'When `markupConfig.lineNumbers` is enabled, the editor', + 'displays line numbers in the left gutter. This is useful', + 'for referencing specific lines in documentation or code reviews.', + '', + '### Line Highlighting', + '', + 'When `lineHighlight()` extension is passed via `markupConfig.extensions`,', + 'clicking on a line number in the gutter will highlight that entire line.', + 'This extension automatically includes line numbers as well.', + '', + 'You can also programmatically highlight a line using the', + '`setHighlightedLine` StateEffect exported from the package.', + '', + '### Scroll to Line', + '', + 'The `markupConfig.lineNumbers.scrollToLine` option allows you to scroll the', + 'editor to a specific line (0-based) on mount. This is useful', + 'when you want to draw attention to a particular section of', + 'a long document.', + '', + '## Example Content', + '', + 'Here is some additional content to make the document long', + 'enough to demonstrate scrolling behavior.', + '', + '### Lists', + '', + '- Item one', + '- Item two', + '- Item three', + '- Item four', + '- Item five', + '', + '### Code Block', + '', + '```typescript', + "import {lineHighlight, useMarkdownEditor} from '@gravity-ui/markdown-editor';", + '', + 'const editor = useMarkdownEditor({', + " initial: {mode: 'markup'},", + ' markupConfig: {', + ' extensions: [lineHighlight()],', + ' },', + '});', + '```', + '', + '### Table', + '', + '| Feature | Option | Default |', + '|---------|--------|---------|', + '| Line numbers | `markupConfig.lineNumbers` | `false` |', + '| Highlight line | `markupConfig.extensions: [lineHighlight()]` | — |', + '| Scroll to line | `markupConfig.lineNumbers.scrollToLine` | `undefined` |', + '', + '### More Text', + '', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.', + '', + 'Duis aute irure dolor in reprehenderit in voluptate velit esse', + 'cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat', + 'cupidatat non proident, sunt in culpa qui officia deserunt mollit.', + '', + '### Final Section', + '', + 'This is the end of the demo document. If `scrollToLine` is set', + 'to a value like 20, the editor should scroll to approximately', + 'this area of the document on initialization.', + '', + '> **Tip:** Try clicking on line numbers in the gutter to highlight lines!', +].join('\n'); + +export type MarkupLineNumbersEditorProps = { + lineNumbers?: MarkupLineNumbersConfig; +}; + +export const MarkupLineNumbersEditor = memo( + function MarkupLineNumbersEditor({lineNumbers}) { + const [fromLine, setFromLine] = useState( + lineNumbers?.initialSelectedLines?.from ?? 0, + ); + const [toLine, setToLine] = useState( + lineNumbers?.initialSelectedLines?.to ?? 0, + ); + const [lastClickedLine, setLastClickedLine] = useState(null); + + const markupLineNumbers: MarkupLineNumbersConfig | undefined = lineNumbers + ? { + ...lineNumbers, + onLineClick: (line) => setLastClickedLine(line), + } + : undefined; + + const editor = useMarkdownEditor( + { + initial: { + mode: 'markup', + markup: longMarkup, + }, + markupConfig: { + lineNumbers: markupLineNumbers, + }, + }, + [], + ); + + const handleHighlightLine = () => { + if (typeof fromLine !== 'number' || Number.isNaN(fromLine)) return; + if (typeof toLine !== 'number' || Number.isNaN(toLine)) return; + + const cm = (editor as any).cm; + if (cm) { + cm.dispatch({effects: setHighlightedLine.of({from: fromLine, to: toLine})}); + } + }; + + return ( + + lineNumbers?.highlightLines ? ( + + From line: + + To line: + + + + {lastClickedLine !== null && ( + Last clicked: line {lastClickedLine + 1} + )} + + ) : null + } + view={({className}) => ( + + )} + /> + ); + }, +); diff --git a/demo/src/stories/examples/markup-line-numbers/MarkupLineNumbers.stories.tsx b/demo/src/stories/examples/markup-line-numbers/MarkupLineNumbers.stories.tsx new file mode 100644 index 000000000..e8379ed5c --- /dev/null +++ b/demo/src/stories/examples/markup-line-numbers/MarkupLineNumbers.stories.tsx @@ -0,0 +1,61 @@ +import type {Meta, StoryObj} from '@storybook/react'; + +import {MarkupLineNumbersEditor} from './Editor'; + +const meta: Meta = { + title: 'Examples / Markup Line Numbers', + component: MarkupLineNumbersEditor, +}; + +export default meta; + +type Story = StoryObj; + +export const LineNumbersOnly: Story = { + args: { + lineNumbers: {enabled: true}, + }, +}; +LineNumbersOnly.storyName = 'Line Numbers Only'; + +export const HighlightLine: Story = { + args: { + lineNumbers: {enabled: true, highlightLines: true}, + }, +}; +HighlightLine.storyName = 'Highlight Line'; + +export const HighlightMultipleLines: Story = { + args: { + lineNumbers: { + enabled: true, + highlightLines: true, + initialSelectedLines: {from: 5, to: 10}, + }, + }, +}; +HighlightMultipleLines.storyName = 'Highlight Multiple Lines'; + +export const ScrollToLine: Story = { + args: { + lineNumbers: { + enabled: true, + scrollToLine: 20, + initialSelectedLines: {from: 20, to: 20}, + highlightLines: true, + }, + }, +}; +ScrollToLine.storyName = 'Scroll to Line'; + +export const AllFeatures: Story = { + args: { + lineNumbers: { + enabled: true, + highlightLines: true, + initialSelectedLines: {from: 20, to: 25}, + scrollToLine: 20, + }, + }, +}; +AllFeatures.storyName = 'All Features'; diff --git a/packages/editor/src/bundle/Editor.ts b/packages/editor/src/bundle/Editor.ts index d2d3b5f4c..894042ba3 100644 --- a/packages/editor/src/bundle/Editor.ts +++ b/packages/editor/src/bundle/Editor.ts @@ -62,6 +62,7 @@ export interface EditorInt readonly mdOptions: Readonly; readonly directiveSyntax: DirectiveSyntaxContext; readonly mobile: boolean; + readonly markupConfig: MarkupConfig; /** @internal used in demo for dev-tools */ readonly _wysiwygView?: PMEditorView; @@ -280,6 +281,7 @@ export class EditorImpl extends SafeEventEmitter implements EditorI directiveSyntax: this.directiveSyntax, receiver: this, searchPanel: this.#markupConfig.searchPanel, + lineNumbers: this.#markupConfig.lineNumbers, }), ); } @@ -310,6 +312,14 @@ export class EditorImpl extends SafeEventEmitter implements EditorI return this.#mobile; } + get initialScrollToLine(): number | undefined { + return this.#markupConfig.lineNumbers?.scrollToLine; + } + + get markupConfig(): MarkupConfig { + return this.#markupConfig; + } + constructor(opts: EditorOptions) { const {logger} = opts; diff --git a/packages/editor/src/bundle/MarkupEditorComponent.tsx b/packages/editor/src/bundle/MarkupEditorComponent.tsx index 420bef24a..8e40da86e 100644 --- a/packages/editor/src/bundle/MarkupEditorComponent.tsx +++ b/packages/editor/src/bundle/MarkupEditorComponent.tsx @@ -34,6 +34,14 @@ export const MarkupEditorComponent: React.FC = } }, [editor.markupEditor]); + // scroll to line on mount + useEffect(() => { + const scrollToLine = editor.markupConfig.lineNumbers?.scrollToLine; + if (editor.markupConfig.lineNumbers?.enabled && scrollToLine !== undefined) { + editor.moveCursor({line: scrollToLine}); + } + }, []); + return ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
void; +} + +export const setHighlightedLine = StateEffect.define(); + +const highlightLineDecoration = Decoration.line({ + attributes: {class: 'cm-highlighted-line'}, +}); + +const highlightedGutterClass = new (class extends GutterMarker { + elementClass = 'cm-highlighted-gutter-line'; +})(); + +const highlightLineTheme = EditorView.baseTheme({ + '.cm-highlighted-line': { + backgroundColor: 'var(--g-color-base-selection) !important', + }, + '.cm-highlighted-gutter-line': { + backgroundColor: 'var(--g-color-base-selection) !important', + color: 'var(--g-color-text-primary) !important', + }, + '.cm-lineNumbers .cm-gutterElement': { + cursor: 'pointer', + }, +}); + +export function lineHighlight(options?: LineHighlightOptions): Extension { + const initialRange = options?.initialRange ?? null; + + const highlightedLineField = StateField.define({ + create: () => initialRange, + update(value, tr) { + for (const effect of tr.effects) { + if (effect.is(setHighlightedLine)) { + return effect.value; + } + } + return value; + }, + }); + + const highlightGutterDecoration = gutterLineClass.compute([highlightedLineField], (state) => { + const range = state.field(highlightedLineField); + + if (range === null) { + return RangeSet.empty; + } + + const builder = new RangeSetBuilder(); + + for (let line = range.from; line <= range.to; line++) { + try { + const cmLine = state.doc.line(line + 1); + builder.add(cmLine.from, cmLine.from, highlightedGutterClass); + } catch {} + } + + return builder.finish(); + }); + + const highlightLineDecorations = EditorView.decorations.compute( + [highlightedLineField], + (state): DecorationSet => { + const range = state.field(highlightedLineField); + + if (range === null) { + return Decoration.none; + } + + const decorations: Range[] = []; + + for (let line = range.from; line <= range.to; line++) { + try { + const cmLine = state.doc.line(line + 1); + decorations.push(highlightLineDecoration.range(cmLine.from)); + } catch {} + } + + return Decoration.set(decorations); + }, + ); + + const clickableLineNumbers = lineNumbers({ + domEventHandlers: { + click(view, line) { + const lineNum = view.state.doc.lineAt(line.from).number - 1; + const current = view.state.field(highlightedLineField); + const isSingleSelected = + current !== null && current.from === lineNum && current.to === lineNum; + view.dispatch({ + effects: setHighlightedLine.of( + isSingleSelected ? null : {from: lineNum, to: lineNum}, + ), + }); + options?.onLineClick?.(lineNum); + return true; + }, + }, + }); + + return [ + highlightedLineField, + highlightLineDecorations, + highlightGutterDecoration, + clickableLineNumbers, + highlightLineTheme, + ]; +} diff --git a/packages/editor/src/markup/codemirror/line-highlight/index.ts b/packages/editor/src/markup/codemirror/line-highlight/index.ts new file mode 100644 index 000000000..e7f890a0c --- /dev/null +++ b/packages/editor/src/markup/codemirror/line-highlight/index.ts @@ -0,0 +1,3 @@ +export type {LineRange} from './types'; +export type {LineHighlightOptions} from './extension'; +export {lineHighlight, setHighlightedLine} from './extension'; diff --git a/packages/editor/src/markup/codemirror/line-highlight/types.ts b/packages/editor/src/markup/codemirror/line-highlight/types.ts new file mode 100644 index 000000000..1640b84dc --- /dev/null +++ b/packages/editor/src/markup/codemirror/line-highlight/types.ts @@ -0,0 +1,17 @@ +export interface LineRange { + from: number; + to: number; +} + +export interface MarkupLineNumbersConfig { + /** Show line numbers in the gutter. Default: false */ + enabled?: boolean; + /** Enable line highlighting (clickable line numbers + highlight decoration). Default: false */ + highlightLines?: boolean; + /** Initial line range to highlight on mount (0-based, inclusive) */ + initialSelectedLines?: LineRange; + /** Called when user clicks on a line number (only when highlightLines is true). 0-based line number. */ + onLineClick?: (line: number) => void; + /** 0-based line number to scroll to on mount in markup mode */ + scrollToLine?: number; +}