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.4.0-alpha.3 to 0.4.0-alpha.4

dist/tests/convert.unit.spec.d.ts

31

dist/components/base-text.d.ts
import { LitElement } from 'lit';
import type { BaseArrtiubtes, DeltaInsert } from '../types.js';
import { z } from 'zod';
import type { DeltaInsert } from '../types.js';
export declare const baseTextAttributes: z.ZodOptional<z.ZodObject<{
bold: z.ZodOptional<z.ZodBoolean>;
italic: z.ZodOptional<z.ZodBoolean>;
underline: z.ZodOptional<z.ZodBoolean>;
strikethrough: z.ZodOptional<z.ZodBoolean>;
inlineCode: z.ZodOptional<z.ZodBoolean>;
color: z.ZodOptional<z.ZodString>;
link: z.ZodOptional<z.ZodString>;
}, "strip", z.ZodTypeAny, {
bold?: boolean | undefined;
italic?: boolean | undefined;
underline?: boolean | undefined;
strikethrough?: boolean | undefined;
inlineCode?: boolean | undefined;
color?: string | undefined;
link?: string | undefined;
}, {
bold?: boolean | undefined;
italic?: boolean | undefined;
underline?: boolean | undefined;
strikethrough?: boolean | undefined;
inlineCode?: boolean | undefined;
color?: string | undefined;
link?: string | undefined;
}>>;
export type BaseTextAttributes = z.infer<typeof baseTextAttributes>;
export declare class BaseText extends LitElement {
delta: DeltaInsert<BaseArrtiubtes>;
delta: DeltaInsert<BaseTextAttributes>;
render(): import("lit-html").TemplateResult<1>;

@@ -6,0 +33,0 @@ createRenderRoot(): this;

@@ -10,5 +10,19 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {

import { styleMap } from 'lit/directives/style-map.js';
import { z } from 'zod';
import { ZERO_WIDTH_SPACE } from '../constant.js';
import { VirgoUnitText } from './virgo-unit-text.js';
export const baseTextAttributes = z
.object({
bold: z.boolean().optional(),
italic: z.boolean().optional(),
underline: z.boolean().optional(),
strikethrough: z.boolean().optional(),
inlineCode: z.boolean().optional(),
color: z.string().optional(),
link: z.string().optional(),
})
.optional();
function virgoTextStyles(props) {
if (!props)
return styleMap({});
let textDecorations = '';

@@ -21,2 +35,14 @@ if (props.underline) {

}
let inlineCodeStyle = {};
if (props.inlineCode) {
inlineCodeStyle = {
'font-family': '"SFMono-Regular", Menlo, Consolas, "PT Mono", "Liberation Mono", Courier, monospace',
'line-height': 'normal',
background: 'rgba(135,131,120,0.15)',
color: '#EB5757',
'border-radius': '3px',
'font-size': '85%',
padding: '0.2em 0.4em',
};
}
return styleMap({

@@ -27,2 +53,3 @@ 'white-space': 'break-spaces',

'text-decoration': textDecorations.length > 0 ? textDecorations : 'none',
...inlineCodeStyle,
});

@@ -35,5 +62,2 @@ }

insert: ZERO_WIDTH_SPACE,
attributes: {
type: 'base',
},
};

@@ -43,3 +67,3 @@ }

const unitText = new VirgoUnitText();
unitText.delta = this.delta;
unitText.str = this.delta.insert;
// we need to avoid \n appearing before and after the span element, which will

@@ -46,0 +70,0 @@ // cause the unexpected space

1

