Huge News!Announcing our $40M Series B led by Abstract Ventures.Learn More
Socket
Sign inDemoInstall
Socket

@blocksuite/virgo

Package Overview
Dependencies
Maintainers
5
Versions
509
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@blocksuite/virgo - npm Package Compare versions

Comparing version 0.5.0-alpha.0 to 0.5.0-alpha.1

dist/components/index.d.ts

41

package.json
{
"name": "@blocksuite/virgo",
"version": "0.5.0-alpha.0",
"version": "0.5.0-alpha.1",
"description": "A micro editor.",
"main": "src/index.ts",
"main": "dist/index.js",
"type": "module",
"scripts": {
"build": "tsc",
"test:unit": "vitest --run",
"test:unit:coverage": "vitest run --coverage",
"test:unit:ui": "vitest --ui",
"test:e2e": "playwright test",
"test": "pnpm test:unit && pnpm test:e2e"
},
"keywords": [],

@@ -20,3 +12,3 @@ "author": "toeverything",

"lit": "^2.6.1",
"yjs": "^13.5.46"
"yjs": "^13.5.48"
},

@@ -28,18 +20,21 @@ "peerDependencies": {

"exports": {
"./*": "./src/*.ts",
".": "./src/index.ts"
"./*": "./dist/*.js",
".": "./dist/index.js"
},
"publishConfig": {
"access": "public",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
"./*": "./dist/*.js",
".": "./dist/index.js"
}
"access": "public"
},
"dependencies": {
"@blocksuite/global": "workspace:*",
"@blocksuite/global": "0.5.0-alpha.1",
"zod": "^3.20.6"
}
}
},
"scripts": {
"build": "tsc",
"test:unit": "vitest --run",
"test:unit:coverage": "vitest run --coverage",
"test:unit:ui": "vitest --ui",
"test:e2e": "playwright test",
"test": "pnpm test:unit && pnpm test:e2e"
},
"types": "dist/index.d.ts"
}

@@ -1,3 +0,3 @@

