Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 77 additions & 23 deletions src/utils/monaco-editor/__tests__/editor.spec.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,87 @@
import { describe, it, expect } from "vitest";
import { ref, nextTick } from "vue";
import { ref, nextTick, unref, isRef } from "vue";
import type { Ref } from "vue";

import fc from "fast-check";

import { withAsyncSetup } from "@/tests/test-utils";
import { useMonacoEditor } from "..";

describe("useMonacoEditor()", () => {
it("Editor is created", async () => {
let result!: Awaited<ReturnType<typeof useMonacoEditor>>;
const element = ref(document.createElement("div"));
const source = ref("# Hello, world!");
await withAsyncSetup(async () => {
result = await useMonacoEditor({ element, source });
});
const { editor, model } = result;
expect(editor.value).toBeDefined();
expect(model.value?.getValue()).toBe("# Hello, world!");
import { fcSource, fcLanguage } from "./model.spec";

const fcElement = () =>
fc
.oneof(
fc.integer().map(() => document.createElement("div")),
fc.constant(undefined)
)
.chain(
(element): fc.Arbitrary<HTMLElement | Ref<HTMLElement | undefined>> =>
element === undefined
? fc.constant(ref(element))
: fc.oneof(fc.constant(element), fc.constant(ref(element)))
);

const fcMode = () =>
fc.option(fc.constantFrom("viewer" as const, "editor" as const), { nil: undefined });

const fcUseMonacoEditorOptions = () =>
fc.record(
{
element: fcElement(),
mode: fcMode(),
source: fcSource(),
language: fcLanguage(),
},
{ requiredKeys: ["element"] }
);

describe("fcUseMonacoEditorOptions()", () => {
it("Options of useMonacoEditor() are generated", () => {
fc.assert(
fc.property(fcUseMonacoEditorOptions(), (options) => {
expect(options).toBeDefined();
return true;
})
);
});
});

it("The 'ready' is awaited", async () => {
let result!: ReturnType<typeof useMonacoEditor>;
const element = ref(document.createElement("div"));
const source = ref("# Hello, world!");
await withAsyncSetup(async () => {
result = useMonacoEditor({ element, source });
});
const { editor, model, ready } = result;
await ready;
expect(editor.value).toBeDefined();
expect(model.value?.getValue()).toBe("# Hello, world!");
describe("useMonacoEditor()", () => {
it("Property test", async () => {
await fc.assert(
fc.asyncProperty(
fcUseMonacoEditorOptions(),
fc.boolean(),
async (options, early) => {
let editor!: ReturnType<typeof useMonacoEditor>["editor"];
let model!: ReturnType<typeof useMonacoEditor>["model"];
let source!: ReturnType<typeof useMonacoEditor>["source"];
let mode!: ReturnType<typeof useMonacoEditor>["mode"];
let ready!: ReturnType<typeof useMonacoEditor>["ready"];
const { wrapper } = await withAsyncSetup(async () => {
({ editor, model, source, mode, ready } = early
? useMonacoEditor(options)
: await useMonacoEditor(options));
});
if (early) await ready;
if (isRef(options.element) && options.element.value === undefined) {
options.element.value = document.createElement("div");
}
await nextTick();
expect(editor.value).toBeDefined();

const expectedSource = unref(options.source) ?? "";
expect(source.value).toBe(expectedSource);
expect(model.value?.getValue()).toBe(expectedSource);

const expectedMode = unref(options.mode) ?? "viewer";
expect(mode.value).toBe(expectedMode);

wrapper.unmount();
}
)
);
});

it("The mode changes between 'viewer' and 'editor'", async () => {
Expand Down
96 changes: 79 additions & 17 deletions src/utils/monaco-editor/__tests__/model.spec.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,91 @@
import { describe, it, expect } from "vitest";
import { ref, nextTick } from "vue";
import { ref, nextTick, unref, isRef } from "vue";

import fc from "fast-check";

import { useModel } from "..";

describe("useModel()", () => {
it("call without arguments", async () => {
const { model, source } = await useModel();
expect(source.value).toBe("");
expect(model.value?.getValue()).toBe("");
const fcText = () =>
fc.array(fc.oneof(fc.lorem(), fc.constant("")), {
maxLength: 100,
size: "max",
});

it("call with string source", async () => {
const source = "# Hello, world!";
const { model } = await useModel({ source });
expect(model.value?.getValue()).toBe("# Hello, world!");
export const fcSource = () =>
fc.option(
fc.oneof(
fcText().map((lines) => lines.join("\n")),
fcText().map((lines) => ref(lines.join("\n")))
),
{ nil: undefined }
);

export const fcLanguage = () =>
fc.option(fc.constantFrom("python", "typescript"), {
nil: undefined,
});

it("call with ref source", async () => {
const source = ref("# Hello, world!");
const { model, source: sourceReturned } = await useModel({ source });
expect(sourceReturned === source).toBe(true);
expect(model.value?.getValue()).toBe("# Hello, world!");
export const fcUseModelOptions = () =>
fc.record(
{
source: fcSource(),
language: fcLanguage(),
},
{ withDeletedKeys: true }
);

describe("fcUseModelOptions()", () => {
it("Options of useModel() are generated", () => {
fc.assert(
fc.property(fcUseModelOptions(), (options) => {
expect(options).toBeDefined();
return true;
})
);
});
});

const fcUseModelArgs = () =>
fc.option(
fcUseModelOptions().map((options) => [options]),
{ nil: [] }
);

function assertDefined<T>(value: T): asserts value is NonNullable<T> {
expect(value).toBeDefined();
}

describe("useModel()", () => {
it("Property test", async () => {
await fc.assert(
fc.asyncProperty(fcUseModelArgs(), fc.boolean(), async (args, early) => {
const { model, source, dispose, ready } = early
? useModel(...args)
: await useModel(...args);
if (early) await ready;

const model_ = model.value;
assertDefined(model_);

const options = args?.[0];

const expectedLanguage = options?.language ?? "python";
expect(model_.getLanguageId()).toBe(expectedLanguage);

if (isRef(options?.source)) {
expect(source.value).toBe(options?.source.value);
}

const expectedValue = unref(options?.source ?? "");
expect(model_.getValue()).toBe(expectedValue);

dispose();
}),
{ verbose: true, numRuns: 10 } // NOTE: Error with larger numRuns
);
});

it("source is reactive", async () => {
it("Source is reactive", async () => {
const source = ref("# Hello, world!");
const { model } = await useModel({ source });
expect(model.value?.getValue()).toBe("# Hello, world!");
Expand All @@ -32,7 +94,7 @@ describe("useModel()", () => {
expect(model.value?.getValue()).toBe("# New source");
});

it("edit model", async () => {
it("Edit model", async () => {
const source = ref("# Hello, world!");
const { model } = await useModel({ source });
expect(model.value?.getValue()).toBe("# Hello, world!");
Expand Down
27 changes: 16 additions & 11 deletions src/utils/monaco-editor/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const modelOptionsMap: Record<Mode, Monaco.editor.IEditorOptions> = {
};

export interface UseMonacoEditorOptions extends UseModelOptions {
element: MaybeRef<HTMLElement | undefined>;
element: HTMLElement | Ref<HTMLElement | undefined>;
mode?: MaybeRef<Mode>;
}

Expand All @@ -70,7 +70,13 @@ export function useMonacoEditor(

const mode = ref(mode_ ?? defaultMode);

const { model, source, beforeSetValue, afterSetValue } = useModel(modelOptions);
const {
model,
source,
beforeSetValue,
afterSetValue,
ready: readyModel,
} = useModel(modelOptions);

const editor = shallowRef<Monaco.editor.IStandaloneCodeEditor>();

Expand All @@ -81,17 +87,15 @@ export function useMonacoEditor(

watchEffect(() => {
if (!isMounted.value) return;
const ele = unref(element);
if (!ele) {
console.error("element is undefined");
return;
}

const htmlElement = unref(element);
if (!htmlElement) return;

const model_ = unref(model);
if (!model_) return;
editor.value = monaco.value?.editor.create(ele, {
model: model_,
...editorOptionsBase,
});

const options = { model: model_, ...editorOptionsBase };
editor.value = monaco.value?.editor.create(htmlElement, options);
});

watchEffect(() => {
Expand All @@ -118,6 +122,7 @@ export function useMonacoEditor(
async function loadMonaco() {
await useColorThemeOnMonacoEditor();
monaco.value = await import("monaco-editor");
await readyModel;
}

const ready = loadMonaco();
Expand Down
50 changes: 44 additions & 6 deletions src/utils/monaco-editor/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,34 +18,46 @@ const _default: Required<UseModelOptions> = {
sourceUpdateMaxWaitMilliseconds: 100,
};

function resolveOptions(options?: UseModelOptions) {
// Remove `undefined` so default has precedence.
const given = Object.fromEntries(
Object.entries(options || {}).filter(([, v]) => v !== undefined)
);
const defaultApplied = { ..._default, ...given };

// Turn `MaybeRef` into `Ref`.
const { source: source_, ...rest } = defaultApplied;
const source: Ref<string> = ref(source_);
return { source, ...rest };
}

interface _UseModelReturn {
model: ShallowRef<Monaco.editor.ITextModel | undefined>;
source: Ref<string>;
beforeSetValue: (fn: () => void) => void;
afterSetValue: (fn: () => void) => void;
dispose: () => void;
ready: Promise<void>;
}

type UseModelReturn = _UseModelReturn & PromiseLike<_UseModelReturn>;

export function useModel(options?: UseModelOptions): UseModelReturn {
const {
source: source_,
source,
language,
sourceUpdateDelayMilliseconds,
sourceUpdateMaxWaitMilliseconds,
} = { ..._default, ...options };
} = resolveOptions(options);

const monaco = ref<typeof Monaco>();
const model = shallowRef<Monaco.editor.ITextModel>();

const source = ref(source_);

const beforeSetValue = createEventHook<null>();
const afterSetValue = createEventHook<null>();

// Update model when source changes.
watchEffect(() => {
const stop = watchEffect(() => {
if (!model.value) return;
if (source.value === model.value.getValue()) return;
beforeSetValue.trigger(null);
Expand All @@ -63,10 +75,35 @@ export function useModel(options?: UseModelOptions): UseModelReturn {
{ maxWait: sourceUpdateMaxWaitMilliseconds }
);

function registerLanguage(monaco: typeof Monaco, language: string) {
// Register the language if it is not registered.
// Many languages are typically registered in browsers.
// Only `plaintext` is registered in Vitest because of `vitest.config.ts`.
if (!language) return;
if (
monaco.languages
.getLanguages()
.map((lang) => lang.id)
.includes(language)
)
return;
monaco.languages.register({ id: language });
}

function dispose() {
stop();
// TODO: Cancel debounced function. https://github.com/vueuse/vueuse/pull/4561
disposeOnDidChangeContent?.dispose();
if (model.value) model.value.dispose();
}

let disposeOnDidChangeContent: Monaco.IDisposable | undefined;

async function loadMonaco() {
monaco.value = await import("monaco-editor");
registerLanguage(monaco.value, language);
model.value = monaco.value.editor.createModel(source.value, language);
model.value.onDidChangeContent(updateSource);
disposeOnDidChangeContent = model.value.onDidChangeContent(updateSource);
}

const ready = loadMonaco();
Expand All @@ -76,6 +113,7 @@ export function useModel(options?: UseModelOptions): UseModelReturn {
source,
beforeSetValue: beforeSetValue.on,
afterSetValue: afterSetValue.on,
dispose,
ready,
};

Expand Down
1 change: 1 addition & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default mergeConfig(
root: fileURLToPath(new URL("./", import.meta.url)),
setupFiles: ["./src/tests/setup.ts"],
includeSource: ["src/**/*.{js,ts}"],
testTimeout: 30_000,
alias: [
{
// https://github.com/vitest-dev/vitest/discussions/1806#discussioncomment-3570047
Expand Down