dist/components/index.d.ts
export * from './base-text.js';
export * from './virgo-line.js';
export * from './virgo-unit-text.js';
export * from './optional/inline-code.js';
//# sourceMappingURL=index.d.ts.map
export * from './base-text.js';
export * from './virgo-line.js';
export * from './virgo-unit-text.js';
// optional elements
export * from './optional/inline-code.js';
//# sourceMappingURL=index.js.map
import { LitElement } from 'lit';
import type { BaseArrtiubtes, DeltaInsert } from '../types.js';
export declare class VirgoUnitText extends LitElement {
delta: DeltaInsert<BaseArrtiubtes>;
str: string;
render(): import("lit-html").TemplateResult<1>;

@@ -6,0 +5,0 @@ createRenderRoot(): this;

@@ -9,12 +9,11 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {

import { customElement, property } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { ZERO_WIDTH_SPACE } from '../constant.js';
const unitTextStyles = styleMap({
whiteSpace: 'pre',
});
let VirgoUnitText = class VirgoUnitText extends LitElement {
constructor() {
super(...arguments);
this.delta = {
insert: ZERO_WIDTH_SPACE,
attributes: {
type: 'base',
},
};
this.str = ZERO_WIDTH_SPACE;
}

@@ -24,3 +23,5 @@ render() {

// cause the sync problem about the cursor position
return html `<span data-virgo-text="true">${this.delta.insert}</span>`;
return html `<span style=${unitTextStyles} data-virgo-text="true"
>${this.str}</span
>`;
}

@@ -32,4 +33,4 @@ createRenderRoot() {

__decorate([
property({ type: Object })
], VirgoUnitText.prototype, "delta", void 0);
property()
], VirgoUnitText.prototype, "str", void 0);
VirgoUnitText = __decorate([

@@ -36,0 +37,0 @@ customElement('virgo-unit-text')

@@ -0,1 +1,2 @@

import { getDefaultPlaygroundURL } from '@blocksuite/global/utils';
export async function type(page, content) {

@@ -5,3 +6,3 @@ await page.keyboard.type(content, { delay: 50 });

export async function enterVirgoPlayground(page) {
const url = new URL('examples/virgo/index.html', 'http://localhost:5173/');
const url = new URL('examples/virgo/index.html', getDefaultPlaygroundURL(!!process.env.CI));
await page.goto(url.toString());

@@ -8,0 +9,0 @@ }

import { expect, test } from '@playwright/test';
import { ZERO_WIDTH_SPACE } from '../constant.js';
import { enterVirgoPlayground, focusVirgoRichText, getDeltaFromVirgoRichText, setVirgoRichTextRange, type, } from './utils/misc.js';
const ZERO_WIDTH_SPACE = '\u200B';
test('basic input', async ({ page }) => {

@@ -39,2 +39,3 @@ await enterVirgoPlayground(page);

await type(page, 'bbb');
page.waitForTimeout(100);
expect(await editorA.innerText()).toBe('abc\n' + ZERO_WIDTH_SPACE + '\nbbb');

@@ -113,3 +114,3 @@ expect(await editorB.innerText()).toBe('abc\n' + ZERO_WIDTH_SPACE + '\nbbb');

});
test('basic text style', async ({ page }) => {
test('basic styles', async ({ page }) => {
await enterVirgoPlayground(page);

@@ -124,3 +125,4 @@ await focusVirgoRichText(page);

const editorAInlineCode = page.getByText('inline-code').nth(0);
const editorAReset = page.getByText('reset').nth(0);
const editorAUndo = page.getByText('undo').nth(0);
const editorARedo = page.getByText('redo').nth(0);
expect(await editorA.innerText()).toBe(ZERO_WIDTH_SPACE);

@@ -135,5 +137,2 @@ expect(await editorB.innerText()).toBe(ZERO_WIDTH_SPACE);

insert: 'abcdefg',
attributes: {
type: 'base',
},
},

@@ -147,5 +146,2 @@ ]);

insert: 'ab',
attributes: {
type: 'base',
},
},

@@ -155,3 +151,2 @@ {

attributes: {
type: 'base',
bold: true,

@@ -162,5 +157,2 @@ },

insert: 'fg',
attributes: {
type: 'base',
},
},

@@ -173,5 +165,2 @@ ]);

insert: 'ab',
attributes: {
type: 'base',
},
},

@@ -181,3 +170,2 @@ {

attributes: {
type: 'base',
bold: true,

@@ -189,5 +177,2 @@ italic: true,

insert: 'fg',
attributes: {
type: 'base',
},
},

@@ -200,5 +185,2 @@ ]);

insert: 'ab',
attributes: {
type: 'base',
},
},

@@ -208,3 +190,2 @@ {

attributes: {
type: 'base',
bold: true,

@@ -217,5 +198,2 @@ italic: true,

insert: 'fg',
attributes: {
type: 'base',
},
},

@@ -228,10 +206,25 @@ ]);

insert: 'ab',
},
{
insert: 'cde',
attributes: {
type: 'base',
bold: true,
italic: true,
underline: true,
strikethrough: true,
},
},
{
insert: 'fg',
},
]);
editorAInlineCode.click();
delta = await getDeltaFromVirgoRichText(page);
expect(delta).toEqual([
{
insert: 'ab',
},
{
insert: 'cde',
attributes: {
type: 'base',
bold: true,

@@ -241,2 +234,3 @@ italic: true,

strikethrough: true,
inlineCode: true,
},

@@ -246,6 +240,34 @@ },

insert: 'fg',
},
]);
editorAUndo.click({
clickCount: 5,
});
delta = await getDeltaFromVirgoRichText(page);
expect(delta).toEqual([
{
insert: 'abcdefg',
},
]);
editorARedo.click({
clickCount: 5,
});
delta = await getDeltaFromVirgoRichText(page);
expect(delta).toEqual([
{
insert: 'ab',
},
{
insert: 'cde',
attributes: {
type: 'base',
bold: true,
italic: true,
underline: true,
strikethrough: true,
inlineCode: true,
},
},
{
insert: 'fg',
},
]);

@@ -257,5 +279,2 @@ editorABold.click();

insert: 'ab',
attributes: {
type: 'base',
},
},

@@ -265,6 +284,6 @@ {

attributes: {
type: 'base',
italic: true,
underline: true,
strikethrough: true,
inlineCode: true,
},

@@ -274,5 +293,2 @@ },

insert: 'fg',
attributes: {
type: 'base',
},
},

@@ -285,5 +301,2 @@ ]);

insert: 'ab',
attributes: {
type: 'base',
},
},

@@ -293,5 +306,5 @@ {

attributes: {
type: 'base',
underline: true,
strikethrough: true,
inlineCode: true,
},

@@ -301,5 +314,2 @@ },

insert: 'fg',
attributes: {
type: 'base',
},
},

@@ -312,5 +322,2 @@ ]);

insert: 'ab',
attributes: {
type: 'base',
},
},

@@ -320,4 +327,4 @@ {

attributes: {
type: 'base',
strikethrough: true,
inlineCode: true,
},

@@ -327,8 +334,21 @@ },

insert: 'fg',
},
]);
editorAStrikethrough.click();
delta = await getDeltaFromVirgoRichText(page);
expect(delta).toEqual([
{
insert: 'ab',
},
{
insert: 'cde',
attributes: {
type: 'base',
inlineCode: true,
},
},
{
insert: 'fg',
},
]);
editorAStrikethrough.click();
editorAInlineCode.click();
delta = await getDeltaFromVirgoRichText(page);