export * from './base-text.js';
export * from './virgo-element.js';
export * from './virgo-line.js';
export * from './virgo-unit-text.js';
export * from './virgo-text.js';
import { html, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { TextElement } from '../types.js';
import type { BaseTextAttributes } from '../utils/index.js';
import type { VirgoElement } from './virgo-element.js';
@customElement('virgo-line')
export class VirgoLine extends LitElement {
@customElement('v-line')
export class VirgoLine<
TextAttributes extends BaseTextAttributes = BaseTextAttributes
> extends LitElement {
@property({ attribute: false })
elements: TextElement[] = [];
elements: VirgoElement<TextAttributes>[] = [];
get textLength() {
return this.elements.reduce((acc, el) => acc + el.delta.insert.length, 0);
}
get textContent() {
return this.elements.reduce((acc, el) => acc + el.delta.insert, '');
}
render() {
return html`
<style>
virgo-line {
return html`<style>
v-line {
display: block;
}
</style>
<div>${this.elements}</div>
`;
<div>${this.elements}</div>`;
}

@@ -29,4 +38,4 @@

interface HTMLElementTagNameMap {
'virgo-line': VirgoLine;
'v-line': VirgoLine;
}
}

@@ -9,2 +9,6 @@ import { getDefaultPlaygroundURL } from '@blocksuite/global/utils';

export async function press(page: Page, content: string) {
await page.keyboard.press(content, { delay: 50 });
}
export async function enterVirgoPlayground(page: Page) {

@@ -75,1 +79,25 @@ const url = new URL(

}
export async function getVirgoRichTextLine(
page: Page,
index: number,
i = 0
): Promise<readonly [string, number]> {
return await page.evaluate(
([index, i]) => {
const richTexts = document
.querySelector('test-page')
?.shadowRoot?.querySelectorAll('rich-text');
if (!richTexts) {
throw new Error('Cannot find rich-text');
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const editor = (richTexts[i] as any).vEditor as VEditor;
const line = editor.getLine(index);
return [line[0].textContent, line[1]] as const;
},
[index, i]
);
}

@@ -8,2 +8,4 @@ import { expect, test } from '@playwright/test';

getDeltaFromVirgoRichText,
getVirgoRichTextLine,
press,
setVirgoRichTextRange,

@@ -42,6 +44,7 @@ type,

await focusVirgoRichText(page);
await page.keyboard.press('Backspace');
await page.keyboard.press('Backspace');
await page.keyboard.press('Backspace');
await page.keyboard.press('Backspace');
await page.waitForTimeout(50);
await press(page, 'Backspace');
await press(page, 'Backspace');
await press(page, 'Backspace');
await press(page, 'Backspace');

@@ -62,4 +65,5 @@ expect(await editorA.innerText()).toBe('abc');

await focusVirgoRichText(page);
await page.keyboard.press('Enter');
await page.keyboard.press('Enter');
await page.waitForTimeout(50);
await press(page, 'Enter');
await press(page, 'Enter');
await type(page, 'bbb');

@@ -83,7 +87,8 @@

await focusVirgoRichText(page);
await page.keyboard.press('Backspace');
await page.keyboard.press('Backspace');
await page.keyboard.press('Backspace');
await page.keyboard.press('Backspace');
await page.keyboard.press('Backspace');
await page.waitForTimeout(50);
await press(page, 'Backspace');
await press(page, 'Backspace');
await press(page, 'Backspace');
await press(page, 'Backspace');
await press(page, 'Backspace');

@@ -103,7 +108,8 @@ expect(await editorA.innerText()).toBe('abc');

await focusVirgoRichText(page);
await page.keyboard.press('ArrowLeft');
await page.keyboard.press('ArrowLeft');
await page.waitForTimeout(50);
await press(page, 'ArrowLeft');
await press(page, 'ArrowLeft');
await type(page, 'bb');
await page.keyboard.press('ArrowRight');
await page.keyboard.press('ArrowRight');
await press(page, 'ArrowRight');
await press(page, 'ArrowRight');
await type(page, 'dd');

@@ -124,6 +130,7 @@

await focusVirgoRichText(page);
await page.keyboard.press('ArrowLeft');
await page.keyboard.press('ArrowLeft');
await page.keyboard.press('Enter');
await page.keyboard.press('Enter');
await page.waitForTimeout(50);
await press(page, 'ArrowLeft');
await press(page, 'ArrowLeft');
await press(page, 'Enter');
await press(page, 'Enter');

@@ -169,3 +176,3 @@ expect(await editorA.innerText()).toBe('abbbc\n' + ZERO_WIDTH_SPACE + '\ndd');

// eslint-disable-next-line @typescript-eslint/no-explicit-any
(richTextA as any).vEditor.setReadOnly(true);
(richTextA as any).vEditor.setReadonly(true);
});

@@ -189,4 +196,4 @@

const editorAUnderline = page.getByText('underline').nth(0);
const editorAStrikethrough = page.getByText('strikethrough').nth(0);
const editorAInlineCode = page.getByText('inline-code').nth(0);
const editorAStrike = page.getByText('strike').nth(0);
const editorACode = page.getByText('code').nth(0);

@@ -214,2 +221,3 @@ const editorAUndo = page.getByText('undo').nth(0);

editorABold.click();
page.waitForTimeout(50);
delta = await getDeltaFromVirgoRichText(page);

@@ -232,2 +240,3 @@ expect(delta).toEqual([

editorAItalic.click();
page.waitForTimeout(50);
delta = await getDeltaFromVirgoRichText(page);

@@ -251,2 +260,3 @@ expect(delta).toEqual([

editorAUnderline.click();
page.waitForTimeout(50);
delta = await getDeltaFromVirgoRichText(page);

@@ -270,3 +280,4 @@ expect(delta).toEqual([

editorAStrikethrough.click();
editorAStrike.click();
page.waitForTimeout(50);
delta = await getDeltaFromVirgoRichText(page);

@@ -283,3 +294,3 @@ expect(delta).toEqual([

underline: true,
strikethrough: true,
strike: true,
},

@@ -292,3 +303,4 @@ },

editorAInlineCode.click();
editorACode.click();
page.waitForTimeout(50);
delta = await getDeltaFromVirgoRichText(page);

@@ -305,4 +317,4 @@ expect(delta).toEqual([

underline: true,
strikethrough: true,
inlineCode: true,
strike: true,
code: true,
},

@@ -318,2 +330,3 @@ },

});
page.waitForTimeout(50);
delta = await getDeltaFromVirgoRichText(page);

@@ -329,2 +342,3 @@ expect(delta).toEqual([

});
page.waitForTimeout(50);
delta = await getDeltaFromVirgoRichText(page);

@@ -341,4 +355,4 @@ expect(delta).toEqual([

underline: true,
strikethrough: true,
inlineCode: true,
strike: true,
code: true,
},

@@ -352,2 +366,3 @@ },

editorABold.click();
page.waitForTimeout(50);
delta = await getDeltaFromVirgoRichText(page);

@@ -363,4 +378,4 @@ expect(delta).toEqual([

underline: true,
strikethrough: true,
inlineCode: true,
strike: true,
code: true,
},

@@ -374,2 +389,3 @@ },

editorAItalic.click();
page.waitForTimeout(50);
delta = await getDeltaFromVirgoRichText(page);

@@ -384,4 +400,4 @@ expect(delta).toEqual([

underline: true,
strikethrough: true,
inlineCode: true,
strike: true,
code: true,
},

@@ -395,2 +411,3 @@ },

editorAUnderline.click();
page.waitForTimeout(50);
delta = await getDeltaFromVirgoRichText(page);

@@ -404,4 +421,4 @@ expect(delta).toEqual([

attributes: {
strikethrough: true,
inlineCode: true,
strike: true,
code: true,
},

@@ -414,3 +431,4 @@ },

editorAStrikethrough.click();
editorAStrike.click();
page.waitForTimeout(50);
delta = await getDeltaFromVirgoRichText(page);

@@ -424,3 +442,3 @@ expect(delta).toEqual([

attributes: {
inlineCode: true,
code: true,
},

@@ -433,3 +451,4 @@ },

editorAInlineCode.click();
editorACode.click();
page.waitForTimeout(50);
delta = await getDeltaFromVirgoRichText(page);

@@ -635,8 +654,9 @@ expect(delta).toEqual([

await focusVirgoRichText(page);
await page.keyboard.press('ArrowLeft');
await page.keyboard.press('ArrowLeft');
await page.keyboard.press('ArrowLeft');
await page.keyboard.press('ArrowLeft');
await page.waitForTimeout(50);
await press(page, 'ArrowLeft');
await press(page, 'ArrowLeft');
await press(page, 'ArrowLeft');
await press(page, 'ArrowLeft');
await page.keyboard.press('Enter');
await press(page, 'Enter');

@@ -658,5 +678,5 @@ expect(await editorA.innerText()).toBe('abc \n' + ' def');

await type(page, 'abc');
await page.keyboard.press('Enter', { delay: 50 });
await press(page, 'Enter');
await type(page, 'def');
await page.keyboard.press('Enter', { delay: 50 });
await press(page, 'Enter');
await type(page, 'ghi');

@@ -672,5 +692,5 @@

*/
await page.keyboard.press('ArrowLeft');
await page.keyboard.press('ArrowLeft');
await page.keyboard.press('ArrowLeft');
await press(page, 'ArrowLeft');
await press(page, 'ArrowLeft');
await press(page, 'ArrowLeft');

@@ -683,4 +703,4 @@ /**

await page.keyboard.down('Shift');
await page.keyboard.press('ArrowUp');
await page.keyboard.press('ArrowUp');
await press(page, 'ArrowUp');
await press(page, 'ArrowUp');

@@ -692,4 +712,4 @@ /**

*/
await page.keyboard.press('ArrowRight');
await page.keyboard.press('Backspace');
await press(page, 'ArrowRight');
await press(page, 'Backspace');

@@ -699,1 +719,41 @@ expect(await editorA.innerText()).toBe('aghi');

});
test('getLine', async ({ page }) => {
await enterVirgoPlayground(page);
await focusVirgoRichText(page);
const editorA = page.locator('[data-virgo-root="true"]').nth(0);
const editorB = page.locator('[data-virgo-root="true"]').nth(1);
expect(await editorA.innerText()).toBe(ZERO_WIDTH_SPACE);
expect(await editorB.innerText()).toBe(ZERO_WIDTH_SPACE);
await type(page, 'abc');
await press(page, 'Enter');
await type(page, 'def');
await press(page, 'Enter');
await type(page, 'ghi');
expect(await editorA.innerText()).toBe('abc\ndef\nghi');
expect(await editorB.innerText()).toBe('abc\ndef\nghi');
const [line1, offset1] = await getVirgoRichTextLine(page, 0);
const [line2, offset2] = await getVirgoRichTextLine(page, 1);
const [line3, offset3] = await getVirgoRichTextLine(page, 4);
const [line4, offset4] = await getVirgoRichTextLine(page, 5);
const [line5, offset5] = await getVirgoRichTextLine(page, 8);
const [line6, offset6] = await getVirgoRichTextLine(page, 11);
expect(line1).toEqual('abc');
expect(offset1).toEqual(0);
expect(line2).toEqual('abc');
expect(offset2).toEqual(1);
expect(line3).toEqual('def');
expect(offset3).toEqual(0);
expect(line4).toEqual('def');
expect(offset4).toEqual(1);
expect(line5).toEqual('ghi');
expect(offset5).toEqual(0);
expect(line6).toEqual('ghi');
expect(offset6).toEqual(3);
});

@@ -1,18 +0,15 @@

import type { BaseText, BaseTextAttributes } from './components/base-text.js';
import type { TemplateResult } from 'lit';
export interface CustomTypes {
[key: string]: unknown;
}
import type { VText } from './components/index.js';
import type { BaseTextAttributes } from './utils/index.js';
type ExtendableKeys = 'Element' | 'Attributes';
type ExtendedType<K extends ExtendableKeys, B> = unknown extends CustomTypes[K]
? B
: CustomTypes[K];
export type TextAttributes = ExtendedType<'Attributes', BaseTextAttributes>;
export type TextElement = ExtendedType<'Element', BaseText>;
export type DeltaInsert<A extends TextAttributes = TextAttributes> = {
export type DeltaInsert<
TextAttributes extends BaseTextAttributes = BaseTextAttributes
> = {
insert: string;
attributes?: A;
attributes?: TextAttributes;
};
export type AttributesRenderer<
TextAttributes extends BaseTextAttributes = BaseTextAttributes
> = (vText: VText, attributes?: TextAttributes) => TemplateResult<1>;
import type { DeltaInsert } from '../types.js';
import type { BaseTextAttributes } from './base-attributes.js';
export function transformDelta(delta: DeltaInsert): (DeltaInsert | '\n')[] {
const result: (DeltaInsert | '\n')[] = [];
export function transformDelta<TextAttributes extends BaseTextAttributes>(
delta: DeltaInsert<TextAttributes>
): (DeltaInsert<TextAttributes> | '\n')[] {
const result: (DeltaInsert<TextAttributes> | '\n')[] = [];

@@ -34,3 +37,5 @@ let tmpString = delta.insert;

*/
export function deltaInsertsToChunks(delta: DeltaInsert[]): DeltaInsert[][] {
export function deltaInsertsToChunks<TextAttributes extends BaseTextAttributes>(
delta: DeltaInsert<TextAttributes>[]
): DeltaInsert<TextAttributes>[][] {
if (delta.length === 0) {

@@ -42,3 +47,3 @@ return [[]];

function* chunksGenerator(arr: (DeltaInsert | '\n')[]) {
function* chunksGenerator(arr: (DeltaInsert<TextAttributes> | '\n')[]) {
let start = 0;

@@ -49,5 +54,5 @@ for (let i = 0; i < arr.length; i++) {

start = i + 1;
yield chunk as DeltaInsert[];
yield chunk as DeltaInsert<TextAttributes>[];
} else if (i === arr.length - 1) {
yield arr.slice(start) as DeltaInsert[];
yield arr.slice(start) as DeltaInsert<TextAttributes>[];
}

@@ -54,0 +59,0 @@ }

@@ -0,1 +1,4 @@

export * from './attributes-renderer.js';
export * from './base-attributes.js';
export * from './convert.js';
export * from './renderer.js';

@@ -1,10 +0,14 @@

import { assertExists, Signal } from '@blocksuite/global/utils';
import { assertExists, Slot } from '@blocksuite/global/utils';
import type * as Y from 'yjs';
import type { z } from 'zod';
import { BaseText } from './components/base-text.js';
import { VirgoElement } from './components/virgo-element.js';
import { VirgoLine } from './components/virgo-line.js';
import { ZERO_WIDTH_SPACE } from './constant.js';
import type { DeltaInsert, TextAttributes, TextElement } from './types.js';
import type { AttributesRenderer, DeltaInsert } from './types.js';
import { getDefaultAttributeRenderer } from './utils/attributes-renderer.js';
import { deltaInsertsToChunks } from './utils/convert.js';
import { baseRenderElement } from './utils/render.js';
import type { BaseTextAttributes } from './utils/index.js';
import { baseTextAttributes } from './utils/index.js';
import { renderElement } from './utils/renderer.js';

@@ -16,7 +20,14 @@ export interface VRange {

export type UpdateVRangeProp = [VRange | null, 'native' | 'input' | 'other'];
export type UpdateVRangeProp = [
range: VRange | null,
type: 'native' | 'input' | 'other'
];
export type DeltaEntry = [DeltaInsert, VRange];
export type DeltaEntry = [delta: DeltaInsert, range: VRange];
interface DomPoint {
// corresponding to [anchorNode/focusNode, anchorOffset/focusOffset]
export type NativePoint = readonly [node: Node, offset: number];
// the number here is relative to the text node
export type TextPoint = readonly [text: Text, offset: number];
export interface DomPoint {
// which text node this point is in

@@ -28,45 +39,297 @@ text: Text;

export class VEditor {
export class VEditor<
TextAttributes extends BaseTextAttributes = BaseTextAttributes
> {
static nativePointToTextPoint(
node: unknown,
offset: number
): TextPoint | null {
let text: Text | null = null;
let textOffset = offset;
if (isVText(node)) {
text = node;
textOffset = offset;
} else if (isVElement(node)) {
const texts = VEditor.getTextNodesFromElement(node);
for (let i = 0; i < texts.length; i++) {
if (offset <= texts[i].length) {
text = texts[i];
textOffset = offset;
break;
}
offset -= texts[i].length;
}
} else if (isVLine(node) || isVRoot(node)) {
const texts = VEditor.getTextNodesFromElement(node);
if (texts.length > 0) {
text = texts[0];
textOffset = offset === 0 ? offset : text.length;
}
} else {
if (node instanceof Node) {
const vLine = node.parentElement?.closest('v-line');
if (vLine) {
const vElements = Array.from(vLine.querySelectorAll('v-element'));
for (let i = 0; i < vElements.length; i++) {
if (
node.compareDocumentPosition(vElements[i]) ===
Node.DOCUMENT_POSITION_CONTAINED_BY ||
node.compareDocumentPosition(vElements[i]) === 20
) {
const texts = VEditor.getTextNodesFromElement(vElements[0]);
if (texts.length === 0) return null;
for (let i = 0; i < texts.length; i++) {
text = texts[i];
textOffset = offset === 0 ? offset : text.length;
}
break;
}
if (
i === 0 &&
node.compareDocumentPosition(vElements[i]) ===
Node.DOCUMENT_POSITION_FOLLOWING
) {
const texts = VEditor.getTextNodesFromElement(vElements[i]);
if (texts.length === 0) return null;
text = texts[0];
textOffset = offset === 0 ? offset : text.length;
break;
} else if (
i === vElements.length - 1 &&
node.compareDocumentPosition(vElements[i]) ===
Node.DOCUMENT_POSITION_PRECEDING
) {
const texts = VEditor.getTextNodesFromElement(vElements[i]);
if (texts.length === 0) return null;
text = texts[0];
textOffset = calculateTextLength(text);
break;
}
if (
i < vElements.length - 1 &&
node.compareDocumentPosition(vElements[i]) ===
Node.DOCUMENT_POSITION_PRECEDING &&
node.compareDocumentPosition(vElements[i + 1]) ===
Node.DOCUMENT_POSITION_FOLLOWING
) {
const texts = VEditor.getTextNodesFromElement(vElements[i]);
if (texts.length === 0) return null;
text = texts[0];
textOffset = calculateTextLength(text);
break;
}
}
}
}
}
if (!text) {
return null;
}
return [text, textOffset] as const;
}
static textPointToDomPoint(
text: Text,
offset: number,
rootElement: HTMLElement
): DomPoint | null {
if (rootElement.dataset.virgoRoot !== 'true') {
throw new Error(
'textRangeToDomPoint should be called with editor root element'
);
}
if (!rootElement.contains(text)) {
return null;
}
const texts = VEditor.getTextNodesFromElement(rootElement);
const goalIndex = texts.indexOf(text);
let index = 0;
for (const text of texts.slice(0, goalIndex)) {
index += calculateTextLength(text);
}
if (text.wholeText !== ZERO_WIDTH_SPACE) {
index += offset;
}
const textParentElement = text.parentElement;
if (!textParentElement) {
throw new Error('text element parent not found');
}
const lineElement = textParentElement.closest('v-line');
if (!lineElement) {
throw new Error('line element not found');
}
const lineIndex = Array.from(
rootElement.querySelectorAll('v-line')
).indexOf(lineElement);
return { text, index: index + lineIndex };
}
static getTextNodesFromElement(element: Element): Text[] {
const textSpanElements = Array.from(
element.querySelectorAll('[data-virgo-text="true"]')
);
const textNodes = textSpanElements.map(textSpanElement => {
const textNode = Array.from(textSpanElement.childNodes).find(
(node): node is Text => node instanceof Text
);
if (!textNode) {
throw new Error('text node not found');
}
return textNode;
});
return textNodes;
}
private _rootElement: HTMLElement | null = null;
private _rootElementAbort: AbortController | null = null;
private _mountAbortController: AbortController | null = null;
private _handlerAbortController: AbortController | null = null;
private _vRange: VRange | null = null;
private _isComposing = false;
private _isReadOnly = false;
private _renderElement: (delta: DeltaInsert) => TextElement =
baseRenderElement;
private _onKeyDown: (event: KeyboardEvent) => void = () => {
return;
private _isReadonly = false;
private _yText: Y.Text;
private _attributesRenderer: AttributesRenderer<TextAttributes> =
getDefaultAttributeRenderer<TextAttributes>();
private _attributesSchema: z.ZodSchema<TextAttributes> =
baseTextAttributes as z.ZodSchema<TextAttributes>;
private _handlers: {
keydown?: (event: KeyboardEvent) => void;
paste?: (event: ClipboardEvent) => void;
virgoInput?: (event: InputEvent) => boolean;
} = {};
private _defaultHandlers: VEditor['_handlers'] = {
paste: (event: ClipboardEvent) => {
const data = event.clipboardData?.getData('text/plain');
if (data) {
const vRange = this._vRange;
if (vRange) {
this.insertText(vRange, data);
this.slots.updateVRange.emit([
{
index: vRange.index + data.length,
length: 0,
},
'input',
]);
}
}
},
};
signals: {
updateVRange: Signal<UpdateVRangeProp>;
private _parseSchema = (textAttributes?: TextAttributes) => {
return this._attributesSchema.optional().parse(textAttributes);
};
yText: Y.Text;
constructor(
yText: VEditor['yText'],
opts: {
renderElement?: (delta: DeltaInsert) => TextElement;
onKeyDown?: (event: KeyboardEvent) => void;
} = {}
) {
this.yText = yText;
const { renderElement, onKeyDown } = opts;
private _renderDeltas = () => {
assertExists(this._rootElement);
if (renderElement) {
this._renderElement = renderElement;
}
const deltas = this.yText.toDelta() as DeltaInsert<TextAttributes>[];
const chunks = deltaInsertsToChunks(deltas);
if (onKeyDown) {
this._onKeyDown = onKeyDown;
// every chunk is a line
const lines = chunks.map(chunk => {
const virgoLine = new VirgoLine<TextAttributes>();
if (chunk.length === 0) {
virgoLine.elements.push(new VirgoElement());
} else {
chunk.forEach(delta => {
const element = renderElement(
delta,
this._parseSchema,
this._attributesRenderer
);
virgoLine.elements.push(element);
});
}
return virgoLine;
});
this._rootElement.replaceChildren(...lines);
};
slots: {
mounted: Slot;
unmounted: Slot;
updateVRange: Slot<UpdateVRangeProp>;
};
get yText() {
return this._yText;
}
get rootElement() {
assertExists(this._rootElement);
return this._rootElement;
}
constructor(yText: VEditor['yText']) {
if (!yText.doc) {
throw new Error('yText must be attached to a Y.Doc');
}
this.signals = {
updateVRange: new Signal<UpdateVRangeProp>(),
this._yText = yText;
this.slots = {
mounted: new Slot(),
unmounted: new Slot(),
updateVRange: new Slot<UpdateVRangeProp>(),
};
this.signals.updateVRange.on(this._onUpdateVRange);
this.slots.updateVRange.on(this._onUpdateVRange);
}
mount(rootElement: HTMLElement): void {
setAttributesSchema = (schema: z.ZodSchema<TextAttributes>) => {
this._attributesSchema = schema;
};
setAttributesRenderer = (renderer: AttributesRenderer<TextAttributes>) => {
this._attributesRenderer = renderer;
};
bindHandlers(handlers: VEditor['_handlers'] = this._defaultHandlers) {
this._handlers = handlers;
if (this._handlerAbortController) {
this._handlerAbortController.abort();
}
this._handlerAbortController = new AbortController();
assertExists(this._rootElement, 'you need to mount the editor first');
if (this._handlers.paste) {
this._rootElement.addEventListener('paste', this._handlers.paste, {
signal: this._handlerAbortController.signal,
});
}
if (this._handlers.keydown) {
this._rootElement.addEventListener('keydown', this._handlers.keydown, {
signal: this._handlerAbortController.signal,
});
}
}
mount(rootElement: HTMLElement) {
this._rootElement = rootElement;

@@ -79,15 +342,12 @@ this._rootElement.replaceChildren();

this._rootElementAbort = new AbortController();
this._mountAbortController = new AbortController();
const deltas = this.yText.toDelta() as DeltaInsert[];
renderDeltas(deltas, this._rootElement, this._renderElement);
this._renderDeltas();
this._rootElement.addEventListener(
'beforeinput',
this._onBeforeInput.bind(this),
{
signal: this._rootElementAbort.signal,
}
);
this._rootElement
const signal = this._mountAbortController.signal;
rootElement.addEventListener('beforeinput', this._onBeforeInput, {
signal,
});
rootElement
.querySelectorAll('[data-virgo-text="true"]')

@@ -100,37 +360,41 @@ .forEach(textNode => {

this._rootElement.addEventListener(
'compositionstart',
this._onCompositionStart.bind(this),
{
signal: this._rootElementAbort.signal,
}
);
this._rootElement.addEventListener(
'compositionend',
this._onCompositionEnd.bind(this),
{
signal: this._rootElementAbort.signal,
}
);
this._rootElement.addEventListener('keydown', this._onKeyDown, {
signal: this._rootElementAbort.signal,
rootElement.addEventListener('compositionstart', this._onCompositionStart, {
signal,
});
this._rootElement.addEventListener('paste', this._onPaste, {
signal: this._rootElementAbort.signal,
rootElement.addEventListener('compositionend', this._onCompositionEnd, {
signal,
});
this.slots.mounted.emit();
}
unmount(): void {
unmount() {
document.removeEventListener('selectionchange', this._onSelectionChange);
if (this._rootElementAbort) {
this._rootElementAbort.abort();
this._rootElementAbort = null;
if (this._mountAbortController) {
this._mountAbortController.abort();
this._mountAbortController = null;
}
if (this._handlerAbortController) {
this._handlerAbortController.abort();
this._handlerAbortController = null;
}
this._handlers = this._defaultHandlers;
this._rootElement?.replaceChildren();
this._rootElement = null;
this.slots.unmounted.emit();
}
requestUpdate(): void {
Promise.resolve().then(() => {
assertExists(this._rootElement);
this._renderDeltas();
});
}
getNativeSelection(): Selection | null {

@@ -160,2 +424,51 @@ const selectionRoot = findDocumentOrShadowRoot(this);

getTextPoint(rangeIndex: VRange['index']): TextPoint {
assertExists(this._rootElement);
const vLines = Array.from(this._rootElement.querySelectorAll('v-line'));
let index = 0;
for (const vLine of vLines) {
const texts = VEditor.getTextNodesFromElement(vLine);
for (const text of texts) {
if (!text.textContent) {
throw new Error('text element should have textContent');
}
if (index + text.textContent.length >= rangeIndex) {
return [text, rangeIndex - index];
}
index += text.textContent.length;
}
index += 1;
}
throw new Error('failed to find leaf');
}
// the number is releated to the VirgoLine's textLength
getLine(rangeIndex: VRange['index']): readonly [VirgoLine, number] {
assertExists(this._rootElement);
const lineElements = Array.from(
this._rootElement.querySelectorAll('v-line')
);
let index = 0;
for (const lineElement of lineElements) {
if (rangeIndex >= index && rangeIndex <= index + lineElement.textLength) {
return [lineElement, rangeIndex - index] as const;
}
if (
rangeIndex === index + lineElement.textLength &&
rangeIndex === this.yText.length
) {
return [lineElement, rangeIndex - index] as const;
}
index += lineElement.textLength + 1;
}
throw new Error('failed to find line');
}
getDeltasByVRange(vRange: VRange): DeltaEntry[] {

@@ -170,3 +483,4 @@ const deltas = this.yText.toDelta() as DeltaInsert[];

index + delta.insert.length >= vRange.index &&
index < vRange.index + vRange.length
(index < vRange.index + vRange.length ||
(vRange.length === 0 && index === vRange.index))
) {

@@ -181,6 +495,2 @@ result.push([delta, { index, length: delta.insert.length }]);

getRootElement(): HTMLElement | null {
return this._rootElement;
}
getVRange(): VRange | null {

@@ -190,12 +500,41 @@ return this._vRange;

getReadOnly(): boolean {
return this._isReadOnly;
getFormat(vRange: VRange): TextAttributes {
const deltas = this.getDeltasByVRange(vRange).filter(
([delta, position]) =>
position.index + position.length > vRange.index &&
position.index <= vRange.index + vRange.length
);
const maybeAttributesArray = deltas.map(([delta]) => delta.attributes);
if (
!maybeAttributesArray.length ||
// some text does not have any attributes
maybeAttributesArray.some(attributes => !attributes)
) {
return {} as TextAttributes;
}
const attributesArray = maybeAttributesArray as TextAttributes[];
return attributesArray.reduce((acc, cur) => {
const newFormat = {} as TextAttributes;
for (const key in acc) {
const typedKey = key as keyof TextAttributes;
// If the given range contains multiple different formats
// such as links with different values,
// we will treat it as having no format
if (acc[typedKey] === cur[typedKey]) {
// This cast is secure because we have checked that the value of the key is the same.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
newFormat[typedKey] = acc[typedKey] as any;
}
}
return newFormat;
});
}
setReadOnly(isReadOnly: boolean): void {
this._isReadOnly = isReadOnly;
setReadonly(isReadonly: boolean): void {
this.rootElement.contentEditable = isReadonly ? 'false' : 'true';
this._isReadonly = isReadonly;
}
setVRange(vRange: VRange): void {
this.signals.updateVRange.emit([vRange, 'other']);
this.slots.updateVRange.emit([vRange, 'other']);
}

@@ -218,6 +557,10 @@

insertText(vRange: VRange, text: string): void {
insertText(
vRange: VRange,
text: string,
attributes: TextAttributes = {} as TextAttributes
): void {
this._transact(() => {
this.yText.delete(vRange.index, vRange.length);
this.yText.insert(vRange.index, text);
this.yText.insert(vRange.index, text, attributes);
});

@@ -235,3 +578,5 @@ }

vRange: VRange,
attributes: TextAttributes,
attributes: Partial<
Record<keyof TextAttributes, TextAttributes[keyof TextAttributes] | null>
>,
options: {

@@ -242,3 +587,3 @@ match?: (delta: DeltaInsert, deltaVRange: VRange) => boolean;

): void {
const { match = () => true, mode = 'replace' } = options;
const { match = () => true, mode = 'merge' } = options;
const deltas = this.getDeltasByVRange(vRange);

@@ -321,5 +666,4 @@

assertExists(this._rootElement);
const lineElements = Array.from(
this._rootElement.querySelectorAll('virgo-line')
this._rootElement.querySelectorAll('v-line')
);

@@ -339,27 +683,19 @@

const textElements = Array.from(
lineElements[i].querySelectorAll('[data-virgo-text="true"]')
);
const texts = VEditor.getTextNodesFromElement(lineElements[i]);
for (const text of texts) {
const textLength = calculateTextLength(text);
for (let j = 0; j < textElements.length; j++) {
if (anchorText && focusText) {
break;
}
const textNode = getTextNodeFromElement(textElements[j]);
if (!textNode) {
return null;
}
const textLength = calculateTextLength(textNode);
if (!anchorText && index + textLength >= vRange.index) {
anchorText = textNode;
anchorText = text;
anchorOffset = vRange.index - index;
}
if (!focusText && index + textLength >= vRange.index + vRange.length) {
focusText = textNode;
focusText = text;
focusOffset = vRange.index + vRange.length - index;
}
if (anchorText && focusText) {
break;
}
index += textLength;

@@ -410,2 +746,3 @@ }

assertExists(this._rootElement);
const root = this._rootElement;

@@ -417,7 +754,7 @@ const { anchorNode, anchorOffset, focusNode, focusOffset } = selection;

const [anchorText, anchorTextOffset] = getTextAndOffset(
const anchorTextPoint = VEditor.nativePointToTextPoint(
anchorNode,
anchorOffset
);
const [focusText, focusTextOffset] = getTextAndOffset(
const focusTextPoint = VEditor.nativePointToTextPoint(
focusNode,

@@ -427,5 +764,12 @@ focusOffset

if (!anchorTextPoint || !focusTextPoint) {
return null;
}
const [anchorText, anchorTextOffset] = anchorTextPoint;
const [focusText, focusTextOffset] = focusTextPoint;
// case 1
if (anchorText && focusText) {
const anchorDomPoint = textPointToDomPoint(
if (root.contains(anchorText) && root.contains(focusText)) {
const anchorDomPoint = VEditor.textPointToDomPoint(
anchorText,

@@ -435,3 +779,3 @@ anchorTextOffset,

);
const focusDomPoint = textPointToDomPoint(
const focusDomPoint = VEditor.textPointToDomPoint(
focusText,

@@ -452,23 +796,33 @@ focusTextOffset,

// case 2
if (anchorText && !focusText) {
const anchorDomPoint = textPointToDomPoint(
anchorText,
anchorTextOffset,
this._rootElement
);
// case 2.1
if (!root.contains(anchorText) && root.contains(focusText)) {
if (isSelectionBackwards(selection)) {
const anchorDomPoint = VEditor.textPointToDomPoint(
anchorText,
anchorTextOffset,
this._rootElement
);
if (!anchorDomPoint) {
return null;
}
if (!anchorDomPoint) {
return null;
}
if (isSelectionBackwards(selection)) {
return {
index: 0,
length: anchorDomPoint.index,
index: anchorDomPoint.index,
length: this.yText.length - anchorDomPoint.index,
};
} else {
const focusDomPoint = VEditor.textPointToDomPoint(
focusText,
focusTextOffset,
this._rootElement
);
if (!focusDomPoint) {
return null;
}
return {
index: anchorDomPoint.index,
length: anchorDomPoint.text.wholeText.length - anchorDomPoint.index,
index: 0,
length: focusDomPoint.index,
};

@@ -478,23 +832,33 @@ }

// case 2
if (!anchorText && focusText) {
const focusDomPoint = textPointToDomPoint(
focusText,
focusTextOffset,
this._rootElement
);
// case 2.2
if (root.contains(anchorText) && !root.contains(focusText)) {
if (isSelectionBackwards(selection)) {
const focusDomPoint = VEditor.textPointToDomPoint(
focusText,
focusTextOffset,
this._rootElement
);
if (!focusDomPoint) {
return null;
}
if (!focusDomPoint) {
return null;
}
if (isSelectionBackwards(selection)) {
return {
index: focusDomPoint.index,
length: focusDomPoint.text.wholeText.length - focusDomPoint.index,
index: 0,
length: focusDomPoint.index,
};
} else {
const anchorDomPoint = VEditor.textPointToDomPoint(
anchorText,
anchorTextOffset,
this._rootElement
);
if (!anchorDomPoint) {
return null;
}
return {
index: 0,
length: focusDomPoint.index,
index: anchorDomPoint.index,
length: this.yText.length - anchorDomPoint.index,
};

@@ -505,7 +869,3 @@ }

// case 3
if (
!anchorText &&
!focusText &&
selection.containsNode(this._rootElement)
) {
if (!root.contains(anchorText) && !root.contains(focusText)) {
return {

@@ -520,21 +880,23 @@ index: 0,

private _onBeforeInput(event: InputEvent): void {
private _onBeforeInput = (event: InputEvent) => {
event.preventDefault();
if (this._isReadOnly) {
return;
}
if (this._isComposing) return;
if (!this._vRange) {
return;
let ifSkip = false;
if (this._handlers.virgoInput) {
ifSkip = this._handlers.virgoInput(event);
}
if (this._isReadonly) return;
if (ifSkip) return;
if (!this._vRange) return;
const { inputType, data } = event;
const currentVRange = this._vRange;
if (inputType === 'insertText' && this._vRange.index >= 0 && data) {
this.insertText(this._vRange, data);
this.signals.updateVRange.emit([
if (inputType === 'insertText' && currentVRange.index >= 0 && data) {
this.slots.updateVRange.emit([
{
index: this._vRange.index + data.length,
index: currentVRange.index + data.length,
length: 0,

@@ -544,8 +906,8 @@ },

]);
} else if (inputType === 'insertParagraph' && this._vRange.index >= 0) {
this.insertLineBreak(this._vRange);
this.signals.updateVRange.emit([
this.insertText(currentVRange, data);
} else if (inputType === 'insertParagraph' && currentVRange.index >= 0) {
this.slots.updateVRange.emit([
{
index: this._vRange.index + 1,
index: currentVRange.index + 1,
length: 0,

@@ -555,12 +917,12 @@ },

]);
this.insertLineBreak(currentVRange);
} else if (
inputType === 'deleteContentBackward' &&
this._vRange.index >= 0
currentVRange.index >= 0
) {
if (this._vRange.length > 0) {
this.deleteText(this._vRange);
this.signals.updateVRange.emit([
if (currentVRange.length > 0) {
this.slots.updateVRange.emit([
{
index: this._vRange.index,
index: currentVRange.index,
length: 0,

@@ -570,14 +932,11 @@ },

]);
} else if (this._vRange.index > 0) {
this.deleteText(currentVRange);
} else if (currentVRange.index > 0) {
// https://dev.to/acanimal/how-to-slice-or-get-symbols-from-a-unicode-string-with-emojis-in-javascript-lets-learn-how-javascript-represent-strings-h3a
const tmpString = this.yText.toString().slice(0, this._vRange.index);
const tmpString = this.yText.toString().slice(0, currentVRange.index);
const deletedCharacter = [...tmpString].slice(-1).join('');
this.deleteText({
index: this._vRange.index - deletedCharacter.length,
length: deletedCharacter.length,
});
this.signals.updateVRange.emit([
this.slots.updateVRange.emit([
{
index: this._vRange.index - deletedCharacter.length,
index: currentVRange.index - deletedCharacter.length,
length: 0,

@@ -587,11 +946,35 @@ },

]);
this.deleteText({
index: currentVRange.index - deletedCharacter.length,
length: deletedCharacter.length,
});
}
} else if (inputType === 'deleteWordBackward') {
const matchs = /\S+\s*$/.exec(
this.yText.toString().substring(0, currentVRange.index)
);
if (!matchs) return;
const deleteLength = matchs[0].length;
this.slots.updateVRange.emit([
{
index: currentVRange.index - deleteLength,
length: 0,
},
'input',
]);
this.deleteText({
index: currentVRange.index - deleteLength,
length: deleteLength,
});
}
}
};
private _onCompositionStart(): void {
private _onCompositionStart = () => {
this._isComposing = true;
}
};
private _onCompositionEnd(event: CompositionEvent): void {
private _onCompositionEnd = (event: CompositionEvent) => {
this._isComposing = false;

@@ -608,3 +991,3 @@

this.signals.updateVRange.emit([
this.slots.updateVRange.emit([
{

@@ -617,12 +1000,10 @@ index: this._vRange.index + data.length,

}
}
};
private _onYTextChange = () => {
assertExists(this._rootElement);
Promise.resolve().then(() => {
assertExists(this._rootElement);
renderDeltas(
this.yText.toDelta() as DeltaInsert[],
this._rootElement,
this._renderElement
);
this._renderDeltas();
});
};

@@ -641,30 +1022,15 @@

const { anchorNode, focusNode } = selection;
if (
!this._rootElement.contains(anchorNode) ||
!this._rootElement.contains(focusNode)
) {
return;
}
const range = selection.getRangeAt(0);
if (!range || !range.intersectsNode(this._rootElement)) return;
const vRange = this.toVRange(selection);
if (vRange) {
this.signals.updateVRange.emit([vRange, 'native']);
this.slots.updateVRange.emit([vRange, 'native']);
}
};
private _onPaste = (event: ClipboardEvent) => {
const data = event.clipboardData?.getData('text/plain');
if (data) {
const vRange = this._vRange;
if (vRange) {
this.insertText(vRange, data);
this.signals.updateVRange.emit([
{
index: vRange.index + data.length,
length: 0,
},
'input',
]);
}
if (
range.startContainer.nodeType !== Node.TEXT_NODE ||
range.endContainer.nodeType !== Node.TEXT_NODE
) {
this.syncVRange();
}

@@ -702,3 +1068,3 @@ };

// updates in lit are performed asynchronously
setTimeout(fn, 0);
requestAnimationFrame(fn);
};

@@ -716,52 +1082,2 @@

function textPointToDomPoint(
text: Text,
offset: number,
rootElement: HTMLElement
): DomPoint | null {
if (rootElement.dataset.virgoRoot !== 'true') {
throw new Error(
'textRangeToDomPoint should be called with editor root element'
);
}
if (!rootElement.contains(text)) {
throw new Error('text is not in root element');
}
const textNodes = Array.from(
rootElement.querySelectorAll('[data-virgo-text="true"]')
).map(textElement => getTextNodeFromElement(textElement));
const goalIndex = textNodes.indexOf(text);
let index = 0;
for (const textNode of textNodes.slice(0, goalIndex)) {
if (!textNode) {
return null;
}
index += calculateTextLength(textNode);
}
if (text.wholeText !== ZERO_WIDTH_SPACE) {
index += offset;
}
const textElement = text.parentElement;
if (!textElement) {
throw new Error('text element not found');
}
const lineElement = textElement.closest('virgo-line');
if (!lineElement) {
throw new Error('line element not found');
}
const lineIndex = Array.from(
rootElement.querySelectorAll('virgo-line')
).indexOf(lineElement);
return { text, index: index + lineIndex };
}
function isSelectionBackwards(selection: Selection): boolean {

@@ -787,24 +1103,2 @@ let backwards = false;

function getTextNodeFromElement(element: Element): Text | null {
let spanElement: Element | null = element;
if (element instanceof HTMLElement && element.dataset.virgoText === 'true') {
spanElement = element;
} else {
spanElement = element.querySelector('[data-virgo-text="true"]');
}
if (!spanElement) {
return null;
}
const textNode = Array.from(spanElement.childNodes).find(
(node): node is Text => node instanceof Text
);
if (textNode) {
return textNode;
}
return null;
}
function isVText(text: unknown): text is Text {

@@ -824,35 +1118,13 @@ return (

function isVLine(element: unknown): element is HTMLElement {
return (
element instanceof HTMLElement && element.parentElement instanceof VirgoLine
);
return element instanceof HTMLElement && element instanceof VirgoLine;
}
function getTextAndOffset(node: unknown, offset: number) {
let text: Text | null = null;
let textOffset = offset;
if (isVText(node)) {
text = node;
textOffset = offset;
} else if (isVElement(node)) {
const textNode = getTextNodeFromElement(node);
if (textNode) {
text = textNode;
textOffset = offset;
}
} else if (isVLine(node)) {
const firstTextElement = node.querySelector('v-text');
if (firstTextElement) {
const textNode = getTextNodeFromElement(firstTextElement);
if (textNode) {
text = textNode;
textOffset = 0;
}
}
}
return [text, textOffset] as const;
function isVRoot(element: unknown): element is HTMLElement {
return element instanceof HTMLElement && element.dataset.virgoRoot === 'true';
}
function findDocumentOrShadowRoot(editor: VEditor): Document {
const el = editor.getRootElement();
function findDocumentOrShadowRoot<TextAttributes extends BaseTextAttributes>(
editor: VEditor<TextAttributes>
): Document {
const el = editor.rootElement;

@@ -874,28 +1146,1 @@ if (!el) {

}
function renderDeltas(
deltas: DeltaInsert[],
rootElement: HTMLElement,
render: (delta: DeltaInsert) => TextElement
) {
const chunks = deltaInsertsToChunks(deltas);
// every chunk is a line
const lines = chunks.map(chunk => {
const virgoLine = new VirgoLine();
if (chunk.length === 0) {
virgoLine.elements.push(new BaseText());
} else {
chunk.forEach(delta => {
const element = render(delta);
virgoLine.elements.push(element);
});
}
return virgoLine;
});
rootElement.replaceChildren(...lines);
}

@@ -10,5 +10,5 @@ import { defineConfig } from 'vitest/config';

reporter: ['lcov'],
reportsDirectory: '../../.coverage/store',
reportsDirectory: '../../.coverage/virgo',
},
},
});
SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc