feat(editor): Refactor expression editors and mixins to composition API (#8894)

Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
Elias Meire
2024-03-15 18:40:37 +01:00
committed by GitHub
parent a10120f74e
commit 0c179e4e51
19 changed files with 1661 additions and 1126 deletions

View File

@@ -0,0 +1,111 @@
import { insertCompletionText, pickedCompletion } from '@codemirror/autocomplete';
import { Compartment, EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { createTestingPinia } from '@pinia/testing';
import { waitFor } from '@testing-library/vue';
import { setActivePinia } from 'pinia';
import { beforeEach, describe, vi } from 'vitest';
import { useAutocompleteTelemetry } from '../useAutocompleteTelemetry';
const trackSpy = vi.fn();
const setAutocompleteOnboardedSpy = vi.fn();
vi.mock('@/composables/useTelemetry', () => ({
useTelemetry: vi.fn(() => ({ track: trackSpy })),
}));
vi.mock('@/stores/ndv.store', () => ({
useNDVStore: vi.fn(() => ({
activeNode: { type: 'n8n-nodes-base.test' },
setAutocompleteOnboarded: setAutocompleteOnboardedSpy,
})),
}));
vi.mock('@/stores/n8nRoot.store', () => ({
useRootStore: vi.fn(() => ({
instanceId: 'test-instance-id',
})),
}));
describe('useAutocompleteTelemetry', () => {
const originalRangeGetBoundingClientRect = Range.prototype.getBoundingClientRect;
const originalRangeGetClientRects = Range.prototype.getClientRects;
beforeEach(() => {
setActivePinia(createTestingPinia());
});
afterEach(() => {
vi.clearAllMocks();
});
beforeAll(() => {
Range.prototype.getBoundingClientRect = vi.fn();
Range.prototype.getClientRects = () => ({
item: vi.fn(),
length: 0,
[Symbol.iterator]: vi.fn(),
});
});
afterAll(() => {
Range.prototype.getBoundingClientRect = originalRangeGetBoundingClientRect;
Range.prototype.getClientRects = originalRangeGetClientRects;
});
const getEditor = (defaultDoc = '') => {
const extensionCompartment = new Compartment();
const state = EditorState.create({
doc: defaultDoc,
extensions: [extensionCompartment.of([])],
});
const editorRoot = document.createElement('div');
return {
editor: new EditorView({ parent: editorRoot, state }),
editorRoot,
compartment: extensionCompartment,
};
};
test('should track user autocomplete', async () => {
const { editor, compartment } = getEditor('$json.');
useAutocompleteTelemetry({
editor,
parameterPath: 'param',
compartment,
});
editor.dispatch({
...insertCompletionText(editor.state, 'foo', 6, 6),
annotations: pickedCompletion.of({ label: 'foo' }),
});
await waitFor(() =>
expect(trackSpy).toHaveBeenCalledWith('User autocompleted code', {
category: 'n/a',
context: '$json',
field_name: 'param',
field_type: 'expression',
inserted_text: 'foo',
instance_id: 'test-instance-id',
node_type: 'n8n-nodes-base.test',
}),
);
});
test('should mark user as onboarded on autocomplete', async () => {
const { editor, compartment } = getEditor();
useAutocompleteTelemetry({
editor,
parameterPath: 'param',
compartment,
});
editor.dispatch({
...insertCompletionText(editor.state, 'foo', 0, 0),
annotations: pickedCompletion.of({ label: 'foo' }),
});
await waitFor(() => expect(setAutocompleteOnboardedSpy).toHaveBeenCalled());
});
});

View File

@@ -0,0 +1,268 @@
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
import { EditorView } from '@codemirror/view';
import { createTestingPinia } from '@pinia/testing';
import { waitFor } from '@testing-library/vue';
import { setActivePinia } from 'pinia';
import { beforeEach, describe, vi } from 'vitest';
import { ref, toValue } from 'vue';
import { n8nLang } from '../../plugins/codemirror/n8nLang';
import { useExpressionEditor } from '../useExpressionEditor';
import { useRouter } from 'vue-router';
import { EditorSelection } from '@codemirror/state';
vi.mock('@/composables/useAutocompleteTelemetry', () => ({
useAutocompleteTelemetry: vi.fn(),
}));
vi.mock('@/stores/ndv.store', () => ({
useNDVStore: vi.fn(() => ({
activeNode: { type: 'n8n-nodes-base.test' },
})),
}));
describe('useExpressionEditor', () => {
const originalRangeGetBoundingClientRect = Range.prototype.getBoundingClientRect;
const originalRangeGetClientRects = Range.prototype.getClientRects;
const mockResolveExpression = () => {
const mock = vi.fn();
vi.spyOn(workflowHelpers, 'useWorkflowHelpers').mockReturnValueOnce({
...workflowHelpers.useWorkflowHelpers({ router: useRouter() }),
resolveExpression: mock,
});
return mock;
};
beforeEach(() => {
setActivePinia(createTestingPinia());
});
afterEach(() => {
vi.clearAllMocks();
});
beforeAll(() => {
Range.prototype.getBoundingClientRect = vi.fn();
Range.prototype.getClientRects = () => ({
item: vi.fn(),
length: 0,
[Symbol.iterator]: vi.fn(),
});
});
afterAll(() => {
Range.prototype.getBoundingClientRect = originalRangeGetBoundingClientRect;
Range.prototype.getClientRects = originalRangeGetClientRects;
});
test('should create an editor', async () => {
const root = ref<HTMLElement>();
const { editor } = useExpressionEditor({
editorRef: root,
});
root.value = document.createElement('div');
await waitFor(() => expect(toValue(editor)).toBeInstanceOf(EditorView));
});
test('should calculate segments', async () => {
mockResolveExpression().mockReturnValueOnce(15);
const root = ref<HTMLElement>();
const { segments } = useExpressionEditor({
editorRef: root,
editorValue: 'before {{ $json.test.length }} after',
extensions: [n8nLang()],
});
root.value = document.createElement('div');
await waitFor(() => {
expect(toValue(segments.all)).toEqual([
{
from: 0,
kind: 'plaintext',
plaintext: 'before ',
to: 7,
},
{
error: null,
from: 7,
kind: 'resolvable',
resolvable: '{{ $json.test.length }}',
resolved: '15',
state: 'valid',
to: 30,
},
{
from: 30,
kind: 'plaintext',
plaintext: ' after',
to: 36,
},
]);
expect(toValue(segments.resolvable)).toEqual([
{
error: null,
from: 7,
kind: 'resolvable',
resolvable: '{{ $json.test.length }}',
resolved: '15',
state: 'valid',
to: 30,
},
]);
expect(toValue(segments.plaintext)).toEqual([
{
from: 0,
kind: 'plaintext',
plaintext: 'before ',
to: 7,
},
{
from: 30,
kind: 'plaintext',
plaintext: ' after',
to: 36,
},
]);
});
});
describe('readEditorValue()', () => {
test('should return the full editor value (unresolved)', async () => {
mockResolveExpression().mockReturnValueOnce(15);
const root = ref<HTMLElement>();
const { readEditorValue } = useExpressionEditor({
editorRef: root,
editorValue: 'before {{ $json.test.length }} after',
extensions: [n8nLang()],
});
root.value = document.createElement('div');
await waitFor(() =>
expect(readEditorValue()).toEqual('before {{ $json.test.length }} after'),
);
});
});
describe('setCursorPosition()', () => {
test('should set cursor position to number correctly', async () => {
const root = ref<HTMLElement>();
const editorValue = 'text here';
const { editor, setCursorPosition } = useExpressionEditor({
editorRef: root,
editorValue,
extensions: [],
});
root.value = document.createElement('div');
await waitFor(() => toValue(editor));
setCursorPosition(4);
await waitFor(() =>
expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(4)),
);
});
test('should set cursor position to end correctly', async () => {
const root = ref<HTMLElement>();
const editorValue = 'text here';
const correctPosition = editorValue.length;
const { editor, setCursorPosition } = useExpressionEditor({
editorRef: root,
editorValue,
extensions: [],
});
root.value = document.createElement('div');
await waitFor(() => toValue(editor));
setCursorPosition('end');
await waitFor(() =>
expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(correctPosition)),
);
});
test('should set cursor position to last expression correctly', async () => {
const root = ref<HTMLElement>();
const editorValue = 'text {{ $json.foo }} {{ $json.bar }} here';
const correctPosition = editorValue.indexOf('bar') + 'bar'.length;
const { editor, setCursorPosition } = useExpressionEditor({
editorRef: root,
editorValue,
extensions: [n8nLang()],
});
root.value = document.createElement('div');
await waitFor(() => toValue(editor));
setCursorPosition('lastExpression');
await waitFor(() =>
expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(correctPosition)),
);
});
});
describe('select()', () => {
test('should select number range', async () => {
const root = ref<HTMLElement>();
const editorValue = 'text here';
const { editor, select } = useExpressionEditor({
editorRef: root,
editorValue,
extensions: [],
});
root.value = document.createElement('div');
await waitFor(() => toValue(editor));
select(4, 7);
await waitFor(() =>
expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(4, 7)),
);
});
test('should select until end', async () => {
const root = ref<HTMLElement>();
const editorValue = 'text here';
const { editor, select } = useExpressionEditor({
editorRef: root,
editorValue,
extensions: [],
});
root.value = document.createElement('div');
await waitFor(() => toValue(editor));
select(4, 'end');
await waitFor(() =>
expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(4, 9)),
);
});
});
describe('selectAll()', () => {
test('should select all', async () => {
const root = ref<HTMLElement>();
const editorValue = 'text here';
const { editor, selectAll } = useExpressionEditor({
editorRef: root,
editorValue,
extensions: [],
});
root.value = document.createElement('div');
await waitFor(() => toValue(editor));
selectAll();
await waitFor(() =>
expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(0, 9)),
);
});
});
});