@@ -338,50 +358,184 @@ expect(delta).toEqual([

insert: 'abcdefg',
},
]);
});
test('overlapping styles', 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);
const editorABold = page.getByText('bold').nth(0);
const editorAItalic = page.getByText('italic').nth(0);
const editorAUndo = page.getByText('undo').nth(0);
const editorARedo = page.getByText('redo').nth(0);
expect(await editorA.innerText()).toBe(ZERO_WIDTH_SPACE);
expect(await editorB.innerText()).toBe(ZERO_WIDTH_SPACE);
await type(page, 'abcdefghijk');
expect(await editorA.innerText()).toBe('abcdefghijk');
expect(await editorB.innerText()).toBe('abcdefghijk');
let delta = await getDeltaFromVirgoRichText(page);
expect(delta).toEqual([
{
insert: 'abcdefghijk',
},
]);
await setVirgoRichTextRange(page, { index: 1, length: 3 });
editorABold.click();
delta = await getDeltaFromVirgoRichText(page);
expect(delta).toEqual([
{
insert: 'a',
},
{
insert: 'bcd',
attributes: {
type: 'base',
bold: true,
},
},
{
insert: 'efghijk',
},
]);
editorAReset.click();
await setVirgoRichTextRange(page, { index: 7, length: 3 });
editorABold.click();
delta = await getDeltaFromVirgoRichText(page);
expect(delta).toEqual([
{
insert: 'abcdefg',
insert: 'a',
},
{
insert: 'bcd',
attributes: {
type: 'base',
bold: true,
},
},
{
insert: 'efg',
},
{
insert: 'hij',
attributes: {
bold: true,
},
},
{
insert: 'k',
},
]);
editorAInlineCode.click();
await setVirgoRichTextRange(page, { index: 3, length: 5 });
editorAItalic.click();
delta = await getDeltaFromVirgoRichText(page);
expect(delta).toEqual([
{
insert: 'ab',
insert: 'a',
},
{
insert: 'bc',
attributes: {
type: 'base',
bold: true,
},
},
{
insert: 'cde',
insert: 'd',
attributes: {
type: 'inline-code',
bold: true,
italic: true,
},
},
{
insert: 'fg',
insert: 'efg',
attributes: {
type: 'base',
italic: true,
},
},
{
insert: 'h',
attributes: {
bold: true,
italic: true,
},
},
{
insert: 'ij',
attributes: {
bold: true,
},
},
{
insert: 'k',
},
]);
editorAInlineCode.click();
editorAUndo.click({
clickCount: 3,
});
delta = await getDeltaFromVirgoRichText(page);
expect(delta).toEqual([
{
insert: 'abcdefg',
insert: 'abcdefghijk',
},
]);
editorARedo.click({
clickCount: 3,
});
delta = await getDeltaFromVirgoRichText(page);
expect(delta).toEqual([
{
insert: 'a',
},
{
insert: 'bc',
attributes: {
type: 'base',
bold: true,
},
},
{
insert: 'd',
attributes: {
bold: true,
italic: true,
},
},
{
insert: 'efg',
attributes: {
italic: true,
},
},
{
insert: 'h',
attributes: {
bold: true,
italic: true,
},
},
{
insert: 'ij',
attributes: {
bold: true,
},
},
{
insert: 'k',
},
]);
});
test('input continuous spaces', 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 def');
expect(await editorA.innerText()).toBe('abc def');
expect(await editorB.innerText()).toBe('abc def');
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.keyboard.press('Enter');
expect(await editorA.innerText()).toBe('abc \n' + ' def');
expect(await editorB.innerText()).toBe('abc \n' + ' def');
});
//# sourceMappingURL=virgo.spec.js.map

@@ -1,15 +0,2 @@

