tabSize: 4
This commit is contained in:
@@ -6,178 +6,184 @@ 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
|
||||
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
|
||||
// Extend the text node's schema to accept the abbreviation attribute.
|
||||
schema.extend('$text', {
|
||||
allowAttributes: ['contextedLink', 'autocomplete']
|
||||
})
|
||||
}
|
||||
})
|
||||
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
|
||||
}
|
||||
}
|
||||
_defineConverters() {
|
||||
// ADDED
|
||||
const conversion = this.editor.conversion
|
||||
|
||||
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))
|
||||
)
|
||||
// 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
|
||||
|
||||
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<number>[] = []
|
||||
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)
|
||||
return writer.createAttributeElement('a', {
|
||||
'data-contexted-link': modelAttributeValue
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
_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)
|
||||
})
|
||||
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<number>[] = []
|
||||
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' }
|
||||
)
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
@@ -192,40 +198,43 @@ function getNodePosition(editor: any, modelPosition: any) {
|
||||
// }
|
||||
|
||||
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) }
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
} else {
|
||||
event = {
|
||||
show: false
|
||||
}
|
||||
}
|
||||
editor.model.document.fire('contextedLinkAutocomplete', event)
|
||||
editor.model.document.fire('contextedLinkAutocomplete', event)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user