View File

@@ -0,0 +1,114 @@
import { type MaybeRefOrGetter, computed, toValue, watchEffect } from 'vue';
import { ExpressionExtensions } from 'n8n-workflow';
import { EditorView, type ViewUpdate } from '@codemirror/view';
import { useNDVStore } from '@/stores/ndv.store';
import { useRootStore } from '@/stores/n8nRoot.store';
import { useTelemetry } from '../composables/useTelemetry';
import type { Compartment } from '@codemirror/state';
import { debounce } from 'lodash-es';
export const useAutocompleteTelemetry = ({
editor: editorRef,
parameterPath,
compartment,
}: {
editor: MaybeRefOrGetter<EditorView | undefined>;
parameterPath: MaybeRefOrGetter<string>;
compartment: MaybeRefOrGetter<Compartment>;
}) => {
const ndvStore = useNDVStore();
const rootStore = useRootStore();
const telemetry = useTelemetry();
const expressionExtensionsCategories = computed(() => {
return ExpressionExtensions.reduce<Record<string, string | undefined>>((acc, cur) => {
for (const fnName of Object.keys(cur.functions)) {
acc[fnName] = cur.typeName;
}
return acc;
}, {});
});
function findCompletionBaseStartIndex(fromIndex: number) {
const editor = toValue(editorRef);
if (!editor) return -1;
const INDICATORS = [
' $', // proxy
'{ ', // primitive
];
const doc = editor.state.doc.toString();
for (let index = fromIndex; index > 0; index--) {
if (INDICATORS.some((indicator) => indicator === doc[index] + doc[index + 1])) {
return index + 1;
}
}
return -1;
}
function trackCompletion(viewUpdate: ViewUpdate, path: string) {
const editor = toValue(editorRef);
if (!editor) return;
const completionTx = viewUpdate.transactions.find((tx) => tx.isUserEvent('input.complete'));
if (!completionTx) return;
ndvStore.setAutocompleteOnboarded();
let completion = '';
let completionBase = '';
viewUpdate.changes.iterChanges((_: number, __: number, fromB: number, toB: number) => {
completion = toValue(editor).state.doc.slice(fromB, toB).toString();
const index = findCompletionBaseStartIndex(fromB);
completionBase = toValue(editor)
.state.doc.slice(index, fromB - 1)
.toString()
.trim();
});
const category = expressionExtensionsCategories.value[completion];
const payload = {
instance_id: rootStore.instanceId,
node_type: ndvStore.activeNode?.type,
field_name: path,
field_type: 'expression',
context: completionBase,
inserted_text: completion,
category: category ?? 'n/a', // only applicable if expression extension completion
};
telemetry.track('User autocompleted code', payload);
}
const safeTrackCompletion = (viewUpdate: ViewUpdate, path: string) => {
try {
trackCompletion(viewUpdate, path);
} catch {}
};
const debouncedTrackCompletion = debounce(safeTrackCompletion, 100);
watchEffect(() => {
const editor = toValue(editorRef);
if (!editor) return;
editor.dispatch({
effects: toValue(compartment).reconfigure([
EditorView.updateListener.of((viewUpdate) => {
if (!viewUpdate.docChanged || !editor) return;
debouncedTrackCompletion(viewUpdate, toValue(parameterPath));
}),
]),
});
});
};