import type { BaseText } from './components/base-text.js';
import type { InlineCode, InlineCodeAttributes } from './components/optional/inline-code.js';
export interface BaseArrtiubtes {
type: 'base';
bold?: true;
italic?: true;
underline?: true;
strikethrough?: true;
}
export interface LineBreakAttributes {
type: 'line-break';
}
export type BaseTextElement = BaseText | InlineCode;
export type BaseTextAttributes = BaseArrtiubtes | LineBreakAttributes | InlineCodeAttributes;
import type { BaseText, BaseTextAttributes } from './components/base-text.js';
export interface CustomTypes {

@@ -20,9 +7,9 @@ [key: string]: unknown;

type ExtendedType<K extends ExtendableKeys, B> = unknown extends CustomTypes[K] ? B : CustomTypes[K];
export type TextElement = ExtendedType<'Element', BaseTextElement>;
export type TextAttributes = ExtendedType<'Attributes', BaseTextAttributes>;
export type TextElement = ExtendedType<'Element', BaseText>;
export type DeltaInsert<A extends TextAttributes = TextAttributes> = {
insert: string;
attributes: A;
attributes?: A;
};
export {};
//# sourceMappingURL=types.d.ts.map
import type { DeltaInsert } from '../types.js';
export declare function transformDelta(delta: DeltaInsert): (DeltaInsert | '\n')[];
/**
* convert a delta insert array to chunks, each chunk is a line
*/
export declare function deltaInsersToChunks(delta: DeltaInsert[]): DeltaInsert[][];
export declare function deltaInsertsToChunks(delta: DeltaInsert[]): DeltaInsert[][];
//# sourceMappingURL=convert.d.ts.map

@@ -0,12 +1,36 @@

export function transformDelta(delta) {
const result = [];
let tmpString = delta.insert;
while (tmpString.length > 0) {
const index = tmpString.indexOf('\n');
if (index === -1) {
result.push({
insert: tmpString,
attributes: delta.attributes,
});
break;
}
if (tmpString.slice(0, index).length > 0) {
result.push({
insert: tmpString.slice(0, index),
attributes: delta.attributes,
});
}
result.push('\n');
tmpString = tmpString.slice(index + 1);
}
return result;
}
/**
* convert a delta insert array to chunks, each chunk is a line
*/
export function deltaInsersToChunks(delta) {
export function deltaInsertsToChunks(delta) {
if (delta.length === 0) {
return [[]];
}
const transformedDelta = delta.flatMap(transformDelta);
function* chunksGenerator(arr) {
let start = 0;
for (let i = 0; i < arr.length; i++) {
if (arr[i].attributes.type === 'line-break') {
if (arr[i] === '\n') {
const chunk = arr.slice(start, i);

@@ -20,8 +44,8 @@ start = i + 1;

}
if (arr[arr.length - 1].attributes.type === 'line-break') {
if (arr.at(-1) === '\n') {
yield [];
}
}
return [...chunksGenerator(delta)];
return [...chunksGenerator(transformedDelta)];
}
//# sourceMappingURL=convert.js.map

@@ -1,2 +0,2 @@

import { BaseText } from '../components/base-text.js';
import { BaseText, baseTextAttributes } from '../components/base-text.js';
/**

@@ -6,12 +6,10 @@ * a default render function for text element

export function baseRenderElement(delta) {
switch (delta.attributes.type) {
case 'base': {
const baseText = new BaseText();
baseText.delta = delta;
return baseText;
}
default:
throw new Error(`Unknown text type: ${delta.attributes.type}`);
}
const parseResult = baseTextAttributes.parse(delta.attributes);
const baseText = new BaseText();
baseText.delta = {
insert: delta.insert,
attributes: parseResult,
};
return baseText;
}
//# sourceMappingURL=render.js.map

@@ -28,3 +28,2 @@ import { Signal } from '@blocksuite/global/utils';

unmount(): void;
getBaseElement(node: Node): TextElement | null;
getNativeSelection(): Selection | null;

@@ -42,3 +41,3 @@ getDeltaByRangeIndex(rangeIndex: VRange['index']): DeltaInsert | null;

insertLineBreak(vRange: VRange): void;
formatText(vRange: VRange, attributes: TextAttributes, options?: {
formatText(vRange: VRange, attributes: NonNullable<TextAttributes>, options?: {
match?: (delta: DeltaInsert, deltaVRange: VRange) => boolean;

@@ -45,0 +44,0 @@ mode?: 'replace' | 'merge';

@@ -5,3 +5,3 @@ import { assertExists, Signal } from '@blocksuite/global/utils';

import { ZERO_WIDTH_SPACE } from './constant.js';
import { deltaInsersToChunks } from './utils/convert.js';
import { deltaInsertsToChunks } from './utils/convert.js';
import { baseRenderElement } from './utils/render.js';

@@ -21,11 +21,3 @@ export class VEditor {

assertExists(this._rootElement);
const deltas = this.yText.toDelta().flatMap(d => {
if (d.attributes.type === 'line-break') {
return d.insert
.split('')
.map(c => ({ insert: c, attributes: d.attributes }));
}
return d;
});
renderDeltas(deltas, this._rootElement, this._renderElement);
renderDeltas(this.yText.toDelta(), this._rootElement, this._renderElement);
};

@@ -78,3 +70,5 @@ this._onSelectionChange = () => {

if (onKeyDown) {
this._onKeyDown = onKeyDown;
this._onKeyDown = e => {
onKeyDown(e);
};
}

@@ -125,9 +119,2 @@ this.signals = {

}
getBaseElement(node) {
const element = node.parentElement?.closest('[data-virgo-element="true"]');
if (element) {
return element;
}
return null;
}
getNativeSelection() {

@@ -198,15 +185,6 @@ const selectionRoot = findDocumentOrShadowRoot(this);

}
// TODO add support for formatting
insertText(vRange, text) {
const currentDelta = this.getDeltaByRangeIndex(vRange.index);
this._transact(() => {
this.yText.delete(vRange.index, vRange.length);
if (vRange.index > 0 &&
currentDelta &&
currentDelta.attributes.type !== 'line-break') {
this.yText.insert(vRange.index, text, currentDelta.attributes);
}
else {
this.yText.insert(vRange.index, text, { type: 'base' });
}
this.yText.insert(vRange.index, text);
});

@@ -217,3 +195,3 @@ }

this.yText.delete(vRange.index, vRange.length);
this.yText.insert(vRange.index, '\n', { type: 'line-break' });
this.yText.insert(vRange.index, '\n');
});

@@ -225,5 +203,2 @@ }

for (const [delta, deltaVRange] of deltas) {
if (delta.attributes.type === 'line-break') {
continue;
}
if (match(delta, deltaVRange)) {

@@ -251,7 +226,8 @@ const targetVRange = {

}
const unset = Object.fromEntries(coverDeltas.flatMap(delta => Object.keys(delta.attributes).map(key => [key, null])));
const unset = Object.fromEntries(coverDeltas.flatMap(delta => delta.attributes
? Object.keys(delta.attributes).map(key => [key, null])
: []));
this._transact(() => {
this.yText.format(vRange.index, vRange.length, {
...unset,
type: 'base',
});

@@ -264,3 +240,3 @@ });

syncVRange() {
setTimeout(() => {
requestAnimationFrame(() => {
if (this._vRange) {

@@ -612,3 +588,3 @@ const newRange = this.toDomRange(this._vRange);

function renderDeltas(deltas, rootElement, render) {
const chunks = deltaInsersToChunks(deltas);
const chunks = deltaInsertsToChunks(deltas);
// every chunk is a line

@@ -615,0 +591,0 @@ const lines = [];

{
"name": "@blocksuite/virgo",
"version": "0.4.0-alpha.3",
"version": "0.4.0-alpha.4",
"description": "A micro editor.",

@@ -11,8 +11,8 @@ "main": "dist/index.js",

"devDependencies": {
"yjs": "^13.5.45",
"lit": "^2.6.1"
"lit": "^2.6.1",
"yjs": "^13.5.46"
},
"peerDependencies": {
"yjs": "^13",
"lit": "^2"
"lit": "^2",
"yjs": "^13"
},

@@ -27,8 +27,14 @@ "exports": {

"dependencies": {
"@blocksuite/global": "0.4.0-alpha.3"
"@blocksuite/global": "0.4.0-alpha.4",
"zod": "^3.20.6"
},
"scripts": {
"build": "tsc"
"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"
}

@@ -5,3 +5,3 @@ # `@blocksuite/virgo`

Virgo is a minimized rich-text editing kernel that synchronizes the state between DOM and [Y.Text](https://docs.yjs.dev/api/shared-types/y.text), which differs from other rich-text editing frameworks in that its data model are _natively_ CRDT. For example, to support collaborative editing in Slate.js, you may need to use a plugin like slate-yjs, a wrapper around [Yjs](https://github.com/yjs/yjs). In these plugins, all text operations should be converted between Yjs and Slate.js operations. This may result in undo/redo properly and hard to maintain the code. However, with Virgo, we can directly synchronize the DOM state between Yjs and DOM, which means that the state in Yjs is the single source of truth. This means that to update, can just calling the `Y.Text` API to manipulate the DOM state, which could significantly reduces the complexity of the editor.
Virgo is a minimized rich-text editing kernel that synchronizes the state between DOM and [Y.Text](https://docs.yjs.dev/api/shared-types/y.text), which differs from other rich-text editing frameworks in that its data model are _natively_ CRDT. For example, to support collaborative editing in Slate.js, you may need to use a plugin like slate-yjs, a wrapper around [Yjs](https://github.com/yjs/yjs). In these plugins, all text operations should be converted between Yjs and Slate.js operations. This may result in undo/redo properly and hard to maintain the code. However, with Virgo, we can directly synchronize the DOM state between Yjs and DOM, which means that the state in Yjs is the single source of truth. It signify that to update, can just calling the `Y.Text` API to manipulate the DOM state, which could significantly reduces the complexity of the editor.

@@ -23,19 +23,4 @@ Initially in BlockSuite, we use [Quill](https://github.com/quilljs/quill) for in-block rich-text editing, which only utilizes a small subset of its APIs. Every paragraph in BlockSuite is managed in a standalone Quill instance, which is attached to a `Y.Text` instance for collaborative editing. Virgo makes this further simpler, since what it needs to do is the same as how we use the Quill subset. It just needs to provide a flat rich-text synchronization mechanism, since the block-tree-level state management is handled by the data store in BlockSuite.

{
insert: 'aaa',
attributes: {
type: 'base',
},
insert: 'aaa\nbbb',
},
{
insert: '\n',
attributes: {
type: 'line-break',
},
},
{
insert: 'bbb',
attributes: {
type: 'base',
},
},
];

@@ -58,3 +43,2 @@ */

attributes: {
type: 'base',
bold: true,

@@ -64,19 +48,4 @@ },

{
insert: 'a',
attributes: {
type: 'base',
},
insert: 'a\nbbb',
},
{
insert: '\n',
attributes: {
type: 'line-break',
},
},
{
insert: 'bbb',
attributes: {
type: 'base',
},
},
];

@@ -83,0 +52,0 @@ */

import { html, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { z } from 'zod';
import { ZERO_WIDTH_SPACE } from '../constant.js';
import type { BaseArrtiubtes, DeltaInsert } from '../types.js';
import type { DeltaInsert } from '../types.js';
import { VirgoUnitText } from './virgo-unit-text.js';
function virgoTextStyles(props: BaseArrtiubtes): ReturnType<typeof styleMap> {
export const baseTextAttributes = z
.object({
bold: z.boolean().optional(),
italic: z.boolean().optional(),
underline: z.boolean().optional(),
strikethrough: z.boolean().optional(),
inlineCode: z.boolean().optional(),
color: z.string().optional(),
link: z.string().optional(),
})
.optional();
export type BaseTextAttributes = z.infer<typeof baseTextAttributes>;
function virgoTextStyles(
props: BaseTextAttributes
): ReturnType<typeof styleMap> {
if (!props) return styleMap({});
let textDecorations = '';

@@ -18,2 +37,16 @@ if (props.underline) {

let inlineCodeStyle = {};
if (props.inlineCode) {
inlineCodeStyle = {
'font-family':
'"SFMono-Regular", Menlo, Consolas, "PT Mono", "Liberation Mono", Courier, monospace',
'line-height': 'normal',
background: 'rgba(135,131,120,0.15)',
color: '#EB5757',
'border-radius': '3px',
'font-size': '85%',
padding: '0.2em 0.4em',
};
}
return styleMap({

@@ -24,2 +57,3 @@ 'white-space': 'break-spaces',

'text-decoration': textDecorations.length > 0 ? textDecorations : 'none',
...inlineCodeStyle,
});

@@ -31,7 +65,4 @@ }

@property({ type: Object })
delta: DeltaInsert<BaseArrtiubtes> = {
delta: DeltaInsert<BaseTextAttributes> = {
insert: ZERO_WIDTH_SPACE,
attributes: {
type: 'base',
},
};

@@ -41,3 +72,3 @@

const unitText = new VirgoUnitText();
unitText.delta = this.delta;
unitText.str = this.delta.insert;

@@ -44,0 +75,0 @@ // we need to avoid \n appearing before and after the span element, which will

export * from './base-text.js';
export * from './virgo-line.js';
export * from './virgo-unit-text.js';
// optional elements
export * from './optional/inline-code.js';
import { html, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { ZERO_WIDTH_SPACE } from '../constant.js';
import type { BaseArrtiubtes, DeltaInsert } from '../types.js';
const unitTextStyles = styleMap({
whiteSpace: 'pre',
});
@customElement('virgo-unit-text')
export class VirgoUnitText extends LitElement {
@property({ type: Object })
delta: DeltaInsert<BaseArrtiubtes> = {
insert: ZERO_WIDTH_SPACE,
attributes: {
type: 'base',
},
};
@property()
str: string = ZERO_WIDTH_SPACE;

@@ -20,3 +19,5 @@ render() {

// cause the sync problem about the cursor position
return html`<span data-virgo-text="true">${this.delta.insert}</span>`;
return html`<span style=${unitTextStyles} data-virgo-text="true"
>${this.str}</span
>`;
}

@@ -23,0 +24,0 @@

@@ -0,1 +1,2 @@

import { getDefaultPlaygroundURL } from '@blocksuite/global/utils';
import type { DeltaInsert, VEditor, VRange } from '@blocksuite/virgo';

@@ -9,3 +10,6 @@ import type { Page } from '@playwright/test';

export async function enterVirgoPlayground(page: Page) {
const url = new URL('examples/virgo/index.html', 'http://localhost:5173/');
const url = new URL(
'examples/virgo/index.html',
getDefaultPlaygroundURL(!!process.env.CI)
);
await page.goto(url.toString());

@@ -12,0 +16,0 @@ }

import { expect, test } from '@playwright/test';
import { ZERO_WIDTH_SPACE } from '../constant.js';
import {

@@ -11,3 +12,2 @@ enterVirgoPlayground,

const ZERO_WIDTH_SPACE = '\u200B';
test('basic input', async ({ page }) => {

@@ -65,2 +65,4 @@ await enterVirgoPlayground(page);

page.waitForTimeout(100);
expect(await editorA.innerText()).toBe('abc\n' + ZERO_WIDTH_SPACE + '\nbbb');

@@ -172,3 +174,3 @@ expect(await editorB.innerText()).toBe('abc\n' + ZERO_WIDTH_SPACE + '\nbbb');

test('basic text style', async ({ page }) => {
test('basic styles', async ({ page }) => {
await enterVirgoPlayground(page);

@@ -185,4 +187,6 @@ await focusVirgoRichText(page);

const editorAInlineCode = page.getByText('inline-code').nth(0);
const editorAReset = page.getByText('reset').nth(0);
const editorAUndo = page.getByText('undo').nth(0);
const editorARedo = page.getByText('redo').nth(0);
expect(await editorA.innerText()).toBe(ZERO_WIDTH_SPACE);

@@ -200,5 +204,2 @@ expect(await editorB.innerText()).toBe(ZERO_WIDTH_SPACE);

insert: 'abcdefg',
attributes: {
type: 'base',
},
},

@@ -214,5 +215,2 @@ ]);

insert: 'ab',
attributes: {
type: 'base',
},
},

@@ -222,3 +220,2 @@ {

attributes: {
type: 'base',
bold: true,

@@ -229,5 +226,2 @@ },

insert: 'fg',
attributes: {
type: 'base',
},
},

@@ -241,5 +235,2 @@ ]);

insert: 'ab',
attributes: {
type: 'base',
},
},

@@ -249,3 +240,2 @@ {

attributes: {
type: 'base',
bold: true,

@@ -257,5 +247,2 @@ italic: true,

insert: 'fg',
attributes: {
type: 'base',
},
},

@@ -269,5 +256,2 @@ ]);

insert: 'ab',
attributes: {
type: 'base',
},
},

@@ -277,3 +261,2 @@ {

attributes: {
type: 'base',
bold: true,

@@ -286,5 +269,2 @@ italic: true,

insert: 'fg',
attributes: {
type: 'base',
},
},

@@ -298,10 +278,26 @@ ]);

insert: 'ab',
},
{
insert: 'cde',
attributes: {
type: 'base',
bold: true,
italic: true,
underline: true,
strikethrough: true,
},
},
{
insert: 'fg',
},
]);
editorAInlineCode.click();
delta = await getDeltaFromVirgoRichText(page);
expect(delta).toEqual([
{
insert: 'ab',
},
{
insert: 'cde',
attributes: {
type: 'base',
bold: true,

@@ -311,2 +307,3 @@ italic: true,

strikethrough: true,
inlineCode: true,
},

@@ -316,6 +313,36 @@ },

insert: 'fg',
},
]);
editorAUndo.click({
clickCount: 5,
});
delta = await getDeltaFromVirgoRichText(page);
expect(delta).toEqual([
{
insert: 'abcdefg',
},
]);
editorARedo.click({
clickCount: 5,
});
delta = await getDeltaFromVirgoRichText(page);
expect(delta).toEqual([
{
insert: 'ab',
},
{
insert: 'cde',
attributes: {
type: 'base',
bold: true,
italic: true,
underline: true,
strikethrough: true,
inlineCode: true,
},
},
{
insert: 'fg',
},
]);

@@ -328,5 +355,2 @@

insert: 'ab',
attributes: {
type: 'base',
},
},

@@ -336,6 +360,6 @@ {

attributes: {
type: 'base',
italic: true,
underline: true,
strikethrough: true,
inlineCode: true,
},

@@ -345,5 +369,2 @@ },

insert: 'fg',
attributes: {
type: 'base',
},
},

@@ -357,5 +378,2 @@ ]);

