mirror of https://github.com/grafana/grafana
Chore: Update Slate to 0.47.8 (#19197)
* Chore: Update Slate to 0.47.8 Closes #17430pull/19312/head
parent
918cb78092
commit
68d6da77da
@ -0,0 +1 @@ |
||||
export { SlatePrism } from './slate-prism'; |
@ -0,0 +1,3 @@ |
||||
const TOKEN_MARK = 'prism-token'; |
||||
|
||||
export default TOKEN_MARK; |
@ -0,0 +1,160 @@ |
||||
import Prism from 'prismjs'; |
||||
import { Block, Text, Decoration } from 'slate'; |
||||
import { Plugin } from '@grafana/slate-react'; |
||||
import Options, { OptionsFormat } from './options'; |
||||
import TOKEN_MARK from './TOKEN_MARK'; |
||||
|
||||
/** |
||||
* A Slate plugin to highlight code syntax. |
||||
*/ |
||||
export function SlatePrism(optsParam: OptionsFormat = {}): Plugin { |
||||
const opts: Options = new Options(optsParam); |
||||
|
||||
return { |
||||
decorateNode: (node, editor, next) => { |
||||
if (!opts.onlyIn(node)) { |
||||
return next(); |
||||
} |
||||
return decorateNode(opts, Block.create(node as Block)); |
||||
}, |
||||
|
||||
renderDecoration: (props, editor, next) => |
||||
opts.renderDecoration( |
||||
{ |
||||
children: props.children, |
||||
decoration: props.decoration, |
||||
}, |
||||
editor as any, |
||||
next |
||||
), |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Returns the decoration for a node |
||||
*/ |
||||
function decorateNode(opts: Options, block: Block) { |
||||
const grammarName = opts.getSyntax(block); |
||||
const grammar = Prism.languages[grammarName]; |
||||
if (!grammar) { |
||||
// Grammar not loaded
|
||||
return []; |
||||
} |
||||
|
||||
// Tokenize the whole block text
|
||||
const texts = block.getTexts(); |
||||
const blockText = texts.map(text => text && text.getText()).join('\n'); |
||||
const tokens = Prism.tokenize(blockText, grammar); |
||||
|
||||
// The list of decorations to return
|
||||
const decorations: Decoration[] = []; |
||||
let textStart = 0; |
||||
let textEnd = 0; |
||||
|
||||
texts.forEach(text => { |
||||
textEnd = textStart + text!.getText().length; |
||||
|
||||
let offset = 0; |
||||
function processToken(token: string | Prism.Token, accu?: string | number) { |
||||
if (typeof token === 'string') { |
||||
if (accu) { |
||||
const decoration = createDecoration({ |
||||
text: text!, |
||||
textStart, |
||||
textEnd, |
||||
start: offset, |
||||
end: offset + token.length, |
||||
className: `prism-token token ${accu}`, |
||||
block, |
||||
}); |
||||
if (decoration) { |
||||
decorations.push(decoration); |
||||
} |
||||
} |
||||
offset += token.length; |
||||
} else { |
||||
accu = `${accu} ${token.type} ${token.alias || ''}`; |
||||
|
||||
if (typeof token.content === 'string') { |
||||
const decoration = createDecoration({ |
||||
text: text!, |
||||
textStart, |
||||
textEnd, |
||||
start: offset, |
||||
end: offset + token.content.length, |
||||
className: `prism-token token ${accu}`, |
||||
block, |
||||
}); |
||||
if (decoration) { |
||||
decorations.push(decoration); |
||||
} |
||||
|
||||
offset += token.content.length; |
||||
} else { |
||||
// When using token.content instead of token.matchedStr, token can be deep
|
||||
for (let i = 0; i < token.content.length; i += 1) { |
||||
// @ts-ignore
|
||||
processToken(token.content[i], accu); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
tokens.forEach(processToken); |
||||
textStart = textEnd + 1; // account for added `\n`
|
||||
}); |
||||
|
||||
return decorations; |
||||
} |
||||
|
||||
/** |
||||
* Return a decoration range for the given text. |
||||
*/ |
||||
function createDecoration({ |
||||
text, |
||||
textStart, |
||||
textEnd, |
||||
start, |
||||
end, |
||||
className, |
||||
block, |
||||
}: { |
||||
text: Text; // The text being decorated
|
||||
textStart: number; // Its start position in the whole text
|
||||
textEnd: number; // Its end position in the whole text
|
||||
start: number; // The position in the whole text where the token starts
|
||||
end: number; // The position in the whole text where the token ends
|
||||
className: string; // The prism token classname
|
||||
block: Block; |
||||
}): Decoration | null { |
||||
if (start >= textEnd || end <= textStart) { |
||||
// Ignore, the token is not in the text
|
||||
return null; |
||||
} |
||||
|
||||
// Shrink to this text boundaries
|
||||
start = Math.max(start, textStart); |
||||
end = Math.min(end, textEnd); |
||||
|
||||
// Now shift offsets to be relative to this text
|
||||
start -= textStart; |
||||
end -= textStart; |
||||
|
||||
const myDec = block.createDecoration({ |
||||
object: 'decoration', |
||||
anchor: { |
||||
key: text.key, |
||||
offset: start, |
||||
object: 'point', |
||||
}, |
||||
focus: { |
||||
key: text.key, |
||||
offset: end, |
||||
object: 'point', |
||||
}, |
||||
type: TOKEN_MARK, |
||||
data: { className }, |
||||
}); |
||||
|
||||
return myDec; |
||||
} |
@ -0,0 +1,77 @@ |
||||
import React from 'react'; |
||||
import { Mark, Node, Decoration } from 'slate'; |
||||
import { Editor } from '@grafana/slate-react'; |
||||
import { Record } from 'immutable'; |
||||
|
||||
import TOKEN_MARK from './TOKEN_MARK'; |
||||
|
||||
export interface OptionsFormat { |
||||
// Determine which node should be highlighted
|
||||
onlyIn?: (node: Node) => boolean; |
||||
// Returns the syntax for a node that should be highlighted
|
||||
getSyntax?: (node: Node) => string; |
||||
// Render a highlighting mark in a highlighted node
|
||||
renderMark?: ({ mark, children }: { mark: Mark; children: React.ReactNode }) => void | React.ReactNode; |
||||
} |
||||
|
||||
/** |
||||
* Default filter for code blocks |
||||
*/ |
||||
function defaultOnlyIn(node: Node): boolean { |
||||
return node.object === 'block' && node.type === 'code_block'; |
||||
} |
||||
|
||||
/** |
||||
* Default getter for syntax |
||||
*/ |
||||
function defaultGetSyntax(node: Node): string { |
||||
return 'javascript'; |
||||
} |
||||
|
||||
/** |
||||
* Default rendering for decorations |
||||
*/ |
||||
function defaultRenderDecoration( |
||||
props: { children: React.ReactNode; decoration: Decoration }, |
||||
editor: Editor, |
||||
next: () => any |
||||
): void | React.ReactNode { |
||||
const { decoration } = props; |
||||
if (decoration.type !== TOKEN_MARK) { |
||||
return next(); |
||||
} |
||||
|
||||
const className = decoration.data.get('className'); |
||||
return <span className={className}>{props.children}</span>; |
||||
} |
||||
|
||||
/** |
||||
* The plugin options |
||||
*/ |
||||
class Options |
||||
extends Record({ |
||||
onlyIn: defaultOnlyIn, |
||||
getSyntax: defaultGetSyntax, |
||||
renderDecoration: defaultRenderDecoration, |
||||
}) |
||||
implements OptionsFormat { |
||||
readonly onlyIn!: (node: Node) => boolean; |
||||
readonly getSyntax!: (node: Node) => string; |
||||
readonly renderDecoration!: ( |
||||
{ |
||||
decoration, |
||||
children, |
||||
}: { |
||||
decoration: Decoration; |
||||
children: React.ReactNode; |
||||
}, |
||||
editor: Editor, |
||||
next: () => any |
||||
) => void | React.ReactNode; |
||||
|
||||
constructor(props: OptionsFormat) { |
||||
super(props); |
||||
} |
||||
} |
||||
|
||||
export default Options; |
@ -1,39 +0,0 @@ |
||||
// @ts-ignore
|
||||
import Plain from 'slate-plain-serializer'; |
||||
|
||||
import BracesPlugin from './braces'; |
||||
|
||||
declare global { |
||||
interface Window { |
||||
KeyboardEvent: any; |
||||
} |
||||
} |
||||
|
||||
describe('braces', () => { |
||||
const handler = BracesPlugin().onKeyDown; |
||||
|
||||
it('adds closing braces around empty value', () => { |
||||
const change = Plain.deserialize('').change(); |
||||
const event = new window.KeyboardEvent('keydown', { key: '(' }); |
||||
handler(event, change); |
||||
expect(Plain.serialize(change.value)).toEqual('()'); |
||||
}); |
||||
|
||||
it('removes closing brace when opening brace is removed', () => { |
||||
const change = Plain.deserialize('time()').change(); |
||||
let event; |
||||
change.move(5); |
||||
event = new window.KeyboardEvent('keydown', { key: 'Backspace' }); |
||||
handler(event, change); |
||||
expect(Plain.serialize(change.value)).toEqual('time'); |
||||
}); |
||||
|
||||
it('keeps closing brace when opening brace is removed and inner values exist', () => { |
||||
const change = Plain.deserialize('time(value)').change(); |
||||
let event; |
||||
change.move(5); |
||||
event = new window.KeyboardEvent('keydown', { key: 'Backspace' }); |
||||
const handled = handler(event, change); |
||||
expect(handled).toBeFalsy(); |
||||
}); |
||||
}); |
@ -0,0 +1,40 @@ |
||||
import React from 'react'; |
||||
import Plain from 'slate-plain-serializer'; |
||||
import { Editor } from '@grafana/slate-react'; |
||||
import { shallow } from 'enzyme'; |
||||
import BracesPlugin from './braces'; |
||||
|
||||
declare global { |
||||
interface Window { |
||||
KeyboardEvent: any; |
||||
} |
||||
} |
||||
|
||||
describe('braces', () => { |
||||
const handler = BracesPlugin().onKeyDown; |
||||
const nextMock = () => {}; |
||||
|
||||
it('adds closing braces around empty value', () => { |
||||
const value = Plain.deserialize(''); |
||||
const editor = shallow<Editor>(<Editor value={value} />); |
||||
const event = new window.KeyboardEvent('keydown', { key: '(' }); |
||||
handler(event as Event, editor.instance() as any, nextMock); |
||||
expect(Plain.serialize(editor.instance().value)).toEqual('()'); |
||||
}); |
||||
|
||||
it('removes closing brace when opening brace is removed', () => { |
||||
const value = Plain.deserialize('time()'); |
||||
const editor = shallow<Editor>(<Editor value={value} />); |
||||
const event = new window.KeyboardEvent('keydown', { key: 'Backspace' }); |
||||
handler(event as Event, editor.instance().moveForward(5) as any, nextMock); |
||||
expect(Plain.serialize(editor.instance().value)).toEqual('time'); |
||||
}); |
||||
|
||||
it('keeps closing brace when opening brace is removed and inner values exist', () => { |
||||
const value = Plain.deserialize('time(value)'); |
||||
const editor = shallow<Editor>(<Editor value={value} />); |
||||
const event = new window.KeyboardEvent('keydown', { key: 'Backspace' }); |
||||
const handled = handler(event as Event, editor.instance().moveForward(5) as any, nextMock); |
||||
expect(handled).toBeFalsy(); |
||||
}); |
||||
}); |
@ -1,39 +0,0 @@ |
||||
// @ts-ignore
|
||||
import Plain from 'slate-plain-serializer'; |
||||
|
||||
import ClearPlugin from './clear'; |
||||
|
||||
describe('clear', () => { |
||||
const handler = ClearPlugin().onKeyDown; |
||||
|
||||
it('does not change the empty value', () => { |
||||
const change = Plain.deserialize('').change(); |
||||
const event = new window.KeyboardEvent('keydown', { |
||||
key: 'k', |
||||
ctrlKey: true, |
||||
}); |
||||
handler(event, change); |
||||
expect(Plain.serialize(change.value)).toEqual(''); |
||||
}); |
||||
|
||||
it('clears to the end of the line', () => { |
||||
const change = Plain.deserialize('foo').change(); |
||||
const event = new window.KeyboardEvent('keydown', { |
||||
key: 'k', |
||||
ctrlKey: true, |
||||
}); |
||||
handler(event, change); |
||||
expect(Plain.serialize(change.value)).toEqual(''); |
||||
}); |
||||
|
||||
it('clears from the middle to the end of the line', () => { |
||||
const change = Plain.deserialize('foo bar').change(); |
||||
change.move(4); |
||||
const event = new window.KeyboardEvent('keydown', { |
||||
key: 'k', |
||||
ctrlKey: true, |
||||
}); |
||||
handler(event, change); |
||||
expect(Plain.serialize(change.value)).toEqual('foo '); |
||||
}); |
||||
}); |
@ -0,0 +1,42 @@ |
||||
import Plain from 'slate-plain-serializer'; |
||||
import React from 'react'; |
||||
import { Editor } from '@grafana/slate-react'; |
||||
import { shallow } from 'enzyme'; |
||||
import ClearPlugin from './clear'; |
||||
|
||||
describe('clear', () => { |
||||
const handler = ClearPlugin().onKeyDown; |
||||
|
||||
it('does not change the empty value', () => { |
||||
const value = Plain.deserialize(''); |
||||
const editor = shallow<Editor>(<Editor value={value} />); |
||||
const event = new window.KeyboardEvent('keydown', { |
||||
key: 'k', |
||||
ctrlKey: true, |
||||
}); |
||||
handler(event as Event, editor.instance() as any, () => {}); |
||||
expect(Plain.serialize(editor.instance().value)).toEqual(''); |
||||
}); |
||||
|
||||
it('clears to the end of the line', () => { |
||||
const value = Plain.deserialize('foo'); |
||||
const editor = shallow<Editor>(<Editor value={value} />); |
||||
const event = new window.KeyboardEvent('keydown', { |
||||
key: 'k', |
||||
ctrlKey: true, |
||||
}); |
||||
handler(event as Event, editor.instance() as any, () => {}); |
||||
expect(Plain.serialize(editor.instance().value)).toEqual(''); |
||||
}); |
||||
|
||||
it('clears from the middle to the end of the line', () => { |
||||
const value = Plain.deserialize('foo bar'); |
||||
const editor = shallow<Editor>(<Editor value={value} />); |
||||
const event = new window.KeyboardEvent('keydown', { |
||||
key: 'k', |
||||
ctrlKey: true, |
||||
}); |
||||
handler(event as Event, editor.instance().moveForward(4) as any, () => {}); |
||||
expect(Plain.serialize(editor.instance().value)).toEqual('foo '); |
||||
}); |
||||
}); |
@ -1,22 +1,27 @@ |
||||
import { Plugin } from '@grafana/slate-react'; |
||||
import { Editor as CoreEditor } from 'slate'; |
||||
|
||||
// Clears the rest of the line after the caret
|
||||
export default function ClearPlugin() { |
||||
export default function ClearPlugin(): Plugin { |
||||
return { |
||||
onKeyDown(event: any, change: { value?: any; deleteForward?: any }) { |
||||
const { value } = change; |
||||
if (!value.isCollapsed) { |
||||
return undefined; |
||||
onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) { |
||||
const value = editor.value; |
||||
|
||||
if (value.selection.isExpanded) { |
||||
return next(); |
||||
} |
||||
|
||||
if (event.key === 'k' && event.ctrlKey) { |
||||
event.preventDefault(); |
||||
const text = value.anchorText.text; |
||||
const offset = value.anchorOffset; |
||||
const offset = value.selection.anchor.offset; |
||||
const length = text.length; |
||||
const forward = length - offset; |
||||
change.deleteForward(forward); |
||||
editor.deleteForward(forward); |
||||
return true; |
||||
} |
||||
return undefined; |
||||
|
||||
return next(); |
||||
}, |
||||
}; |
||||
} |
||||
|
@ -0,0 +1,61 @@ |
||||
import { Plugin } from '@grafana/slate-react'; |
||||
import { Editor as CoreEditor } from 'slate'; |
||||
|
||||
const getCopiedText = (textBlocks: string[], startOffset: number, endOffset: number) => { |
||||
if (!textBlocks.length) { |
||||
return undefined; |
||||
} |
||||
|
||||
const excludingLastLineLength = textBlocks.slice(0, -1).join('').length + textBlocks.length - 1; |
||||
return textBlocks.join('\n').slice(startOffset, excludingLastLineLength + endOffset); |
||||
}; |
||||
|
||||
export default function ClipboardPlugin(): Plugin { |
||||
const clipboardPlugin = { |
||||
onCopy(event: ClipboardEvent, editor: CoreEditor) { |
||||
event.preventDefault(); |
||||
|
||||
const { document, selection } = editor.value; |
||||
const { |
||||
start: { offset: startOffset }, |
||||
end: { offset: endOffset }, |
||||
} = selection; |
||||
const selectedBlocks = document |
||||
.getLeafBlocksAtRange(selection) |
||||
.toArray() |
||||
.map(block => block.text); |
||||
|
||||
const copiedText = getCopiedText(selectedBlocks, startOffset, endOffset); |
||||
if (copiedText) { |
||||
event.clipboardData.setData('Text', copiedText); |
||||
} |
||||
|
||||
return true; |
||||
}, |
||||
|
||||
onPaste(event: ClipboardEvent, editor: CoreEditor) { |
||||
event.preventDefault(); |
||||
const pastedValue = event.clipboardData.getData('Text'); |
||||
const lines = pastedValue.split('\n'); |
||||
|
||||
if (lines.length) { |
||||
editor.insertText(lines[0]); |
||||
for (const line of lines.slice(1)) { |
||||
editor.splitBlock().insertText(line); |
||||
} |
||||
} |
||||
|
||||
return true; |
||||
}, |
||||
}; |
||||
|
||||
return { |
||||
...clipboardPlugin, |
||||
onCut(event: ClipboardEvent, editor: CoreEditor) { |
||||
clipboardPlugin.onCopy(event, editor); |
||||
editor.deleteAtRange(editor.value.selection); |
||||
|
||||
return true; |
||||
}, |
||||
}; |
||||
} |
@ -0,0 +1,93 @@ |
||||
import { RangeJSON, Range as SlateRange, Editor as CoreEditor } from 'slate'; |
||||
import { Plugin } from '@grafana/slate-react'; |
||||
import { isKeyHotkey } from 'is-hotkey'; |
||||
|
||||
const isIndentLeftHotkey = isKeyHotkey('mod+['); |
||||
const isShiftTabHotkey = isKeyHotkey('shift+tab'); |
||||
const isIndentRightHotkey = isKeyHotkey('mod+]'); |
||||
|
||||
const SLATE_TAB = ' '; |
||||
|
||||
const handleTabKey = (event: KeyboardEvent, editor: CoreEditor, next: Function): void => { |
||||
const { |
||||
startBlock, |
||||
endBlock, |
||||
selection: { |
||||
start: { offset: startOffset, key: startKey }, |
||||
end: { offset: endOffset, key: endKey }, |
||||
}, |
||||
} = editor.value; |
||||
|
||||
const first = startBlock.getFirstText(); |
||||
|
||||
const startBlockIsSelected = |
||||
startOffset === 0 && startKey === first.key && endOffset === first.text.length && endKey === first.key; |
||||
|
||||
if (startBlockIsSelected || !startBlock.equals(endBlock)) { |
||||
handleIndent(editor, 'right'); |
||||
} else { |
||||
editor.insertText(SLATE_TAB); |
||||
} |
||||
}; |
||||
|
||||
const handleIndent = (editor: CoreEditor, indentDirection: 'left' | 'right') => { |
||||
const curSelection = editor.value.selection; |
||||
const selectedBlocks = editor.value.document.getLeafBlocksAtRange(curSelection).toArray(); |
||||
|
||||
if (indentDirection === 'left') { |
||||
for (const block of selectedBlocks) { |
||||
const blockWhitespace = block.text.length - block.text.trimLeft().length; |
||||
|
||||
const textKey = block.getFirstText().key; |
||||
|
||||
const rangeProperties: RangeJSON = { |
||||
anchor: { |
||||
key: textKey, |
||||
offset: blockWhitespace, |
||||
path: [], |
||||
}, |
||||
focus: { |
||||
key: textKey, |
||||
offset: blockWhitespace, |
||||
path: [], |
||||
}, |
||||
}; |
||||
|
||||
editor.deleteBackwardAtRange(SlateRange.create(rangeProperties), Math.min(SLATE_TAB.length, blockWhitespace)); |
||||
} |
||||
} else { |
||||
const { startText } = editor.value; |
||||
const textBeforeCaret = startText.text.slice(0, curSelection.start.offset); |
||||
const isWhiteSpace = /^\s*$/.test(textBeforeCaret); |
||||
|
||||
for (const block of selectedBlocks) { |
||||
editor.insertTextByKey(block.getFirstText().key, 0, SLATE_TAB); |
||||
} |
||||
|
||||
if (isWhiteSpace) { |
||||
editor.moveStartBackward(SLATE_TAB.length); |
||||
} |
||||
} |
||||
}; |
||||
|
||||
// Clears the rest of the line after the caret
|
||||
export default function IndentationPlugin(): Plugin { |
||||
return { |
||||
onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) { |
||||
if (isIndentLeftHotkey(event) || isShiftTabHotkey(event)) { |
||||
event.preventDefault(); |
||||
handleIndent(editor, 'left'); |
||||
} else if (isIndentRightHotkey(event)) { |
||||
event.preventDefault(); |
||||
handleIndent(editor, 'right'); |
||||
} else if (event.key === 'Tab') { |
||||
event.preventDefault(); |
||||
handleTabKey(event, editor, next); |
||||
} else { |
||||
return next(); |
||||
} |
||||
|
||||
return true; |
||||
}, |
||||
}; |
||||
} |
@ -0,0 +1,17 @@ |
||||
import Plain from 'slate-plain-serializer'; |
||||
import React from 'react'; |
||||
import { Editor } from '@grafana/slate-react'; |
||||
import { shallow } from 'enzyme'; |
||||
import RunnerPlugin from './runner'; |
||||
|
||||
describe('runner', () => { |
||||
const mockHandler = jest.fn(); |
||||
const handler = RunnerPlugin({ handler: mockHandler }).onKeyDown; |
||||
|
||||
it('should execute query when enter is pressed and there are no suggestions visible', () => { |
||||
const value = Plain.deserialize(''); |
||||
const editor = shallow<Editor>(<Editor value={value} />); |
||||
handler({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent, editor.instance() as any, () => {}); |
||||
expect(mockHandler).toBeCalled(); |
||||
}); |
||||
}); |
@ -0,0 +1,31 @@ |
||||
import { Plugin } from '@grafana/slate-react'; |
||||
import { Editor as CoreEditor } from 'slate'; |
||||
|
||||
import { isKeyHotkey } from 'is-hotkey'; |
||||
|
||||
const isSelectLineHotkey = isKeyHotkey('mod+l'); |
||||
|
||||
// Clears the rest of the line after the caret
|
||||
export default function SelectionShortcutsPlugin(): Plugin { |
||||
return { |
||||
onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) { |
||||
if (isSelectLineHotkey(event)) { |
||||
event.preventDefault(); |
||||
const { focusBlock, document } = editor.value; |
||||
|
||||
editor.moveAnchorToStartOfBlock(); |
||||
|
||||
const nextBlock = document.getNextBlock(focusBlock.key); |
||||
if (nextBlock) { |
||||
editor.moveFocusToStartOfNextBlock(); |
||||
} else { |
||||
editor.moveFocusToEndOfText(); |
||||
} |
||||
} else { |
||||
return next(); |
||||
} |
||||
|
||||
return true; |
||||
}, |
||||
}; |
||||
} |
@ -0,0 +1,312 @@ |
||||
import React from 'react'; |
||||
import debounce from 'lodash/debounce'; |
||||
import sortBy from 'lodash/sortBy'; |
||||
|
||||
import { Editor as CoreEditor } from 'slate'; |
||||
import { Plugin as SlatePlugin } from '@grafana/slate-react'; |
||||
import { TypeaheadOutput, CompletionItem, CompletionItemGroup } from 'app/types'; |
||||
|
||||
import { QueryField, TypeaheadInput } from '../QueryField'; |
||||
import TOKEN_MARK from '@grafana/ui/src/slate-plugins/slate-prism/TOKEN_MARK'; |
||||
import { TypeaheadWithTheme, Typeahead } from '../Typeahead'; |
||||
|
||||
import { makeFragment } from '@grafana/ui'; |
||||
|
||||
export const TYPEAHEAD_DEBOUNCE = 100; |
||||
|
||||
export interface SuggestionsState { |
||||
groupedItems: CompletionItemGroup[]; |
||||
typeaheadPrefix: string; |
||||
typeaheadContext: string; |
||||
typeaheadText: string; |
||||
} |
||||
|
||||
let state: SuggestionsState = { |
||||
groupedItems: [], |
||||
typeaheadPrefix: '', |
||||
typeaheadContext: '', |
||||
typeaheadText: '', |
||||
}; |
||||
|
||||
export default function SuggestionsPlugin({ |
||||
onTypeahead, |
||||
cleanText, |
||||
onWillApplySuggestion, |
||||
syntax, |
||||
portalOrigin, |
||||
component, |
||||
}: { |
||||
onTypeahead: (typeahead: TypeaheadInput) => Promise<TypeaheadOutput>; |
||||
cleanText?: (text: string) => string; |
||||
onWillApplySuggestion?: (suggestion: string, state: SuggestionsState) => string; |
||||
syntax?: string; |
||||
portalOrigin: string; |
||||
component: QueryField; // Need to attach typeaheadRef here
|
||||
}): SlatePlugin { |
||||
return { |
||||
onBlur: (event, editor, next) => { |
||||
state = { |
||||
...state, |
||||
groupedItems: [], |
||||
}; |
||||
|
||||
return next(); |
||||
}, |
||||
|
||||
onClick: (event, editor, next) => { |
||||
state = { |
||||
...state, |
||||
groupedItems: [], |
||||
}; |
||||
|
||||
return next(); |
||||
}, |
||||
|
||||
onKeyDown: (event: KeyboardEvent, editor, next) => { |
||||
const currentSuggestions = state.groupedItems; |
||||
|
||||
const hasSuggestions = currentSuggestions.length; |
||||
|
||||
switch (event.key) { |
||||
case 'Escape': { |
||||
if (hasSuggestions) { |
||||
event.preventDefault(); |
||||
|
||||
state = { |
||||
...state, |
||||
groupedItems: [], |
||||
}; |
||||
|
||||
// Bogus edit to re-render editor
|
||||
return editor.insertText(''); |
||||
} |
||||
|
||||
break; |
||||
} |
||||
|
||||
case 'ArrowDown': |
||||
case 'ArrowUp': |
||||
if (hasSuggestions) { |
||||
event.preventDefault(); |
||||
component.typeaheadRef.moveMenuIndex(event.key === 'ArrowDown' ? 1 : -1); |
||||
return; |
||||
} |
||||
|
||||
break; |
||||
|
||||
case 'Enter': |
||||
case 'Tab': { |
||||
if (hasSuggestions) { |
||||
event.preventDefault(); |
||||
|
||||
component.typeaheadRef.insertSuggestion(); |
||||
return handleTypeahead(event, editor, onTypeahead, cleanText); |
||||
} |
||||
|
||||
break; |
||||
} |
||||
|
||||
default: { |
||||
handleTypeahead(event, editor, onTypeahead, cleanText); |
||||
break; |
||||
} |
||||
} |
||||
|
||||
return next(); |
||||
}, |
||||
|
||||
commands: { |
||||
selectSuggestion: (editor: CoreEditor, suggestion: CompletionItem): CoreEditor => { |
||||
const suggestions = state.groupedItems; |
||||
if (!suggestions || !suggestions.length) { |
||||
return editor; |
||||
} |
||||
|
||||
// @ts-ignore
|
||||
return editor.applyTypeahead(suggestion); |
||||
}, |
||||
|
||||
applyTypeahead: (editor: CoreEditor, suggestion: CompletionItem): CoreEditor => { |
||||
let suggestionText = suggestion.insertText || suggestion.label; |
||||
|
||||
const preserveSuffix = suggestion.kind === 'function'; |
||||
const move = suggestion.move || 0; |
||||
|
||||
const { typeaheadPrefix, typeaheadText, typeaheadContext } = state; |
||||
|
||||
if (onWillApplySuggestion) { |
||||
suggestionText = onWillApplySuggestion(suggestionText, { |
||||
groupedItems: state.groupedItems, |
||||
typeaheadContext, |
||||
typeaheadPrefix, |
||||
typeaheadText, |
||||
}); |
||||
} |
||||
|
||||
// Remove the current, incomplete text and replace it with the selected suggestion
|
||||
const backward = suggestion.deleteBackwards || typeaheadPrefix.length; |
||||
const text = cleanText ? cleanText(typeaheadText) : typeaheadText; |
||||
const suffixLength = text.length - typeaheadPrefix.length; |
||||
const offset = typeaheadText.indexOf(typeaheadPrefix); |
||||
const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText); |
||||
const forward = midWord && !preserveSuffix ? suffixLength + offset : 0; |
||||
|
||||
// If new-lines, apply suggestion as block
|
||||
if (suggestionText.match(/\n/)) { |
||||
const fragment = makeFragment(suggestionText); |
||||
return editor |
||||
.deleteBackward(backward) |
||||
.deleteForward(forward) |
||||
.insertFragment(fragment) |
||||
.focus(); |
||||
} |
||||
|
||||
state = { |
||||
...state, |
||||
groupedItems: [], |
||||
}; |
||||
|
||||
return editor |
||||
.deleteBackward(backward) |
||||
.deleteForward(forward) |
||||
.insertText(suggestionText) |
||||
.moveForward(move) |
||||
.focus(); |
||||
}, |
||||
}, |
||||
|
||||
renderEditor: (props, editor, next) => { |
||||
if (editor.value.selection.isExpanded) { |
||||
return next(); |
||||
} |
||||
|
||||
const children = next(); |
||||
|
||||
return ( |
||||
<> |
||||
{children} |
||||
<TypeaheadWithTheme |
||||
menuRef={(el: Typeahead) => (component.typeaheadRef = el)} |
||||
origin={portalOrigin} |
||||
prefix={state.typeaheadPrefix} |
||||
isOpen={!!state.groupedItems.length} |
||||
groupedItems={state.groupedItems} |
||||
//@ts-ignore
|
||||
onSelectSuggestion={editor.selectSuggestion} |
||||
/> |
||||
</> |
||||
); |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
const handleTypeahead = debounce( |
||||
async ( |
||||
event: Event, |
||||
editor: CoreEditor, |
||||
onTypeahead?: (typeahead: TypeaheadInput) => Promise<TypeaheadOutput>, |
||||
cleanText?: (text: string) => string |
||||
) => { |
||||
if (!onTypeahead) { |
||||
return null; |
||||
} |
||||
|
||||
const { value } = editor; |
||||
const { selection } = value; |
||||
|
||||
// Get decorations associated with the current line
|
||||
const parentBlock = value.document.getClosestBlock(value.focusBlock.key); |
||||
const myOffset = value.selection.start.offset - 1; |
||||
const decorations = parentBlock.getDecorations(editor as any); |
||||
|
||||
const filteredDecorations = decorations |
||||
.filter( |
||||
decoration => |
||||
decoration.start.offset <= myOffset && decoration.end.offset > myOffset && decoration.type === TOKEN_MARK |
||||
) |
||||
.toArray(); |
||||
|
||||
const labelKeyDec = decorations |
||||
.filter( |
||||
decoration => |
||||
decoration.end.offset === myOffset && |
||||
decoration.type === TOKEN_MARK && |
||||
decoration.data.get('className').includes('label-key') |
||||
) |
||||
.first(); |
||||
|
||||
const labelKey = labelKeyDec && value.focusText.text.slice(labelKeyDec.start.offset, labelKeyDec.end.offset); |
||||
|
||||
const wrapperClasses = filteredDecorations |
||||
.map(decoration => decoration.data.get('className')) |
||||
.join(' ') |
||||
.split(' ') |
||||
.filter(className => className.length); |
||||
|
||||
let text = value.focusText.text; |
||||
let prefix = text.slice(0, selection.focus.offset); |
||||
|
||||
if (filteredDecorations.length) { |
||||
text = value.focusText.text.slice(filteredDecorations[0].start.offset, filteredDecorations[0].end.offset); |
||||
prefix = value.focusText.text.slice(filteredDecorations[0].start.offset, selection.focus.offset); |
||||
} |
||||
|
||||
// Label values could have valid characters erased if `cleanText()` is
|
||||
// blindly applied, which would undesirably interfere with suggestions
|
||||
const labelValueMatch = prefix.match(/(?:!?=~?"?|")(.*)/); |
||||
if (labelValueMatch) { |
||||
prefix = labelValueMatch[1]; |
||||
} else if (cleanText) { |
||||
prefix = cleanText(prefix); |
||||
} |
||||
|
||||
const { suggestions, context } = await onTypeahead({ |
||||
prefix, |
||||
text, |
||||
value, |
||||
wrapperClasses, |
||||
labelKey, |
||||
}); |
||||
|
||||
const filteredSuggestions = suggestions |
||||
.map(group => { |
||||
if (!group.items) { |
||||
return group; |
||||
} |
||||
|
||||
if (prefix) { |
||||
// Filter groups based on prefix
|
||||
if (!group.skipFilter) { |
||||
group.items = group.items.filter(c => (c.filterText || c.label).length >= prefix.length); |
||||
if (group.prefixMatch) { |
||||
group.items = group.items.filter(c => (c.filterText || c.label).startsWith(prefix)); |
||||
} else { |
||||
group.items = group.items.filter(c => (c.filterText || c.label).includes(prefix)); |
||||
} |
||||
} |
||||
|
||||
// Filter out the already typed value (prefix) unless it inserts custom text
|
||||
group.items = group.items.filter(c => c.insertText || (c.filterText || c.label) !== prefix); |
||||
} |
||||
|
||||
if (!group.skipSort) { |
||||
group.items = sortBy(group.items, (item: CompletionItem) => item.sortText || item.label); |
||||
} |
||||
|
||||
return group; |
||||
}) |
||||
.filter(group => group.items && group.items.length); // Filter out empty groups
|
||||
|
||||
state = { |
||||
...state, |
||||
groupedItems: filteredSuggestions, |
||||
typeaheadPrefix: prefix, |
||||
typeaheadContext: context, |
||||
typeaheadText: text, |
||||
}; |
||||
|
||||
// Bogus edit to force re-render
|
||||
return editor.blur().focus(); |
||||
}, |
||||
TYPEAHEAD_DEBOUNCE |
||||
); |
Loading…
Reference in new issue