This commit is contained in:
duanfuxiang
2025-01-05 11:51:39 +08:00
commit 0c7ee142cb
215 changed files with 20611 additions and 0 deletions

View 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;

View 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;

View 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
View 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)
);
}

View 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;
}

View 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;