insert: 'ab',
attributes: {
type: 'base',
},
},

@@ -365,5 +383,5 @@ {

attributes: {
type: 'base',
underline: true,
strikethrough: true,
inlineCode: true,
},

@@ -373,5 +391,2 @@ },

insert: 'fg',
attributes: {
type: 'base',
},
},

@@ -385,5 +400,2 @@ ]);

insert: 'ab',
attributes: {
type: 'base',
},
},

@@ -393,4 +405,4 @@ {

attributes: {
type: 'base',
strikethrough: true,
inlineCode: true,
},

@@ -400,9 +412,23 @@ },

insert: 'fg',
},
]);
editorAStrikethrough.click();
delta = await getDeltaFromVirgoRichText(page);
expect(delta).toEqual([
{
insert: 'ab',
},
{
insert: 'cde',
attributes: {
type: 'base',
inlineCode: true,
},
},
{
insert: 'fg',
},
]);
editorAStrikethrough.click();
editorAInlineCode.click();
delta = await getDeltaFromVirgoRichText(page);

@@ -412,52 +438,207 @@ expect(delta).toEqual([

insert: 'abcdefg',
},
]);
});
test('overlapping styles', 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);
const editorABold = page.getByText('bold').nth(0);
const editorAItalic = page.getByText('italic').nth(0);
const editorAUndo = page.getByText('undo').nth(0);
const editorARedo = page.getByText('redo').nth(0);
expect(await editorA.innerText()).toBe(ZERO_WIDTH_SPACE);
expect(await editorB.innerText()).toBe(ZERO_WIDTH_SPACE);
await type(page, 'abcdefghijk');
expect(await editorA.innerText()).toBe('abcdefghijk');
expect(await editorB.innerText()).toBe('abcdefghijk');
let delta = await getDeltaFromVirgoRichText(page);
expect(delta).toEqual([
{
insert: 'abcdefghijk',
},
]);
await setVirgoRichTextRange(page, { index: 1, length: 3 });
editorABold.click();
delta = await getDeltaFromVirgoRichText(page);
expect(delta).toEqual([
{
insert: 'a',
},
{
insert: 'bcd',
attributes: {
type: 'base',
bold: true,
},
},
{
insert: 'efghijk',
},
]);
editorAReset.click();
await setVirgoRichTextRange(page, { index: 7, length: 3 });
editorABold.click();
delta = await getDeltaFromVirgoRichText(page);
expect(delta).toEqual([
{
insert: 'abcdefg',
insert: 'a',
},
{
insert: 'bcd',
attributes: {
type: 'base',
bold: true,
},
},
{
insert: 'efg',
},
{
insert: 'hij',
attributes: {
bold: true,
},
},
{
insert: 'k',
},
]);
editorAInlineCode.click();
await setVirgoRichTextRange(page, { index: 3, length: 5 });
editorAItalic.click();
delta = await getDeltaFromVirgoRichText(page);
expect(delta).toEqual([
{
insert: 'ab',
insert: 'a',
},
{
insert: 'bc',
attributes: {
type: 'base',
bold: true,
},
},
{
insert: 'cde',
insert: 'd',
attributes: {
type: 'inline-code',
bold: true,
italic: true,
},
},
{
insert: 'fg',
insert: 'efg',
attributes: {
type: 'base',
italic: true,
},
},
{
insert: 'h',
attributes: {
bold: true,
italic: true,
},
},
{
insert: 'ij',
attributes: {
bold: true,
},
},
{
insert: 'k',
},
]);
editorAInlineCode.click();
editorAUndo.click({
clickCount: 3,
});
delta = await getDeltaFromVirgoRichText(page);
expect(delta).toEqual([
{
insert: 'abcdefg',
insert: 'abcdefghijk',
},
]);
editorARedo.click({
clickCount: 3,
});
delta = await getDeltaFromVirgoRichText(page);
expect(delta).toEqual([
{
insert: 'a',
},
{
insert: 'bc',
attributes: {
type: 'base',
bold: true,
},
},
{
insert: 'd',
attributes: {
bold: true,
italic: true,
},
},
{
insert: 'efg',
attributes: {
italic: true,
},
},
{
insert: 'h',
attributes: {
bold: true,
italic: true,
},
},
{
insert: 'ij',
attributes: {
bold: true,
},
},
{
insert: 'k',
},
]);
});
test('input continuous spaces', 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 def');
expect(await editorA.innerText()).toBe('abc def');
expect(await editorB.innerText()).toBe('abc def');
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.keyboard.press('Enter');
expect(await editorA.innerText()).toBe('abc \n' + ' def');
expect(await editorB.innerText()).toBe('abc \n' + ' def');
});

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