View File

@@ -0,0 +1,405 @@
import {
computed,
type MaybeRefOrGetter,
onBeforeUnmount,
ref,
watchEffect,
type Ref,
toValue,
watch,
} from 'vue';
import { ensureSyntaxTree } from '@codemirror/language';
import type { IDataObject } from 'n8n-workflow';
import { Expression, ExpressionExtensions } from 'n8n-workflow';
import { EXPRESSION_EDITOR_PARSER_TIMEOUT } from '@/constants';
import { useNDVStore } from '@/stores/ndv.store';
import type { TargetItem } from '@/Interface';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import type { Html, Plaintext, RawSegment, Resolvable, Segment } from '@/types/expressions';
import {
getExpressionErrorMessage,
getResolvableState,
isEmptyExpression,
} from '@/utils/expressions';
import { completionStatus } from '@codemirror/autocomplete';
import { Compartment, EditorState, type Extension } from '@codemirror/state';
import { EditorView, type ViewUpdate } from '@codemirror/view';
import { debounce, isEqual } from 'lodash-es';
import { useRouter } from 'vue-router';
import { useI18n } from '../composables/useI18n';
import { highlighter } from '../plugins/codemirror/resolvableHighlighter';
import { useWorkflowsStore } from '../stores/workflows.store';
import { useAutocompleteTelemetry } from './useAutocompleteTelemetry';
export const useExpressionEditor = ({
editorRef,
editorValue,
extensions = [],
additionalData = {},
skipSegments = [],
autocompleteTelemetry,
isReadOnly = false,
}: {
editorRef: Ref<HTMLElement | undefined>;
editorValue?: MaybeRefOrGetter<string>;
extensions?: MaybeRefOrGetter<Extension[]>;
additionalData?: MaybeRefOrGetter<IDataObject>;
skipSegments?: MaybeRefOrGetter<string[]>;
autocompleteTelemetry?: MaybeRefOrGetter<{ enabled: true; parameterPath: string }>;
isReadOnly?: MaybeRefOrGetter<boolean>;
}) => {
const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
const router = useRouter();
const workflowHelpers = useWorkflowHelpers({ router });
const i18n = useI18n();
const editor = ref<EditorView>();
const hasFocus = ref(false);
const segments = ref<Segment[]>([]);
const customExtensions = ref<Compartment>(new Compartment());
const readOnlyExtensions = ref<Compartment>(new Compartment());
const telemetryExtensions = ref<Compartment>(new Compartment());
const autocompleteStatus = ref<'pending' | 'active' | null>(null);
const updateSegments = (): void => {
const state = editor.value?.state;
if (!state) return;
const rawSegments: RawSegment[] = [];
const fullTree = ensureSyntaxTree(state, state.doc.length, EXPRESSION_EDITOR_PARSER_TIMEOUT);
if (fullTree === null) return;
const skip = ['Program', 'Script', 'Document', ...toValue(skipSegments)];
fullTree.cursor().iterate((node) => {
const text = state.sliceDoc(node.from, node.to);
if (skip.includes(node.type.name)) return;
const newSegment: RawSegment = {
from: node.from,
to: node.to,
text,
token: node.type.name === 'Resolvable' ? 'Resolvable' : 'Plaintext',
};
// Avoid duplicates
if (isEqual(newSegment, rawSegments.at(-1))) return;
rawSegments.push(newSegment);
});
segments.value = rawSegments.reduce<Segment[]>((acc, segment) => {
const { from, to, text, token } = segment;
if (token === 'Resolvable') {
const { resolved, error, fullError } = resolve(text, hoveringItem.value);
acc.push({
kind: 'resolvable',
from,
to,
resolvable: text,
// TODO:
// For some reason, expressions that resolve to a number 0 are breaking preview in the SQL editor
// This fixes that but as as TODO we should figure out why this is happening
resolved: String(resolved),
state: getResolvableState(fullError ?? error, completionStatus !== null),
error: fullError,
});
return acc;
}
acc.push({ kind: 'plaintext', from, to, plaintext: text });
return acc;
}, []);
};
function readEditorValue(): string {
return editor.value?.state.doc.toString() ?? '';
}
function updateHighlighting(): void {
if (!editor.value) return;
highlighter.removeColor(editor.value, plaintextSegments.value);
highlighter.addColor(editor.value, resolvableSegments.value);
}
const debouncedUpdateSegments = debounce(updateSegments, 200);
function onEditorUpdate(viewUpdate: ViewUpdate) {
if (!viewUpdate.docChanged || !editor.value) return;
autocompleteStatus.value = completionStatus(viewUpdate.view.state);
debouncedUpdateSegments();
}
watch(editorRef, () => {
const parent = toValue(editorRef);
if (!parent) return;
const state = EditorState.create({
doc: toValue(editorValue),
extensions: [
customExtensions.value.of(toValue(extensions)),
readOnlyExtensions.value.of([
EditorState.readOnly.of(toValue(isReadOnly)),
EditorView.editable.of(!toValue(isReadOnly)),
]),
telemetryExtensions.value.of([]),
EditorView.updateListener.of(onEditorUpdate),
EditorView.focusChangeEffect.of((_, newHasFocus) => {
hasFocus.value = newHasFocus;
return null;
}),
EditorView.contentAttributes.of({ 'data-gramm': 'false' }), // disable grammarly
],
});
if (editor.value) {
editor.value.destroy();
}
editor.value = new EditorView({ parent, state });
debouncedUpdateSegments();
});
watchEffect(() => {
if (editor.value) {
editor.value.dispatch({
effects: customExtensions.value.reconfigure(toValue(extensions)),
});
}
});
watchEffect(() => {
if (editor.value) {
editor.value.dispatch({
effects: readOnlyExtensions.value.reconfigure([
EditorState.readOnly.of(toValue(isReadOnly)),
EditorView.editable.of(!toValue(isReadOnly)),
]),
});
}
});
watchEffect(() => {
if (!editor.value) return;
const newValue = toValue(editorValue);
const currentValue = readEditorValue();
if (newValue === undefined || newValue === currentValue) return;
editor.value.dispatch({
changes: { from: 0, to: currentValue.length, insert: newValue },
});
});
watchEffect(() => {
const telemetry = toValue(autocompleteTelemetry);
if (!telemetry?.enabled) return;
useAutocompleteTelemetry({
editor,
parameterPath: telemetry.parameterPath,
compartment: telemetryExtensions,
});
});
onBeforeUnmount(() => {
editor.value?.destroy();
});
const expressionExtensionNames = computed<Set<string>>(() => {
return new Set(
ExpressionExtensions.reduce<string[]>((acc, cur) => {
return [...acc, ...Object.keys(cur.functions)];
}, []),
);
});
function isUncalledExpressionExtension(resolvable: string) {
const end = resolvable
.replace(/^{{|}}$/g, '')
.trim()
.split('.')
.pop();
return end !== undefined && expressionExtensionNames.value.has(end);
}
function resolve(resolvable: string, hoverItem: TargetItem | null) {
const result: { resolved: unknown; error: boolean; fullError: Error | null } = {
resolved: undefined,
error: false,
fullError: null,
};
try {
if (!ndvStore.activeNode) {
// e.g. credential modal
result.resolved = Expression.resolveWithoutWorkflow(resolvable, toValue(additionalData));
} else {
let opts;
if (ndvStore.isInputParentOfActiveNode) {
opts = {
targetItem: hoverItem ?? undefined,
inputNodeName: ndvStore.ndvInputNodeName,
inputRunIndex: ndvStore.ndvInputRunIndex,
inputBranchIndex: ndvStore.ndvInputBranchIndex,
additionalKeys: toValue(additionalData),
};
}
result.resolved = workflowHelpers.resolveExpression('=' + resolvable, undefined, opts);
}
} catch (error) {
result.resolved = `[${getExpressionErrorMessage(error)}]`;
result.error = true;
result.fullError = error;
}
if (result.resolved === '') {
result.resolved = i18n.baseText('expressionModalInput.empty');
}
if (result.resolved === undefined && isEmptyExpression(resolvable)) {
result.resolved = i18n.baseText('expressionModalInput.empty');
}
if (result.resolved === undefined) {
result.resolved = isUncalledExpressionExtension(resolvable)
? i18n.baseText('expressionEditor.uncalledFunction')
: i18n.baseText('expressionModalInput.undefined');
result.error = true;
}
if (typeof result.resolved === 'number' && isNaN(result.resolved)) {
result.resolved = i18n.baseText('expressionModalInput.null');
}
return result;
}
const hoveringItem = computed(() => {
return ndvStore.hoveringItem;
});
const resolvableSegments = computed<Resolvable[]>(() => {
return segments.value.filter((s): s is Resolvable => s.kind === 'resolvable');
});
const plaintextSegments = computed<Plaintext[]>(() => {
return segments.value.filter((s): s is Plaintext => s.kind === 'plaintext');
});
const htmlSegments = computed<Html[]>(() => {
return segments.value.filter((s): s is Html => s.kind !== 'resolvable');
});
/**
* Segments to display in the output of an expression editor.
*
* Some segments are not displayed when they are _part_ of the result,
* but displayed when they are the _entire_ result:
*
* - `This is a {{ [] }} test` displays as `This is a test`.
* - `{{ [] }}` displays as `[Array: []]`.
*
* Some segments display differently based on context:
*
* Date displays as
* - `Mon Nov 14 2022 17:26:13 GMT+0100 (CST)` when part of the result
* - `[Object: "2022-11-14T17:26:13.130Z"]` when the entire result
*
* Only needed in order to mimic behavior of `ParameterInputHint`.
*/
const displayableSegments = computed<Segment[]>(() => {
const cachedSegments = segments.value;
return cachedSegments
.map((s) => {
if (cachedSegments.length <= 1 || s.kind !== 'resolvable') return s;
if (typeof s.resolved === 'string' && /\[Object: "\d{4}-\d{2}-\d{2}T/.test(s.resolved)) {
const utcDateString = s.resolved.replace(/(\[Object: "|\"\])/g, '');
s.resolved = new Date(utcDateString).toString();
}
if (typeof s.resolved === 'string' && /\[Array:\s\[.+\]\]/.test(s.resolved)) {
s.resolved = s.resolved.replace(/(\[Array: \[|\])/g, '');
}
return s;
})
.filter((s) => {
if (
cachedSegments.length > 1 &&
s.kind === 'resolvable' &&
typeof s.resolved === 'string' &&
(s.resolved === '[Array: []]' ||
s.resolved === i18n.baseText('expressionModalInput.empty'))
) {
return false;
}
return true;
});
});
watch(
[
() => workflowsStore.getWorkflowExecution,
() => workflowsStore.getWorkflowRunData,
() => ndvStore.hoveringItemNumber,
],
debouncedUpdateSegments,
);
watch(resolvableSegments, updateHighlighting);
function setCursorPosition(pos: number | 'lastExpression' | 'end'): void {
if (pos === 'lastExpression') {
const END_OF_EXPRESSION = ' }}';
pos = Math.max(readEditorValue().lastIndexOf(END_OF_EXPRESSION), 0);
} else if (pos === 'end') {
pos = editor.value?.state.doc.length ?? 0;
}
editor.value?.dispatch({ selection: { head: pos, anchor: pos } });
}
function select(anchor: number, head: number | 'end' = 'end'): void {
editor.value?.dispatch({
selection: { anchor, head: head === 'end' ? editor.value?.state.doc.length ?? 0 : head },
});
}
const selectAll = () => select(0, 'end');
function focus(): void {
if (hasFocus.value) return;
editor.value?.focus();
}
return {
editor,
hasFocus,
segments: {
all: segments,
html: htmlSegments,
display: displayableSegments,
plaintext: plaintextSegments,
resolvable: resolvableSegments,
},
readEditorValue,
setCursorPosition,
select,
selectAll,
focus,
};
};