import Plugin from '@ckeditor/ckeditor5-core/src/plugin' import { TwoStepCaretMovement, inlineHighlight } from 'ckeditor5/src/typing' import AttributeCommand from '@ckeditor/ckeditor5-basic-styles/src/attributecommand' import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect' const HIGHLIGHT_CLASS = 'ck-link_selected' export default class ContextedLinkEditing extends Plugin { init() { this._defineSchema() // ADDED this._defineConverters() // ADDED this._addContextedKeyHandler() const twoStepCaretMovementPlugin = this.editor.plugins.get(TwoStepCaretMovement) twoStepCaretMovementPlugin.registerAttribute('contextedLink') inlineHighlight(this.editor, 'contextedLink', 'a', HIGHLIGHT_CLASS) this.editor.commands.add('autocomplete', new AttributeCommand(this.editor, 'autocomplete')) } afterInit() { this._addAutocomplete() } _defineSchema() { // ADDED const schema = this.editor.model.schema // Extend the text node's schema to accept the abbreviation attribute. schema.extend('$text', { allowAttributes: ['contextedLink', 'autocomplete'] }) } _defineConverters() { // ADDED const conversion = this.editor.conversion // Conversion from a model attribute to a view element. conversion.for('downcast').attributeToElement({ model: 'contextedLink', // Callback function provides access to the model attribute value // and the DowncastWriter. view: (modelAttributeValue, conversionApi) => { const { writer } = conversionApi return writer.createAttributeElement('a', { 'data-contexted-link': modelAttributeValue }) } }) conversion.for('upcast').elementToAttribute({ view: { name: 'a', key: 'data-contexted-link' }, model: { key: 'contextedLink' }, converterPriority: 'high' }) } _addAutocomplete() { // Copied from: node_modules/@ckeditor/ckeditor5-autoformat/src/inlineautoformatediting.js const editor = this.editor let showAutocomplete = false editor.model.document.on('change', (_, batch) => { if (batch.isUndo || !batch.isLocal) return const model = editor.model const selection = model.document.selection // Do nothing if selection is not collapsed. if (!selection.isCollapsed) return const changes = Array.from(model.document.differ.getChanges()) const entry = changes[0] // Typing is represented by only a single change. if ( changes.length != 1 || (entry.type !== 'insert' && entry.type !== 'remove') || (entry.name != '$text' && entry.name != 'paragraph') || entry.length != 1 ) { return } const focus = selection.focus const block = focus?.parent if (!block || !focus) return const { text, range } = getTextAfterCode( model.createRange(model.createPositionAt(block, 0), focus), model ) const inputText = (text as string).split(']]').slice(-1)[0] const autocompleteText = (inputText as string).match(/(?<=\[\[).*/g) const cursorNodes = [focus.textNode, focus.nodeBefore, focus.nodeAfter] const autocompleteNode: any = cursorNodes.find((node) => ['contextedLink', 'autocomplete'].some((attribute) => node?.hasAttribute(attribute)) ) if (Boolean(autocompleteText) !== Boolean(autocompleteNode)) { editor.execute('autocomplete') } const autocompleteActive = ['contextedLink', 'autocomplete'].some((attribute) => { return editor.model.document.selection.hasAttribute(attribute) }) showAutocomplete = autocompleteActive fireAutocompleteEvent(editor, showAutocomplete, autocompleteNode) const regexFormat = /(\[\[)([^[]+?)(\]\])$/g let result const format: Array[] = [] while ((result = regexFormat.exec(text as string)) !== null) { if (result && result.length < 4) { break } let index = result.index const { '1': leftDel, '2': content, '3': rightDel } = result // Real matched string - there might be some non-capturing groups so we need to recalculate starting index. const found = leftDel + content + rightDel index += result[0].length - found.length format.push([index + leftDel.length, index + leftDel.length + content.length]) } model.enqueueChange((writer) => { const rangesToFormat = format.map((array) => model.createRange( range.start.getShiftedBy(array[0]), range.start.getShiftedBy(array[1]) ) ) const validRanges = editor.model.schema.getValidRanges( rangesToFormat, 'contextedLink' ) for (const range of validRanges) { for (const item of range.getItems()) { if ((item as any).data) { writer.setAttribute('contextedLink', true, range) } } } }) }) } _addContextedKeyHandler() { const editor = this.editor const viewDocument = editor.editing.view.document viewDocument.on( 'keydown', (evt, data) => { const { keyCode } = data const keyCodesCycle = [38, 40] // Up, Down const keyCodesConfirm = [13] // Enter const keyCodesCancel = [27] // Escape if (keyCodesCancel.includes(keyCode)) { fireAutocompleteEvent(editor, false) } const keyCodes = [...keyCodesConfirm, ...keyCodesCycle] const selection = editor.model.document.selection const selectionInContextedLink = ['contextedLink', 'autocomplete'].some( (attribute) => selection.hasAttribute(attribute) ) if (selectionInContextedLink && keyCodes.includes(keyCode)) { if (selection.hasAttribute('contextedLink')) { const autocompleteNode = [ selection.focus?.nodeBefore, selection.focus?.textNode, selection.focus?.nodeAfter ].find((node) => Boolean(node)) fireAutocompleteEvent(editor, true, autocompleteNode) } this.editor.model.document.fire('contextedKeypress', { keyCode }) data.preventDefault() evt.stop() } }, { priority: 'highest' } ) } } function getNodePosition(editor: any, modelPosition: any) { try { const mapper = editor.editing.mapper const viewPosition = mapper.toViewPosition(modelPosition) const viewRange = editor.editing.view.createRange(viewPosition) const domConverter = editor.editing.view.domConverter const rangeRects = Rect.getDomRangeRects(domConverter.viewRangeToDom(viewRange)).pop() return rangeRects } catch (e) { console.log(e) } } // function testOutputToRanges(start: any, arrays: any[], model: any) { // return arrays // .filter((array) => array[0] !== undefined && array[1] !== undefined) // .map((array) => { // return model.createRange( // start.getShiftedBy(array[0]), // start.getShiftedBy(array[1]) // ) // }) // } function getTextAfterCode(range: any, model: any) { let start = range.start const text = Array.from(range.getItems()).reduce((rangeText: any, node: any) => { // Trim text to a last occurrence of an inline element and update range start. if (!(node.is('$text') || node.is('$textProxy')) || node.getAttribute('code')) { start = model.createPositionAfter(node) return '' } return rangeText + node.data }, '') return { text, range: model.createRange(start, range.end) } } function fireAutocompleteEvent(editor: any, show: boolean, autocompleteNode?: any) { let event: AutocompleteEvent if (show && autocompleteNode) { const view = editor.editing.view const viewPosition = view.document.selection.focus const viewNode = viewPosition?.parent.parent const domElement = viewNode ? (view.domConverter.mapViewToDom(viewNode) as HTMLElement) : undefined event = { position: getNodePosition( editor, editor.model.createPositionFromPath( autocompleteNode.root, autocompleteNode.getPath() ) ), autocompleteText: autocompleteNode.data, domElement, show: true } } else { event = { show: false } } editor.model.document.fire('contextedLinkAutocomplete', event) }