From 4a5fbc37b1e766f831506e1e7cacdc15a7aa2075 Mon Sep 17 00:00:00 2001 From: Victor Ilyushchenko Date: Tue, 17 Dec 2024 16:41:22 +0300 Subject: [PATCH] basic indentation support in code blocks Signed-off-by: Victor Ilyushchenko --- .../src/components/extension/indent.ts | 152 ++++++++++++++++++ .../src/kits/editor-kit.ts | 9 ++ 2 files changed, 161 insertions(+) create mode 100644 plugins/text-editor-resources/src/components/extension/indent.ts diff --git a/plugins/text-editor-resources/src/components/extension/indent.ts b/plugins/text-editor-resources/src/components/extension/indent.ts new file mode 100644 index 0000000000..aca37f6a1a --- /dev/null +++ b/plugins/text-editor-resources/src/components/extension/indent.ts @@ -0,0 +1,152 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { Extension } from '@tiptap/core' +import { type Node } from '@tiptap/pm/model' +import { type EditorState, TextSelection, type Transaction } from '@tiptap/pm/state' + +export interface IndendOptions { + indentUnit: string + allowedNodes: string[] +} + +export const indentExtensionOptions: IndendOptions = { + indentUnit: ' ', + allowedNodes: ['mermaid', 'codeBlock'] +} + +export const IndentExtension = Extension.create({ + name: 'huly-indent', + addKeyboardShortcuts () { + return { + Tab: ({ editor }) => { + if (!editor.isEditable) return false + return this.editor.commands.command(({ state, dispatch }) => { + return dispatch?.(adjustIndent(state, 1, this.options)) + }) + }, + 'Shift-Tab': ({ editor }) => { + if (!editor.isEditable) return false + return this.editor.commands.command(({ state, dispatch }) => { + return dispatch?.(adjustIndent(state, -1, this.options)) + }) + } + } + } +}) + +export function adjustIndent (state: EditorState, direction: -1 | 1, options: IndendOptions): Transaction | undefined { + const { selection } = state + if (selection instanceof TextSelection) { + return adjustSelectionIndent(state, selection, direction, options) + } +} + +export function adjustSelectionIndent ( + state: EditorState, + selection: TextSelection, + direction: -1 | 1, + options: IndendOptions +): Transaction | undefined { + const ranges = getLinesInSelection(state, selection).filter((range) => + options.allowedNodes.some((n) => n === range.node.type.name) + ) + + if (ranges.length === 0) return + + const { indentUnit } = options + + const indentLevelOffset = (pos: number, direction: -1 | 1): number => { + const unitSize = indentUnit.length + const levelAdjustment = direction === -1 && pos % unitSize !== 0 ? 0 : direction + const indentPos = Math.floor((pos + levelAdjustment * unitSize) / unitSize) * unitSize + return indentPos - pos + } + + const tr = state.tr + + if (ranges.length === 1) { + const range = ranges[0] + const withinIndent = selection.from >= range.from && selection.to <= range.from + range.indent && range.indent > 0 + if (!withinIndent && direction > 0) { + const indentOffset = indentLevelOffset(selection.from - range.from, direction) + tr.insertText(indentUnit.slice(0, indentOffset), selection.from, selection.to) + return + } + } + + let insertionOffset = 0 + for (const range of ranges) { + if (direction > 0 ? range.text === '' : range.indent === 0) { + continue + } + const indentOffset = indentLevelOffset(range.indent, direction) + const from = range.from + insertionOffset + if (indentOffset > 0) { + tr.insertText(indentUnit.slice(0, indentOffset), from) + } else { + tr.insertText('', from, from - indentOffset) + } + insertionOffset += indentOffset + } + + tr.setSelection(selection.map(tr.doc, tr.mapping)) + + return tr +} + +function countLeadingSpace (str: string): number { + const match = str.match(/^(\s*)/) + return match !== null ? match[0].length : 0 +} + +interface LineRange { + node: Node + text: string + from: number + indent: number + to: number +} + +function getLinesInSelection (state: EditorState, selection: TextSelection): LineRange[] { + const { from, to } = selection // Selection start and end positions + const ranges: LineRange[] = [] + + state.doc.nodesBetween(from, to, (node: Node, pos: number) => { + if (!node.isTextblock) return + + let currentPos = pos + 1 + const lines = node.textContent.split('\n') + + for (const line of lines) { + const lineStart = currentPos + const lineEnd = currentPos + line.length + + if (lineStart <= to && lineEnd >= from) { + ranges.push({ + node, + from: lineStart, + indent: countLeadingSpace(line), + to: lineEnd, + text: line + }) + } + + currentPos = lineEnd + 1 + } + }) + + return ranges +} diff --git a/plugins/text-editor-resources/src/kits/editor-kit.ts b/plugins/text-editor-resources/src/kits/editor-kit.ts index c40df51d78..4258539547 100644 --- a/plugins/text-editor-resources/src/kits/editor-kit.ts +++ b/plugins/text-editor-resources/src/kits/editor-kit.ts @@ -37,6 +37,7 @@ import { Table, TableCell, TableRow } from '../components/extension/table' import { DefaultKit, type DefaultKitOptions } from './default-kit' import { MermaidExtension, type MermaidOptions, mermaidOptions } from '../components/extension/mermaid' import { DrawingBoardExtension, type DrawingBoardOptions } from '../components/extension/drawingBoard' +import { type IndendOptions, IndentExtension, indentExtensionOptions } from '../components/extension/indent' export interface EditorKitOptions extends DefaultKitOptions { history?: false @@ -53,6 +54,7 @@ export interface EditorKitOptions extends DefaultKitOptions { | false drawingBoard?: DrawingBoardOptions | false mermaid?: MermaidOptions | false + indent?: IndendOptions | false mode?: 'full' | 'compact' note?: NoteOptions | false submit?: SubmitOptions | false @@ -260,6 +262,13 @@ async function buildEditorKit (): Promise> { staticKitExtensions.push([850, MermaidExtension.configure(this.options.mermaid ?? mermaidOptions)]) } + if (this.options.indent !== false) { + staticKitExtensions.push([ + 860, + IndentExtension.configure(this.options.indent ?? indentExtensionOptions) + ]) + } + if (this.options.toolbar !== false) { staticKitExtensions.push([ 900,