init
This commit is contained in:
27
src/render-plugin/completion-key-watcher.ts
Normal file
27
src/render-plugin/completion-key-watcher.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Prec } from "@codemirror/state";
|
||||
import { keymap } from "@codemirror/view";
|
||||
|
||||
function CompletionKeyWatcher(
|
||||
handleAcceptKey: () => boolean,
|
||||
handlePartialAcceptKey: () => boolean,
|
||||
handleCancelKey: () => boolean
|
||||
) {
|
||||
return Prec.highest(
|
||||
keymap.of([
|
||||
{
|
||||
key: "Tab",
|
||||
run: handleAcceptKey,
|
||||
},
|
||||
{
|
||||
key: "ArrowRight",
|
||||
run: handlePartialAcceptKey,
|
||||
},
|
||||
{
|
||||
key: "Escape",
|
||||
run: handleCancelKey,
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
export default CompletionKeyWatcher;
|
||||
201
src/render-plugin/document-changes-listener.ts
Normal file
201
src/render-plugin/document-changes-listener.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { ViewPlugin, ViewUpdate } from "@codemirror/view";
|
||||
|
||||
import UserEvent from "./user-event";
|
||||
|
||||
export class DocumentChanges {
|
||||
private update: ViewUpdate;
|
||||
|
||||
constructor(update: ViewUpdate) {
|
||||
this.update = update;
|
||||
|
||||
}
|
||||
|
||||
public isDocInFocus(): boolean {
|
||||
return this.update.view.hasFocus;
|
||||
}
|
||||
|
||||
public noUserEvents(): boolean {
|
||||
return this.getUserEvents().length === 0;
|
||||
}
|
||||
|
||||
public hasUserTyped(): boolean {
|
||||
const userEvents = this.getUserEvents();
|
||||
return userEvents.contains(UserEvent.INPUT_TYPE);
|
||||
}
|
||||
|
||||
public hasUserUndone(): boolean {
|
||||
const userEvents = this.getUserEvents();
|
||||
return userEvents.contains(UserEvent.UNDO);
|
||||
}
|
||||
|
||||
public hasUserRedone(): boolean {
|
||||
const userEvents = this.getUserEvents();
|
||||
return userEvents.contains(UserEvent.REDO);
|
||||
}
|
||||
|
||||
public hasUserDeleted(): boolean {
|
||||
const userEvents = this.getUserEvents();
|
||||
return (
|
||||
userEvents.filter((event) => UserEvent.isDelete(event)).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
public hasDocChanged(): boolean {
|
||||
return (
|
||||
this.update.docChanged ||
|
||||
this.hasUserTyped() ||
|
||||
this.hasUserDeleted()
|
||||
);
|
||||
}
|
||||
|
||||
public hasCursorMoved(): boolean {
|
||||
return this.getUserEvents().contains(UserEvent.CURSOR_MOVED);
|
||||
}
|
||||
|
||||
public getUserEvents(): UserEvent[] {
|
||||
const userEvents: UserEvent[] = [];
|
||||
for (const transaction of this.update.transactions) {
|
||||
const event = UserEvent.fromTransaction(transaction);
|
||||
if (event) {
|
||||
userEvents.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
return userEvents;
|
||||
}
|
||||
|
||||
isTextAdded(): boolean {
|
||||
return this.getAddedText().length > 0;
|
||||
}
|
||||
|
||||
getAddedText(): string {
|
||||
let addedText = "";
|
||||
this.update.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
|
||||
addedText += inserted;
|
||||
});
|
||||
|
||||
return addedText;
|
||||
}
|
||||
|
||||
getPrefix(): string {
|
||||
return getPrefix(this.update.state);
|
||||
}
|
||||
|
||||
getSuffix(): string {
|
||||
return getSuffix(this.update.state);
|
||||
}
|
||||
|
||||
getAddedPrefixText(): string | undefined {
|
||||
if (!this.isDocInFocus() || this.hasCursorMoved()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const previousPrefix = this.getPreviousPrefix();
|
||||
const updatedPrefix = this.getPrefix();
|
||||
|
||||
if (updatedPrefix.length > previousPrefix.length) {
|
||||
return updatedPrefix.substring(previousPrefix.length);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
getPreviousPrefix(): string {
|
||||
return getPrefix(this.update.startState);
|
||||
}
|
||||
|
||||
getAddedSuffixText(): string | undefined {
|
||||
if (!this.isDocInFocus() || this.hasCursorMoved()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const previousSuffix = this.getPreviousSuffix();
|
||||
const updatedSuffix = this.getSuffix();
|
||||
|
||||
if (updatedSuffix.length > previousSuffix.length) {
|
||||
return updatedSuffix.substring(0, updatedSuffix.length - previousSuffix.length);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
getPreviousSuffix(): string {
|
||||
return getSuffix(this.update.startState);
|
||||
}
|
||||
|
||||
getRemovedPrefixText(): string | undefined {
|
||||
if (!this.isDocInFocus() || this.hasCursorMoved()) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const previousPrefix = this.getPreviousPrefix();
|
||||
const updatedPrefix = this.getPrefix();
|
||||
|
||||
if (updatedPrefix.length < previousPrefix.length) {
|
||||
return previousPrefix.substring(updatedPrefix.length);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
getRemovedSuffixText(): string | undefined {
|
||||
if (!this.isDocInFocus() || this.hasCursorMoved()) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const previousSuffix = this.getPreviousSuffix();
|
||||
const updatedSuffix = this.getSuffix();
|
||||
|
||||
if (updatedSuffix.length < previousSuffix.length) {
|
||||
return previousSuffix.substring(0, previousSuffix.length - updatedSuffix.length);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
hasSelection(): boolean {
|
||||
return hasSelection(this.update.state);
|
||||
}
|
||||
|
||||
hasMultipleCursors(): boolean {
|
||||
return hasMultipleCursors(this.update.state);
|
||||
}
|
||||
}
|
||||
|
||||
const DocumentChangesListener = (
|
||||
onDocumentChange: (documentChange: DocumentChanges) => Promise<void>
|
||||
) =>
|
||||
ViewPlugin.fromClass(
|
||||
class FetchPlugin {
|
||||
|
||||
async update(update: ViewUpdate) {
|
||||
|
||||
await onDocumentChange(new DocumentChanges(update));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export function getPrefix(state: EditorState): string {
|
||||
return state.doc.sliceString(0, getCursorLocation(state));
|
||||
}
|
||||
|
||||
export function getCursorLocation(state: EditorState): number {
|
||||
return state.selection.main.head;
|
||||
}
|
||||
|
||||
export function getSuffix(state: EditorState): string {
|
||||
return state.doc.sliceString(getCursorLocation(state));
|
||||
}
|
||||
|
||||
export function hasMultipleCursors(state: EditorState): boolean {
|
||||
return state.selection.ranges.length > 1;
|
||||
}
|
||||
|
||||
export function hasSelection(state: EditorState): boolean {
|
||||
for (const range of state.selection.ranges) {
|
||||
const { from, to } = range;
|
||||
if (from !== to) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default DocumentChangesListener;
|
||||
105
src/render-plugin/render-surgestion-plugin.ts
Normal file
105
src/render-plugin/render-surgestion-plugin.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Prec } from "@codemirror/state";
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
WidgetType,
|
||||
} from "@codemirror/view";
|
||||
|
||||
import { cancelSuggestion, InlineSuggestionState } from "./states";
|
||||
import { OptionalSuggestion, Suggestion } from "./types";
|
||||
|
||||
const RenderSuggestionPlugin = () =>
|
||||
Prec.lowest(
|
||||
// must be lowest else you get infinite loop with state changes by our plugin
|
||||
ViewPlugin.fromClass(
|
||||
class RenderPlugin {
|
||||
decorations: DecorationSet;
|
||||
suggestion: Suggestion;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = Decoration.none;
|
||||
this.suggestion = {
|
||||
value: "",
|
||||
render: false,
|
||||
}
|
||||
}
|
||||
|
||||
async update(update: ViewUpdate) {
|
||||
const suggestion: OptionalSuggestion = update.state.field(
|
||||
InlineSuggestionState
|
||||
);
|
||||
|
||||
if (suggestion !== null && suggestion !== undefined) {
|
||||
this.suggestion = suggestion;
|
||||
}
|
||||
|
||||
this.decorations = inlineSuggestionDecoration(
|
||||
update.view,
|
||||
this.suggestion
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (v) => v.decorations,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
function inlineSuggestionDecoration(
|
||||
view: EditorView,
|
||||
display_suggestion: Suggestion
|
||||
) {
|
||||
const post = view.state.selection.main.head;
|
||||
|
||||
if (!display_suggestion.render) {
|
||||
return Decoration.none;
|
||||
}
|
||||
try {
|
||||
const widget = new InlineSuggestionWidget(display_suggestion.value, view);
|
||||
const decoration = Decoration.widget({
|
||||
widget,
|
||||
side: 1,
|
||||
});
|
||||
|
||||
return Decoration.set([decoration.range(post)]);
|
||||
} catch (e) {
|
||||
return Decoration.none;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class InlineSuggestionWidget extends WidgetType {
|
||||
constructor(readonly display_suggestion: string, readonly view: EditorView) {
|
||||
super();
|
||||
this.display_suggestion = display_suggestion;
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
eq(other: InlineSuggestionWidget) {
|
||||
return other.display_suggestion == this.display_suggestion;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = this.display_suggestion;
|
||||
span.style.opacity = "0.4"; // TODO replace with css
|
||||
span.onclick = () => {
|
||||
cancelSuggestion(this.view);
|
||||
}
|
||||
span.onselect = () => {
|
||||
cancelSuggestion(this.view);
|
||||
}
|
||||
|
||||
return span;
|
||||
}
|
||||
|
||||
destroy(dom: HTMLElement) {
|
||||
super.destroy(dom);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default RenderSuggestionPlugin;
|
||||
151
src/render-plugin/states.ts
Normal file
151
src/render-plugin/states.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
EditorSelection,
|
||||
EditorState,
|
||||
SelectionRange,
|
||||
StateEffect,
|
||||
StateField,
|
||||
Transaction,
|
||||
TransactionSpec,
|
||||
} from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
|
||||
import { InlineSuggestion, OptionalSuggestion } from "./types";
|
||||
|
||||
const InlineSuggestionEffect = StateEffect.define<InlineSuggestion>();
|
||||
|
||||
export const InlineSuggestionState = StateField.define<OptionalSuggestion>({
|
||||
create(): OptionalSuggestion {
|
||||
return null;
|
||||
},
|
||||
update(
|
||||
value: OptionalSuggestion,
|
||||
transaction: Transaction
|
||||
): OptionalSuggestion {
|
||||
const inlineSuggestion = transaction.effects.find((effect) =>
|
||||
effect.is(InlineSuggestionEffect)
|
||||
);
|
||||
|
||||
if (
|
||||
inlineSuggestion?.value?.doc !== undefined
|
||||
) {
|
||||
return inlineSuggestion.value.suggestion;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const updateSuggestion = (
|
||||
view: EditorView,
|
||||
suggestion: string
|
||||
) => {
|
||||
const doc = view.state.doc;
|
||||
sleep(1).then(() => {
|
||||
view.dispatch({
|
||||
effects: InlineSuggestionEffect.of({
|
||||
suggestion: {
|
||||
value: suggestion,
|
||||
render: true,
|
||||
},
|
||||
doc: doc,
|
||||
}),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const cancelSuggestion = (view: EditorView) => {
|
||||
const doc = view.state.doc;
|
||||
sleep(1).then(() => {
|
||||
view.dispatch({
|
||||
effects: InlineSuggestionEffect.of({
|
||||
suggestion: {
|
||||
value: "",
|
||||
render: false,
|
||||
},
|
||||
doc: doc,
|
||||
}),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const insertSuggestion = (view: EditorView, suggestion: string) => {
|
||||
view.dispatch({
|
||||
...createInsertSuggestionTransaction(
|
||||
view.state,
|
||||
suggestion,
|
||||
view.state.selection.main.from,
|
||||
view.state.selection.main.to
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
function createInsertSuggestionTransaction(
|
||||
state: EditorState,
|
||||
text: string,
|
||||
from: number,
|
||||
to: number
|
||||
): TransactionSpec {
|
||||
const docLength = state.doc.length;
|
||||
if (from < 0 || to > docLength || from > to) {
|
||||
// If the range is not valid, return an empty transaction spec.
|
||||
return { changes: [] };
|
||||
}
|
||||
|
||||
const createInsertSuggestionTransactionFromSelectionRange = (
|
||||
range: SelectionRange
|
||||
) => {
|
||||
|
||||
if (range === state.selection.main) {
|
||||
return {
|
||||
changes: { from, to, insert: text },
|
||||
range: EditorSelection.cursor(to + text.length),
|
||||
};
|
||||
}
|
||||
const length = to - from;
|
||||
if (hasTextChanged(from, to, state, range)) {
|
||||
return { range };
|
||||
}
|
||||
return {
|
||||
changes: {
|
||||
from: range.from - length,
|
||||
to: range.from,
|
||||
insert: text,
|
||||
},
|
||||
range: EditorSelection.cursor(range.from - length + text.length),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
...state.changeByRange(
|
||||
createInsertSuggestionTransactionFromSelectionRange
|
||||
),
|
||||
userEvent: "input.complete",
|
||||
};
|
||||
}
|
||||
|
||||
function hasTextChanged(
|
||||
from: number,
|
||||
to: number,
|
||||
state: EditorState,
|
||||
changeRange: SelectionRange
|
||||
) {
|
||||
if (changeRange.empty) {
|
||||
return false;
|
||||
}
|
||||
const length = to - from;
|
||||
if (length <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (changeRange.to <= from || changeRange.from >= to) {
|
||||
return false;
|
||||
}
|
||||
// check out of bound
|
||||
if (changeRange.from < 0 || changeRange.to > state.doc.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
state.sliceDoc(changeRange.from - length, changeRange.from) !=
|
||||
state.sliceDoc(from, to)
|
||||
);
|
||||
}
|
||||
14
src/render-plugin/types.ts
Normal file
14
src/render-plugin/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Text } from "@codemirror/state";
|
||||
|
||||
export interface Suggestion {
|
||||
value: string;
|
||||
render: boolean;
|
||||
}
|
||||
export type OptionalSuggestion = Suggestion | null;
|
||||
|
||||
export interface InlineSuggestion {
|
||||
suggestion: OptionalSuggestion;
|
||||
doc: Text | null;
|
||||
}
|
||||
|
||||
|
||||
56
src/render-plugin/user-event.ts
Normal file
56
src/render-plugin/user-event.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Transaction } from "@codemirror/state";
|
||||
|
||||
enum UserEvent {
|
||||
INPUT = "input",
|
||||
INPUT_TYPE = "input.type",
|
||||
INPUT_TYPE_COMPOSE = "input.type.compose",
|
||||
INPUT_PASTE = "input.paste",
|
||||
INPUT_DROP = "input.drop",
|
||||
INPUT_COMPLETE = "input.complete",
|
||||
DELETE = "delete",
|
||||
DELETE_SELECTION = "delete.selection",
|
||||
DELETE_FORWARD = "delete.forward",
|
||||
DELETE_BACKWARDS = "delete.backward",
|
||||
DELETE_CUT = "delete.cut",
|
||||
MOVE = "move",
|
||||
MOVE_DROP = "move.drop",
|
||||
CURSOR_MOVED = "select",
|
||||
CURSOR_MOVED_BY_MOUSE = "select.pointer",
|
||||
UNDO = "undo",
|
||||
REDO = "redo",
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace UserEvent {
|
||||
export function isDelete(event: UserEvent) {
|
||||
return event.contains("delete");
|
||||
}
|
||||
|
||||
export function values_string(): Array<string> {
|
||||
return Object.values(UserEvent).map((event) => event.toString());
|
||||
}
|
||||
|
||||
export function fromString(event: string) {
|
||||
const keys = Object.keys(UserEvent) as Array<keyof typeof UserEvent>;
|
||||
for (const key of keys) {
|
||||
if (event === UserEvent[key]) {
|
||||
return UserEvent[key] as UserEvent;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function fromTransaction(transaction: Transaction) {
|
||||
for (const inputType of UserEvent.values_string()) {
|
||||
if (transaction.isUserEvent(inputType)) {
|
||||
const event = UserEvent.fromString(inputType);
|
||||
if (event) {
|
||||
return event;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default UserEvent;
|
||||
Reference in New Issue
Block a user