import type { BaseText } from './components/base-text.js';
import type {
InlineCode,
InlineCodeAttributes,
} from './components/optional/inline-code.js';
import type { BaseText, BaseTextAttributes } from './components/base-text.js';
export interface BaseArrtiubtes {
type: 'base';
bold?: true;
italic?: true;
underline?: true;
strikethrough?: true;
}
export interface LineBreakAttributes {
type: 'line-break';
}
export type BaseTextElement = BaseText | InlineCode;
export type BaseTextAttributes =
| BaseArrtiubtes
| LineBreakAttributes
| InlineCodeAttributes;
export interface CustomTypes {

@@ -34,8 +12,8 @@ [key: string]: unknown;

export type TextElement = ExtendedType<'Element', BaseTextElement>;
export type TextAttributes = ExtendedType<'Attributes', BaseTextAttributes>;
export type TextElement = ExtendedType<'Element', BaseText>;
export type DeltaInsert<A extends TextAttributes = TextAttributes> = {
insert: string;
attributes: A;
attributes?: A;
};
import type { DeltaInsert } from '../types.js';
export function transformDelta(delta: DeltaInsert): (DeltaInsert | '\n')[] {
const result: (DeltaInsert | '\n')[] = [];
let tmpString = delta.insert;
while (tmpString.length > 0) {
const index = tmpString.indexOf('\n');
if (index === -1) {
result.push({
insert: tmpString,
attributes: delta.attributes,
});
break;
}
if (tmpString.slice(0, index).length > 0) {
result.push({
insert: tmpString.slice(0, index),
attributes: delta.attributes,
});
}
result.push('\n');
tmpString = tmpString.slice(index + 1);
}
return result;
}
/**
* convert a delta insert array to chunks, each chunk is a line
*/
export function deltaInsersToChunks(delta: DeltaInsert[]): DeltaInsert[][] {
export function deltaInsertsToChunks(delta: DeltaInsert[]): DeltaInsert[][] {
if (delta.length === 0) {

@@ -11,15 +39,17 @@ return [[]];

function* chunksGenerator(arr: DeltaInsert[]) {
const transformedDelta = delta.flatMap(transformDelta);
function* chunksGenerator(arr: (DeltaInsert | '\n')[]) {
let start = 0;
for (let i = 0; i < arr.length; i++) {
if (arr[i].attributes.type === 'line-break') {
if (arr[i] === '\n') {
const chunk = arr.slice(start, i);
start = i + 1;
yield chunk;
yield chunk as DeltaInsert[];
} else if (i === arr.length - 1) {
yield arr.slice(start);
yield arr.slice(start) as DeltaInsert[];
}
}
if (arr[arr.length - 1].attributes.type === 'line-break') {
if (arr.at(-1) === '\n') {
yield [];

@@ -29,3 +59,3 @@ }

return [...chunksGenerator(delta)];
return [...chunksGenerator(transformedDelta)];
}

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

import { BaseText } from '../components/base-text.js';
import type { BaseArrtiubtes, DeltaInsert, TextElement } from '../types.js';
import { BaseText, baseTextAttributes } from '../components/base-text.js';
import type { DeltaInsert, TextElement } from '../types.js';

@@ -8,11 +8,11 @@ /**

export function baseRenderElement(delta: DeltaInsert): TextElement {
switch (delta.attributes.type) {
case 'base': {
const baseText = new BaseText();
baseText.delta = delta as DeltaInsert<BaseArrtiubtes>;
return baseText;
}
default:
throw new Error(`Unknown text type: ${delta.attributes.type}`);
}
const parseResult = baseTextAttributes.parse(delta.attributes);
const baseText = new BaseText();
baseText.delta = {
insert: delta.insert,
attributes: parseResult,
};
return baseText;
}

@@ -8,3 +8,3 @@ import { assertExists, Signal } from '@blocksuite/global/utils';

import type { DeltaInsert, TextAttributes, TextElement } from './types.js';
import { deltaInsersToChunks } from './utils/convert.js';
import { deltaInsertsToChunks } from './utils/convert.js';
import { baseRenderElement } from './utils/render.js';

@@ -60,3 +60,5 @@

if (onKeyDown) {
this._onKeyDown = onKeyDown;
this._onKeyDown = e => {
onKeyDown(e);
};
}

@@ -131,12 +133,2 @@

getBaseElement(node: Node): TextElement | null {
const element = node.parentElement?.closest('[data-virgo-element="true"]');
if (element) {
return element as TextElement;
}
return null;
}
getNativeSelection(): Selection | null {

@@ -226,17 +218,6 @@ const selectionRoot = findDocumentOrShadowRoot(this);

// TODO add support for formatting
insertText(vRange: VRange, text: string): void {
const currentDelta = this.getDeltaByRangeIndex(vRange.index);
this._transact(() => {
this.yText.delete(vRange.index, vRange.length);
if (
vRange.index > 0 &&
currentDelta &&
currentDelta.attributes.type !== 'line-break'
) {
this.yText.insert(vRange.index, text, currentDelta.attributes);
} else {
this.yText.insert(vRange.index, text, { type: 'base' });
}
this.yText.insert(vRange.index, text);
});

@@ -248,3 +229,3 @@ }

this.yText.delete(vRange.index, vRange.length);
this.yText.insert(vRange.index, '\n', { type: 'line-break' });
this.yText.insert(vRange.index, '\n');
});

@@ -255,3 +236,3 @@ }

vRange: VRange,
attributes: TextAttributes,
attributes: NonNullable<TextAttributes>,
options: {

@@ -266,6 +247,2 @@ match?: (delta: DeltaInsert, deltaVRange: VRange) => boolean;

for (const [delta, deltaVRange] of deltas) {
if (delta.attributes.type === 'line-break') {
continue;
}
if (match(delta, deltaVRange)) {

@@ -307,3 +284,5 @@ const targetVRange = {

coverDeltas.flatMap(delta =>
Object.keys(delta.attributes).map(key => [key, null])
delta.attributes
? Object.keys(delta.attributes).map(key => [key, null])
: []
)

@@ -315,3 +294,2 @@ );

...unset,
type: 'base',
});

@@ -325,3 +303,3 @@ });

syncVRange(): void {
setTimeout(() => {
requestAnimationFrame(() => {
if (this._vRange) {

@@ -648,12 +626,7 @@ const newRange = this.toDomRange(this._vRange);

const deltas = (this.yText.toDelta() as DeltaInsert[]).flatMap(d => {
if (d.attributes.type === 'line-break') {
return d.insert
.split('')
.map(c => ({ insert: c, attributes: d.attributes }));
}
return d;
}) as DeltaInsert[];
renderDeltas(deltas, this._rootElement, this._renderElement);
renderDeltas(
this.yText.toDelta() as DeltaInsert[],
this._rootElement,
this._renderElement
);
};

@@ -845,3 +818,3 @@

) {
const chunks = deltaInsersToChunks(deltas);
const chunks = deltaInsertsToChunks(deltas);

@@ -848,0 +821,0 @@ // every chunk is a line

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

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