// // 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 { backtickInputRegex, codeBlockOptions, tildeInputRegex } from '@hcengineering/text' import { DropdownLabelsPopup, getEventPositionElement, showPopup } from '@hcengineering/ui' import { isActive, textblockTypeInputRule } from '@tiptap/core' import { type CodeBlockLowlightOptions, CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight' import { type Node as ProseMirrorNode } from '@tiptap/pm/model' import { Plugin, PluginKey } from '@tiptap/pm/state' import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view' import { common, createLowlight } from 'lowlight' import { isChangeEditable } from './editable' type Lowlight = ReturnType const chevronSvg = ` ` export const codeBlockHighlightOptions: CodeBlockLowlightOptions = { ...codeBlockOptions, lowlight: createLowlight(common) } export const CodeBlockHighlighExtension = CodeBlockLowlight.extend({ addCommands () { return { setCodeBlock: (attributes) => ({ commands }) => { return commands.setNode(this.name, attributes) }, toggleCodeBlock: (attributes) => ({ chain, commands, state }) => { const { from, to } = state.selection // merge multiple paragraphs into codeblock if (!isActive(state, this.name) && !state.selection.empty) { let hasParagraphsOnlySelected = true const textArr: string[] = [] state.doc.nodesBetween(from, to, (node, pos) => { if (node.isInline) { return false } if (node.type.name !== 'paragraph') { if (pos + 1 <= from && pos + node.nodeSize - 1 >= to) { // skip nodes outside of the selected range return false } else { // cannot merge non-paragraph nodes inside selection hasParagraphsOnlySelected = false return false } } else { const selectedText = (node.textContent ?? '').slice( pos + 1 > from ? 0 : from - pos - 1, pos + node.nodeSize - 1 < to ? node.nodeSize - 1 : to - pos - 1 ) textArr.push(selectedText ?? '') } }) if (hasParagraphsOnlySelected && textArr.length > 1) { return chain() .command(({ state, tr }) => { tr.replaceRangeWith(from, to, this.type.create(attributes, state.schema.text(textArr.join('\n')))) return true }) .setTextSelection({ from: from + 2, to: from + 2 }) .run() } } return commands.toggleNode(this.name, 'paragraph', attributes) } } }, addInputRules () { return [ textblockTypeInputRule({ find: backtickInputRegex, type: this.type }), textblockTypeInputRule({ find: tildeInputRegex, type: this.type }) ] }, addProseMirrorPlugins () { return [...(this.parent?.() ?? []), LanguageSelector(this.options)] } }) export function LanguageSelector (options: CodeBlockLowlightOptions): Plugin { return new Plugin({ key: new PluginKey('codeblock-language-selector'), props: { decorations (state) { return this.getState(state) } }, state: { init (config, state) { return createDecorations(state.doc, options) }, apply (tr, prev) { if (tr.docChanged || isChangeEditable(tr)) { return createDecorations(tr.doc, options) } return prev } } }) } function createDecorations (doc: ProseMirrorNode, options: CodeBlockLowlightOptions): DecorationSet { const decorations: Decoration[] = [] doc.descendants((node, pos) => { if (node.type.name === CodeBlockLowlight.name) { decorations.push( Decoration.widget(pos + 1, (view) => { const button = createLangButton(node.attrs.language) if (view.editable) { button.addEventListener('click', (e) => { e.preventDefault() e.stopPropagation() handleLangButtonClick(e, node, pos, view, options) }) } else { button.disabled = true } return button }) ) } }) return DecorationSet.create(doc, decorations) } function createLangButton (language: string | null): HTMLButtonElement { const button = document.createElement('button') button.className = 'antiButton link-bordered small sh-no-shape bs-none gap-medium iconR' button.style.position = 'absolute' button.style.top = '0.375rem' button.style.right = '0.375rem' button.style.zIndex = '1' const label = document.createElement('span') label.className = 'overflow-label label disabled mr-2' label.textContent = language ?? 'auto' const icon = document.createElement('div') icon.innerHTML = chevronSvg button.append(label, icon) return button } function handleLangButtonClick ( evt: MouseEvent, node: ProseMirrorNode, pos: number, view: EditorView, options: CodeBlockLowlightOptions ): void { const language = node.attrs.language const lowlight: Lowlight = options.lowlight const items = lowlight.listLanguages().map((language) => ({ id: language, label: language })) showPopup( DropdownLabelsPopup, { items, selected: language }, getEventPositionElement(evt), (result) => { if (result != null) { const tr = view.state.tr.setNodeAttribute(pos, 'language', result) view.dispatch(tr) } } ) }