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.8.0 to 0.9.0

2

dist/components/embed-gap.js

@@ -10,3 +10,3 @@ import { html } from 'lit';

userSelect: 'text',
padding: '0 1px',
padding: '0 0.5px',
outline: 'none',

@@ -13,0 +13,0 @@ })}

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

import { getVEditorInsideRoot } from '../utils/query.js';
export let VirgoElement = class VirgoElement extends LitElement {
let VirgoElement = class VirgoElement extends LitElement {
constructor() {

@@ -58,2 +58,3 @@ super(...arguments);

], VirgoElement);
export { VirgoElement };
//# sourceMappingURL=virgo-element.js.map

@@ -6,9 +6,10 @@ import { LitElement, type TemplateResult } from 'lit';

get vElements(): import("./virgo-element.js").VirgoElement<{
bold?: true | undefined;
italic?: true | undefined;
underline?: true | undefined;
strike?: true | undefined;
code?: true | undefined;
link?: string | undefined;
bold?: true | null | undefined;
italic?: true | null | undefined;
underline?: true | null | undefined;
strike?: true | null | undefined;
code?: true | null | undefined;
link?: string | null | undefined;
}>[];
get vTexts(): import("./virgo-text.js").VText[];
get textLength(): number;

@@ -15,0 +16,0 @@ get textContent(): string;

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

import { styleMap } from 'lit/directives/style-map.js';
import { ZERO_WIDTH_SPACE } from '../consts.js';
import { VIRGO_ROOT_ATTR, ZERO_WIDTH_SPACE } from '../consts.js';
import { EmbedGap } from './embed-gap.js';
export let VirgoLine = class VirgoLine extends LitElement {
let VirgoLine = class VirgoLine extends LitElement {
constructor() {

@@ -22,2 +22,5 @@ super(...arguments);

}
get vTexts() {
return Array.from(this.querySelectorAll('v-text'));
}
get textLength() {

@@ -32,2 +35,3 @@ return this.vElements.reduce((acc, el) => acc + el.delta.insert.length, 0);

await Promise.all(this.vElements.map(el => el.updateComplete));
await Promise.all(this.vTexts.map(el => el.updateComplete));
return result;

@@ -42,3 +46,3 @@ }

}
const rootElement = this.closest('[data-virgo-root="true"]');
const rootElement = this.closest(`[${VIRGO_ROOT_ATTR}]`);
assertExists(rootElement, 'v-line must be inside a v-root');

@@ -82,3 +86,3 @@ const virgoEditor = rootElement.virgoEditor;

// start and end of the line when the first and last element is embed element
padding: '0 1px',
padding: '0 0.5px',
})}>${renderElements}</div>`;

@@ -96,2 +100,3 @@ }

], VirgoLine);
export { VirgoLine };
//# sourceMappingURL=virgo-line.js.map

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

import { ZERO_WIDTH_SPACE } from '../consts.js';
export let VText = class VText extends LitElement {
let VText = class VText extends LitElement {
constructor() {

@@ -41,2 +41,3 @@ super(...arguments);

], VText);
export { VText };
//# sourceMappingURL=virgo-text.js.map
export declare const ZERO_WIDTH_SPACE = "\u200B";
export declare const ZERO_WIDTH_NON_JOINER = "\u200C";
export declare const VIRGO_ROOT_ATTR = "data-virgo-root";
//# sourceMappingURL=consts.d.ts.map
export const ZERO_WIDTH_SPACE = '\u200B';
// see https://en.wikipedia.org/wiki/Zero-width_non-joiner
export const ZERO_WIDTH_NON_JOINER = '\u200C';
export const VIRGO_ROOT_ATTR = 'data-virgo-root';
//# sourceMappingURL=consts.js.map

@@ -7,3 +7,3 @@ import type { z, ZodTypeDef } from 'zod';

export declare class VirgoAttributeService<TextAttributes extends BaseTextAttributes> {
private readonly _editor;
readonly editor: VEditor<TextAttributes>;
private _marks;

@@ -10,0 +10,0 @@ private _attributeRenderer;

import { baseTextAttributes, getDefaultAttributeRenderer, } from '../utils/index.js';
export class VirgoAttributeService {
constructor(editor) {
this.editor = editor;
this._marks = null;

@@ -20,3 +21,3 @@ this._attributeRenderer = getDefaultAttributeRenderer();

this.getFormat = (vRange, loose = false) => {
const deltas = this._editor.deltaService
const deltas = this.editor.deltaService
.getDeltasByVRange(vRange)

@@ -62,5 +63,4 @@ .filter(([_, position]) => position.index + position.length > vRange.index &&

// filter out undefined values
Object.entries(attributeResult.data).filter(([_, v]) => v));
Object.entries(attributeResult.data).filter(([_, v]) => v || v === null));
};
this._editor = editor;
}

@@ -67,0 +67,0 @@ get marks() {

@@ -6,3 +6,3 @@ import type { DeltaInsert } from '../types.js';

export declare class VirgoDeltaService<TextAttributes extends BaseTextAttributes> {
private readonly _editor;
readonly editor: VEditor<TextAttributes>;
constructor(editor: VEditor<TextAttributes>);

@@ -9,0 +9,0 @@ get deltas(): DeltaInsert<TextAttributes>[];

@@ -6,2 +6,3 @@ import { html, render } from 'lit';

constructor(editor) {
this.editor = editor;
this.mapDeltasInVRange = (vRange, callback, normalize = false) => {

@@ -125,3 +126,5 @@ const deltas = normalize ? this.normalizedDeltas : this.deltas;

this.render = async (syncVRange = true) => {
const rootElement = this._editor.rootElement;
if (!this.editor.mounted)
return;
const rootElement = this.editor.rootElement;
const normalizedDeltas = this.normalizedDeltas;

@@ -140,3 +143,3 @@ const chunks = deltaInsertsToChunks(normalizedDeltas);

let selected = false;
const vRange = this._editor.getVRange();
const vRange = this.editor.getVRange();
if (vRange) {

@@ -146,3 +149,3 @@ selected = this.isNormalizedDeltaSelected(normalizedDeltaIndex, vRange);

return [
renderElement(delta, this._editor.attributeService.normalizeAttributes, selected),
renderElement(delta, this.editor.attributeService.normalizeAttributes, selected),
delta,

@@ -162,17 +165,16 @@ ];

// Lit may be crashed by IME input and we need to rerender whole editor for it
this._editor.rerenderWholeEditor();
await this._editor.waitForUpdate();
this.editor.rerenderWholeEditor();
await this.editor.waitForUpdate();
}
await this._editor.waitForUpdate();
await this.editor.waitForUpdate();
if (syncVRange) {
// We need to synchronize the selection immediately after rendering is completed,
// otherwise there is a possibility of an error in the cursor position
this._editor.rangeService.syncVRange();
this.editor.rangeService.syncVRange();
}
this._editor.slots.updated.emit();
this.editor.slots.updated.emit();
};
this._editor = editor;
}
get deltas() {
return this._editor.yText.toDelta();
return this.editor.yText.toDelta();
}

@@ -185,3 +187,3 @@ get normalizedDeltas() {

for (const delta of this.deltas) {
if (this._editor.isEmbed(delta)) {
if (this.editor.isEmbed(delta)) {
const dividedDeltas = [...delta.insert].map(subInsert => ({

@@ -202,3 +204,3 @@ insert: subInsert,

if (vRange.length >= 1) {
this._editor.mapDeltasInVRange(vRange, (_, rangeIndex, deltaIndex) => {
this.editor.mapDeltasInVRange(vRange, (_, rangeIndex, deltaIndex) => {
if (deltaIndex === normalizedDeltaIndex &&

@@ -205,0 +207,0 @@ rangeIndex >= vRange.index) {

@@ -9,9 +9,9 @@ import { type BaseTextAttributes } from '../utils/index.js';

constructor(editor: VEditor<TextAttributes>);
get vRangeProvider(): import("../virgo.js").VRangeProvider | null;
mount: () => void;
private _isRangeCompletelyInRoot;
private _onSelectionChange;
private _onCompositionStart;
private _onCompositionEnd;
private _firstRecomputeInFrame;
private _onBeforeInput;
private _onScroll;
private _onKeyDown;

@@ -18,0 +18,0 @@ private _onClick;

@@ -14,10 +14,30 @@ import { assertExists } from '@blocksuite/global/utils';

const rootElement = this.editor.rootElement;
this.editor.disposables.addFromEvent(document, 'selectionchange', this._onSelectionChange);
if (!this.vRangeProvider) {
this.editor.disposables.addFromEvent(document, 'selectionchange', this._onSelectionChange);
}
this.editor.disposables.addFromEvent(rootElement, 'beforeinput', this._onBeforeInput);
this.editor.disposables.addFromEvent(rootElement, 'compositionstart', this._onCompositionStart);
this.editor.disposables.addFromEvent(rootElement, 'compositionend', this._onCompositionEnd);
this.editor.disposables.addFromEvent(rootElement, 'scroll', this._onScroll);
this.editor.disposables.addFromEvent(rootElement, 'keydown', this._onKeyDown);
this.editor.disposables.addFromEvent(rootElement, 'click', this._onClick);
};
this._isRangeCompletelyInRoot = () => {
const selectionRoot = findDocumentOrShadowRoot(this.editor);
const selection = selectionRoot.getSelection();
if (!selection || selection.rangeCount === 0)
return false;
const range = selection.getRangeAt(0);
const rootElement = this.editor.rootElement;
const rootRange = document.createRange();
rootRange.selectNode(rootElement);
if (range.startContainer.compareDocumentPosition(range.endContainer) &
Node.DOCUMENT_POSITION_FOLLOWING) {
return (rootRange.comparePoint(range.startContainer, range.startOffset) >= 0 &&
rootRange.comparePoint(range.endContainer, range.endOffset) <= 0);
}
else {
return (rootRange.comparePoint(range.endContainer, range.startOffset) >= 0 &&
rootRange.comparePoint(range.startContainer, range.endOffset) <= 0);
}
};
this._onSelectionChange = () => {

@@ -35,3 +55,3 @@ const rootElement = this.editor.rootElement;

if (previousVRange !== null) {
this.editor.slots.vRangeUpdated.emit([null, 'native']);
this.editor.setVRange(null, false);
}

@@ -61,3 +81,3 @@ return;

if (previousVRange !== null) {
this.editor.slots.vRangeUpdated.emit([null, 'native']);
this.editor.setVRange(null, false);
}

@@ -71,3 +91,3 @@ return;

if (!isMaybeVRangeEqual(previousVRange, vRange)) {
this.editor.slots.vRangeUpdated.emit([vRange, 'native']);
this.editor.setVRange(vRange, false);
}

@@ -98,3 +118,3 @@ // avoid infinite syncVRange

await this.editor.waitForUpdate();
if (this.editor.isReadonly)
if (this.editor.isReadonly || !this._isRangeCompletelyInRoot())
return;

@@ -162,23 +182,27 @@ const vRange = this.editor.getVRange();

this.editor.insertText(newVRange, newData, ctx.attributes);
this.editor.slots.vRangeUpdated.emit([
{
index: newVRange.index + newData.length,
length: 0,
},
'input',
]);
this.editor.setVRange({
index: newVRange.index + newData.length,
length: 0,
}, false);
}
}
};
this._firstRecomputeInFrame = true;
this._onBeforeInput = (event) => {
event.preventDefault();
if (this.editor.isReadonly || this._isComposing)
if (this.editor.isReadonly ||
this._isComposing ||
!this._isRangeCompletelyInRoot())
return;
if (this._firstRecomputeInFrame) {
this._firstRecomputeInFrame = false;
this._onSelectionChange();
requestAnimationFrame(() => {
this._firstRecomputeInFrame = true;
});
if (!this.editor.getVRange())
return;
const targetRanges = event.getTargetRanges();
if (targetRanges.length > 0) {
const staticRange = targetRanges[0];
const range = document.createRange();
range.setStart(staticRange.startContainer, staticRange.startOffset);
range.setEnd(staticRange.endContainer, staticRange.endOffset);
const vRange = this.editor.toVRange(range);
if (!isMaybeVRangeEqual(this.editor.getVRange(), vRange)) {
this.editor.setVRange(vRange, false);
}
}

@@ -204,5 +228,2 @@ const vRange = this.editor.getVRange();

};
this._onScroll = () => {
this.editor.slots.scrollUpdated.emit(this.editor.rootElement.scrollLeft);
};
this._onKeyDown = (event) => {

@@ -280,3 +301,6 @@ if (!event.shiftKey &&

}
get vRangeProvider() {
return this.editor.vRangeProvider;
}
}
//# sourceMappingURL=event.js.map

@@ -1,14 +0,31 @@

import type { VRange } from '../types.js';
import type { VRangeUpdatedProp } from '../types.js';
import type { VirgoLine } from '../components/virgo-line.js';
import type { TextPoint, VRange, VRangeUpdatedProp } from '../types.js';
import type { BaseTextAttributes } from '../utils/base-attributes.js';
import type { VEditor } from '../virgo.js';
export declare class VirgoRangeService<TextAttributes extends BaseTextAttributes> {
private readonly _editor;
private _prevVRange;
readonly editor: VEditor<TextAttributes>;
private _vRange;
private _lastScrollLeft;
constructor(editor: VEditor<TextAttributes>);
onVRangeUpdated: ([newVRange, origin]: VRangeUpdatedProp) => void;
get vRangeProvider(): import("../virgo.js").VRangeProvider | null;
get rootElement(): import("../virgo.js").VirgoRootElement<TextAttributes>;
onVRangeUpdated: ([newVRange, sync]: VRangeUpdatedProp) => Promise<void>;
getNativeSelection(): Selection | null;
getVRange: () => VRange | null;
getVRangeFromElement: (element: Element) => VRange | null;
getTextPoint(rangeIndex: VRange['index']): TextPoint;
getLine(rangeIndex: VRange['index']): readonly [VirgoLine, number];
isVRangeValid: (vRange: VRange | null) => boolean;
/**
* There are two cases to have the second line:
* 1. long text auto wrap in span element
* 2. soft break
*/
isFirstLine: (vRange: VRange | null) => boolean;
/**
* There are two cases to have the second line:
* 1. long text auto wrap in span element
* 2. soft break
*/
isLastLine: (vRange: VRange | null) => boolean;
/**
* the vRange is synced to the native selection asynchronically

@@ -18,2 +35,6 @@ * if sync is true, the native selection will be synced immediately

setVRange: (vRange: VRange | null, sync?: boolean) => void;
focusEnd: () => void;
focusStart: () => void;
selectAll: () => void;
focusIndex: (index: number) => void;
/**

@@ -54,7 +75,4 @@ * sync the dom selection from vRange for **this Editor**

toVRange: (range: Range) => VRange | null;
onScrollUpdated: (scrollLeft: number) => void;
private _applyVRange;
private _scrollLineIntoViewIfNeeded;
private _scrollCursorIntoViewIfNeeded;
}
//# sourceMappingURL=range.d.ts.map

@@ -1,29 +0,31 @@

import { VirgoLine } from '../components/index.js';
import { assertExists } from '@blocksuite/global/utils';
import { findDocumentOrShadowRoot } from '../utils/query.js';
import { domRangeToVirgoRange, virgoRangeToDomRange, } from '../utils/range-conversion.js';
import { calculateTextLength, getTextNodesFromElement } from '../utils/text.js';
import { isMaybeVRangeEqual } from '../utils/v-range.js';
export class VirgoRangeService {
constructor(editor) {
this._prevVRange = null;
this.editor = editor;
this._vRange = null;
this._lastScrollLeft = 0;
this.onVRangeUpdated = ([newVRange, origin]) => {
this.onVRangeUpdated = async ([newVRange, sync]) => {
const eq = isMaybeVRangeEqual(this._vRange, newVRange);
if (eq) {
return;
}
this._vRange = newVRange;
if (this._editor.mounted &&
newVRange &&
!isMaybeVRangeEqual(this._prevVRange, newVRange)) {
// no need to sync and native selection behavior about shift+arrow will
// be broken if we sync
this._editor.requestUpdate(false);
// try to trigger update because the `selected` state of the virgo element may change
if (this.editor.mounted) {
// range change may happen before the editor is prepared
await this.editor.waitForUpdate();
this.editor.requestUpdate(false);
}
this._prevVRange = newVRange;
if (origin !== 'other') {
if (!sync) {
return;
}
if (this._vRange === null) {
const selectionRoot = findDocumentOrShadowRoot(this._editor);
const selectionRoot = findDocumentOrShadowRoot(this.editor);
const selection = selectionRoot.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
if (range.intersectsNode(this._editor.rootElement)) {
if (range.intersectsNode(this.editor.rootElement)) {
selection.removeAllRanges();

@@ -46,5 +48,106 @@ }

this.getVRange = () => {
if (this.vRangeProvider) {
return this.vRangeProvider.getVRange();
}
return this._vRange;
};
this.getVRangeFromElement = (element) => {
const range = document.createRange();
const text = element.querySelector('[data-virgo-text');
if (!text) {
return null;
}
const textNode = text.childNodes[1];
assertExists(textNode instanceof Text);
range.setStart(textNode, 0);
range.setEnd(textNode, textNode.textContent?.length ?? 0);
const vRange = this.toVRange(range);
return vRange;
};
this.isVRangeValid = (vRange) => {
return !(vRange &&
(vRange.index < 0 ||
vRange.index + vRange.length > this.editor.yText.length));
};
/**
* There are two cases to have the second line:
* 1. long text auto wrap in span element
* 2. soft break
*/
this.isFirstLine = (vRange) => {
if (!vRange)
return false;
if (vRange.length > 0) {
throw new Error('vRange should be collapsed');
}
const range = this.toDomRange(vRange);
if (!range) {
throw new Error('failed to convert vRange to domRange');
}
// check case 1:
const beforeText = this.editor.yTextString.slice(0, vRange.index);
if (beforeText.includes('\n')) {
return false;
}
// check case 2:
// If there is a wrapped text, there are two possible positions for
// cursor: (in first line and in second line)
// aaaaaaaa| or aaaaaaaa
// bb |bb
// We have no way to distinguish them and we just assume that the cursor
// can not in the first line because if we apply the vRange manually the
// cursor will jump to the second line.
const container = range.commonAncestorContainer.parentElement;
assertExists(container);
const containerRect = container.getBoundingClientRect();
// There will be two rects if the cursor is at the edge of the line:
// aaaaaaaa| or aaaaaaaa
// bb |bb
const rangeRects = range.getClientRects();
// We use last rect here to make sure we get the second rect.
// (Based on the assumption that the cursor can not in the first line)
const rangeRect = rangeRects[rangeRects.length - 1];
return rangeRect.top === containerRect.top;
};
/**
* There are two cases to have the second line:
* 1. long text auto wrap in span element
* 2. soft break
*/
this.isLastLine = (vRange) => {
if (!vRange)
return false;
if (vRange.length > 0) {
throw new Error('vRange should be collapsed');
}
// check case 1:
const afterText = this.editor.yTextString.slice(vRange.index);
if (afterText.includes('\n')) {
return false;
}
const range = this.toDomRange(vRange);
if (!range) {
throw new Error('failed to convert vRange to domRange');
}
// check case 2:
// If there is a wrapped text, there are two possible positions for
// cursor: (in first line and in second line)
// aaaaaaaa| or aaaaaaaa
// bb |bb
// We have no way to distinguish them and we just assume that the cursor
// can not in the first line because if we apply the vRange manually the
// cursor will jump to the second line.
const container = range.commonAncestorContainer.parentElement;
assertExists(container);
const containerRect = container.getBoundingClientRect();
// There will be two rects if the cursor is at the edge of the line:
// aaaaaaaa| or aaaaaaaa
// bb |bb
const rangeRects = range.getClientRects();
// We use last rect here to make sure we get the second rect.
// (Based on the assumption that the cursor can not in the first line)
const rangeRect = rangeRects[rangeRects.length - 1];
return rangeRect.bottom === containerRect.bottom;
};
/**
* the vRange is synced to the native selection asynchronically

@@ -54,9 +157,35 @@ * if sync is true, the native selection will be synced immediately

this.setVRange = (vRange, sync = true) => {
if (vRange &&
(vRange.index < 0 ||
vRange.index + vRange.length > this._editor.yText.length)) {
if (!this.isVRangeValid(vRange)) {
throw new Error('invalid vRange');
}
this._editor.slots.vRangeUpdated.emit([vRange, sync ? 'other' : 'silent']);
if (this.vRangeProvider) {
this.vRangeProvider.setVRange(vRange);
return;
}
this.editor.slots.vRangeUpdated.emit([vRange, sync]);
};
this.focusEnd = () => {
this.setVRange({
index: this.editor.yTextLength,
length: 0,
});
};
this.focusStart = () => {
this.setVRange({
index: 0,
length: 0,
});
};
this.selectAll = () => {
this.setVRange({
index: 0,
length: this.editor.yTextLength,
});
};
this.focusIndex = (index) => {
this.setVRange({
index,
length: 0,
});
};
/**

@@ -66,4 +195,5 @@ * sync the dom selection from vRange for **this Editor**

this.syncVRange = () => {
if (this._vRange && this._editor.mounted) {
this._applyVRange(this._vRange);
const vRange = this.getVRange();
if (vRange && this.editor.mounted) {
this._applyVRange(vRange);
}

@@ -75,3 +205,3 @@ };

this.toDomRange = (vRange) => {
const rootElement = this._editor.rootElement;
const rootElement = this.editor.rootElement;
return virgoRangeToDomRange(rootElement, vRange);

@@ -106,10 +236,7 @@ };

this.toVRange = (range) => {
const { rootElement, yText } = this._editor;
const { rootElement, yText } = this.editor;
return domRangeToVirgoRange(range, rootElement, yText);
};
this.onScrollUpdated = (scrollLeft) => {
this._lastScrollLeft = scrollLeft;
};
this._applyVRange = (vRange) => {
const selectionRoot = findDocumentOrShadowRoot(this._editor);
const selectionRoot = findDocumentOrShadowRoot(this.editor);
const selection = selectionRoot.getSelection();

@@ -125,33 +252,55 @@ if (!selection) {

selection.addRange(newRange);
this._scrollLineIntoViewIfNeeded(newRange);
this._scrollCursorIntoViewIfNeeded(newRange);
this._editor.slots.rangeUpdated.emit(newRange);
this.editor.slots.rangeUpdated.emit(newRange);
};
this._scrollLineIntoViewIfNeeded = (range) => {
if (this._editor.shouldLineScrollIntoView) {
let lineElement = range.endContainer.parentElement;
while (!(lineElement instanceof VirgoLine)) {
lineElement = lineElement?.parentElement ?? null;
}
get vRangeProvider() {
return this.editor.vRangeProvider;
}
get rootElement() {
return this.editor.rootElement;
}
getNativeSelection() {
const selectionRoot = findDocumentOrShadowRoot(this.editor);
const selection = selectionRoot.getSelection();
if (!selection)
return null;
if (selection.rangeCount === 0)
return null;
return selection;
}
getTextPoint(rangeIndex) {
const vLines = Array.from(this.rootElement.querySelectorAll('v-line'));
let index = 0;
for (const vLine of vLines) {
const texts = getTextNodesFromElement(vLine);
for (const text of texts) {
if (!text.textContent) {
throw new Error('text element should have textContent');
}
lineElement?.scrollIntoView({
block: 'nearest',
});
}
};
this._scrollCursorIntoViewIfNeeded = (range) => {
if (this._editor.shouldCursorScrollIntoView) {
const root = this._editor.rootElement;
const rootRect = root.getBoundingClientRect();
const rangeRect = range.getBoundingClientRect();
let moveX = 0;
if (rangeRect.left > rootRect.left) {
moveX = Math.max(this._lastScrollLeft, rangeRect.left - rootRect.right);
if (index + text.textContent.length >= rangeIndex) {
return [text, rangeIndex - index];
}
root.scrollLeft = moveX;
this._lastScrollLeft = moveX;
index += calculateTextLength(text);
}
};
this._editor = editor;
index += 1;
}
throw new Error('failed to find leaf');
}
// the number is related to the VirgoLine's textLength
getLine(rangeIndex) {
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];
}
if (rangeIndex === index + lineElement.textLength &&
rangeIndex === this.editor.yTextLength) {
return [lineElement, rangeIndex - index];
}
index += lineElement.textLength + 1;
}
throw new Error('failed to find line');
}
}
//# sourceMappingURL=range.js.map

@@ -655,7 +655,3 @@ import { expect, test } from '@playwright/test';

await page.waitForTimeout(100);
await type(page, 'abc');
await press(page, 'Enter');
await type(page, 'def');
await press(page, 'Enter');
await type(page, 'ghi');
await type(page, 'abc\ndef\nghi');
expect(await editorA.innerText()).toBe('abc\ndef\nghi');

@@ -715,2 +711,3 @@ expect(await editorB.innerText()).toBe('abc\ndef\nghi');

await press(page, 'ArrowLeft');
await page.waitForTimeout(100);
page.keyboard.down('Shift');

@@ -717,0 +714,0 @@ await press(page, 'ArrowLeft');

@@ -12,6 +12,3 @@ import type { TemplateResult } from 'lit';

}
export type VRangeUpdatedProp = [
range: VRange | null,
type: 'native' | 'input' | 'other' | 'silent'
];
export type VRangeUpdatedProp = [range: VRange | null, sync: boolean];
export type DeltaEntry<TextAttributes extends BaseTextAttributes = BaseTextAttributes> = [delta: DeltaInsert<TextAttributes>, range: VRange];

@@ -18,0 +15,0 @@ export type NativePoint = readonly [node: Node, offset: number];

import type { AttributeRenderer } from '../types.js';
export declare const getDefaultAttributeRenderer: <T extends {
bold?: true | undefined;
italic?: true | undefined;
underline?: true | undefined;
strike?: true | undefined;
code?: true | undefined;
link?: string | undefined;
bold?: true | null | undefined;
italic?: true | null | undefined;
underline?: true | null | undefined;
strike?: true | null | undefined;
code?: true | null | undefined;
link?: string | null | undefined;
}>() => AttributeRenderer<T>;
//# sourceMappingURL=attribute-renderer.d.ts.map
import { z } from 'zod';
export declare const baseTextAttributes: z.ZodObject<{
bold: z.ZodCatch<z.ZodOptional<z.ZodLiteral<true>>>;
italic: z.ZodCatch<z.ZodOptional<z.ZodLiteral<true>>>;
underline: z.ZodCatch<z.ZodOptional<z.ZodLiteral<true>>>;
strike: z.ZodCatch<z.ZodOptional<z.ZodLiteral<true>>>;
code: z.ZodCatch<z.ZodOptional<z.ZodLiteral<true>>>;
link: z.ZodCatch<z.ZodOptional<z.ZodString>>;
bold: z.ZodCatch<z.ZodNullable<z.ZodOptional<z.ZodLiteral<true>>>>;
italic: z.ZodCatch<z.ZodNullable<z.ZodOptional<z.ZodLiteral<true>>>>;
underline: z.ZodCatch<z.ZodNullable<z.ZodOptional<z.ZodLiteral<true>>>>;
strike: z.ZodCatch<z.ZodNullable<z.ZodOptional<z.ZodLiteral<true>>>>;
code: z.ZodCatch<z.ZodNullable<z.ZodOptional<z.ZodLiteral<true>>>>;
link: z.ZodCatch<z.ZodNullable<z.ZodOptional<z.ZodString>>>;
}, "strip", z.ZodTypeAny, {
bold?: true | undefined;
italic?: true | undefined;
underline?: true | undefined;
strike?: true | undefined;
code?: true | undefined;
link?: string | undefined;
bold?: true | null | undefined;
italic?: true | null | undefined;
underline?: true | null | undefined;
strike?: true | null | undefined;
code?: true | null | undefined;
link?: string | null | undefined;
}, {

@@ -17,0 +17,0 @@ bold?: unknown;

import { z } from 'zod';
export const baseTextAttributes = z.object({
bold: z.literal(true).optional().catch(undefined),
italic: z.literal(true).optional().catch(undefined),
underline: z.literal(true).optional().catch(undefined),
strike: z.literal(true).optional().catch(undefined),
code: z.literal(true).optional().catch(undefined),
link: z.string().optional().catch(undefined),
bold: z.literal(true).optional().nullable().catch(undefined),
italic: z.literal(true).optional().nullable().catch(undefined),
underline: z.literal(true).optional().nullable().catch(undefined),
strike: z.literal(true).optional().nullable().catch(undefined),
code: z.literal(true).optional().nullable().catch(undefined),
link: z.string().optional().nullable().catch(undefined),
});
//# sourceMappingURL=base-attributes.js.map

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

import { ZERO_WIDTH_SPACE } from '../consts.js';
import { VIRGO_ROOT_ATTR, ZERO_WIDTH_SPACE } from '../consts.js';
import { isNativeTextInVText, isVElement, isVLine, isVRoot } from './guard.js';

@@ -64,4 +64,4 @@ import { calculateTextLength, getTextNodesFromElement } from './text.js';

const container = node instanceof Element
? node.closest('[data-virgo-root="true"]')
: node.parentElement?.closest('[data-virgo-root="true"]');
? node.closest(`[${VIRGO_ROOT_ATTR}]`)
: node.parentElement?.closest(`[${VIRGO_ROOT_ATTR}]`);
if (container) {

@@ -68,0 +68,0 @@ return Array.from(container.querySelectorAll('v-line'));

import { assertExists } from '@blocksuite/global/utils';
import { VIRGO_ROOT_ATTR } from '../consts.js';
export function findDocumentOrShadowRoot(editor) {

@@ -15,3 +16,3 @@ const el = editor.rootElement;

export function getVEditorInsideRoot(element) {
const rootElement = element.closest('[data-virgo-root="true"]');
const rootElement = element.closest(`[${VIRGO_ROOT_ATTR}]`);
assertExists(rootElement, 'element must be inside a v-root');

@@ -18,0 +19,0 @@ const virgoEditor = rootElement.virgoEditor;

function handleInsertText(vRange, data, editor, attributes) {
if (vRange.index >= 0 && data) {
editor.slots.vRangeUpdated.emit([
{
index: vRange.index + data.length,
length: 0,
},
'input',
]);
editor.insertText(vRange, data, attributes);
}
if (!data)
return;
editor.insertText(vRange, data, attributes);
editor.setVRange({
index: vRange.index + data.length,
length: 0,
}, false);
}
function handleInsertParagraph(vRange, editor) {
if (vRange.index >= 0) {
editor.slots.vRangeUpdated.emit([
{
index: vRange.index + 1,
length: 0,
},
'input',
]);
editor.insertLineBreak(vRange);
}
editor.insertLineBreak(vRange);
editor.setVRange({
index: vRange.index + 1,
length: 0,
}, false);
}
function handleDeleteBackward(vRange, editor) {
if (vRange.index >= 0) {
if (vRange.length > 0) {
editor.slots.vRangeUpdated.emit([
{
index: vRange.index,
length: 0,
},
'input',
]);
editor.deleteText(vRange);
return;
}
if (vRange.index > 0) {
const originalString = editor.yText.toString().slice(0, vRange.index);
const segments = [...new Intl.Segmenter().segment(originalString)];
const deletedLength = segments[segments.length - 1].segment.length;
editor.slots.vRangeUpdated.emit([
{
index: vRange.index - deletedLength,
length: 0,
},
'input',
]);
editor.deleteText({
index: vRange.index - deletedLength,
length: deletedLength,
});
}
}
function handleDelete(vRange, editor) {
editor.deleteText(vRange);
editor.setVRange({
index: vRange.index,
length: 0,
}, false);
}
function handleDeleteForward(editor, vRange) {
if (vRange.index < editor.yText.length) {
if (vRange.length > 0) {
editor.slots.vRangeUpdated.emit([
{
index: vRange.index,
length: 0,
},
'input',
]);
editor.deleteText(vRange);
}
else {
const originalString = editor.yText.toString();
const segments = [...new Intl.Segmenter().segment(originalString)];
const slicedString = originalString.slice(0, vRange.index);
const slicedSegments = [...new Intl.Segmenter().segment(slicedString)];
const deletedLength = segments[slicedSegments.length].segment.length;
editor.slots.vRangeUpdated.emit([
{
index: vRange.index,
length: 0,
},
'input',
]);
editor.deleteText({
index: vRange.index,
length: deletedLength,
});
}
export function transformInput(inputType, data, attributes, vRange, editor) {
if (!editor.isVRangeValid(vRange))
return;
if (inputType === 'insertText') {
handleInsertText(vRange, data, editor, attributes);
}
}
function handleDeleteWordBackward(editor, vRange) {
const matches = /\S+\s*$/.exec(editor.yText.toString().slice(0, vRange.index));
if (matches) {
const deleteLength = matches[0].length;
editor.slots.vRangeUpdated.emit([
{
index: vRange.index - deleteLength,
length: 0,
},
'input',
]);
editor.deleteText({
index: vRange.index - deleteLength,
length: deleteLength,
});
else if (inputType === 'insertParagraph' ||
inputType === 'insertLineBreak') {
handleInsertParagraph(vRange, editor);
}
}
function handleDeleteWordForward(editor, vRange) {
const matches = /^\s*\S+/.exec(editor.yText.toString().slice(vRange.index));
if (matches) {
const deleteLength = matches[0].length;
editor.slots.vRangeUpdated.emit([
{
index: vRange.index,
length: 0,
},
'input',
]);
editor.deleteText({
index: vRange.index,
length: deleteLength,
});
else if (inputType.startsWith('delete')) {
handleDelete(vRange, editor);
}
}
function handleDeleteLine(editor, vRange) {
if (vRange.length > 0) {
editor.slots.vRangeUpdated.emit([
{
index: vRange.index,
length: 0,
},
'input',
]);
editor.deleteText(vRange);
else {
return;
}
if (vRange.index > 0) {
const str = editor.yText.toString();
const deleteLength = vRange.index - Math.max(0, str.slice(0, vRange.index).lastIndexOf('\n'));
editor.slots.vRangeUpdated.emit([
{
index: vRange.index - deleteLength,
length: 0,
},
'input',
]);
editor.deleteText({
index: vRange.index - deleteLength,
length: deleteLength,
});
}
}
export function transformInput(inputType, data, attributes, vRange, editor) {
// You can find explanation of inputType here:
// [Input Events Level 2](https://w3c.github.io/input-events/#interface-InputEvent-Attributes)
switch (inputType) {
case 'insertText': {
handleInsertText(vRange, data, editor, attributes);
return;
}
case 'insertParagraph': {
handleInsertParagraph(vRange, editor);
return;
}
// Chrome and Safari on Mac: Backspace or Ctrl + H
case 'deleteContentBackward':
case 'deleteByCut': {
handleDeleteBackward(vRange, editor);
return;
}
// Chrome on Mac: Fn + Backspace or Ctrl + D
// Safari on Mac: Ctrl + K or Ctrl + D
case 'deleteContentForward': {
handleDeleteForward(editor, vRange);
return;
}
// On Mac: Option + Backspace
// On iOS: Hold the backspace for a while and the whole words will start to disappear
case 'deleteWordBackward': {
handleDeleteWordBackward(editor, vRange);
return;
}
// onMac: Fn + Option + Backspace
// onWindows: Control + Delete
case 'deleteWordForward': {
handleDeleteWordForward(editor, vRange);
return;
}
// deleteHardLineBackward: Safari on Mac: Cmd + Backspace
// deleteSoftLineBackward: Chrome on Mac: Cmd + Backspace
case 'deleteHardLineBackward':
case 'deleteSoftLineBackward': {
handleDeleteLine(editor, vRange);
return;
}
}
}
//# sourceMappingURL=transform-input.js.map

@@ -1,8 +0,6 @@

import type { NullablePartial } from '@blocksuite/global/utils';
import { DisposableGroup, Slot } from '@blocksuite/global/utils';
import type * as Y from 'yjs';
import type { VirgoLine } from './components/index.js';
import { VirgoHookService } from './services/hook.js';
import { VirgoAttributeService, VirgoDeltaService, VirgoEventService, VirgoRangeService } from './services/index.js';
import type { DeltaInsert, TextPoint, VRange, VRangeUpdatedProp } from './types.js';
import type { DeltaInsert, VRange, VRangeUpdatedProp } from './types.js';
import { type BaseTextAttributes, nativePointToTextPoint, textPointToDomPoint } from './utils/index.js';

@@ -13,2 +11,7 @@ import { getTextNodesFromElement } from './utils/text.js';

};
export interface VRangeProvider {
getVRange(): VRange | null;
setVRange(vRange: VRange | null): void;
vRangeUpdatedSlot: Slot<VRangeUpdatedProp>;
}
export declare class VEditor<TextAttributes extends BaseTextAttributes = BaseTextAttributes> {

@@ -29,5 +32,4 @@ static nativePointToTextPoint: typeof nativePointToTextPoint;

private _mounted;
shouldLineScrollIntoView: boolean;
shouldCursorScrollIntoView: boolean;
readonly isEmbed: (delta: DeltaInsert<TextAttributes>) => boolean;
readonly vRangeProvider: VRangeProvider | null;
slots: {

@@ -39,3 +41,2 @@ mounted: Slot;

rangeUpdated: Slot<Range>;
scrollUpdated: Slot<number>;
};

@@ -61,3 +62,14 @@ get yText(): Y.Text;

getVRange: () => VRange | null;
getVRangeFromElement: (element: Element) => VRange | null;
getNativeSelection: () => Selection | null;
getTextPoint: (rangeIndex: number) => import("./types.js").TextPoint;
getLine: (rangeIndex: number) => readonly [import("./index.js").VirgoLine, number];
isVRangeValid: (vRange: VRange | null) => boolean;
isFirstLine: (vRange: VRange | null) => boolean;
isLastLine: (vRange: VRange | null) => boolean;
setVRange: (vRange: VRange | null, sync?: boolean) => void;
focusStart: () => void;
focusEnd: () => void;
selectAll: () => void;
focusIndex: (index: number) => void;
syncVRange: () => void;

@@ -75,2 +87,3 @@ getDeltasByVRange: (vRange: VRange) => import("./types.js").DeltaEntry<TextAttributes>[];

hooks?: VirgoHookService<TextAttributes>['hooks'];
vRangeProvider?: VRangeProvider;
});

@@ -81,16 +94,8 @@ mount(rootElement: HTMLElement): void;

waitForUpdate(): Promise<void>;
getNativeSelection(): Selection | null;
getTextPoint(rangeIndex: VRange['index']): TextPoint;
getLine(rangeIndex: VRange['index']): readonly [VirgoLine, number];
setReadonly(isReadonly: boolean): void;
get isReadonly(): boolean;
/**
* the vRange is synced to the native selection asynchronically
*/
focusEnd(): void;
focusByIndex(index: number): void;
deleteText(vRange: VRange): void;
insertText(vRange: VRange, text: string, attributes?: TextAttributes): void;
insertLineBreak(vRange: VRange): void;
formatText(vRange: VRange, attributes: NullablePartial<TextAttributes>, options?: {
formatText(vRange: VRange, attributes: TextAttributes, options?: {
match?: (delta: DeltaInsert, deltaVRange: VRange) => boolean;

@@ -97,0 +102,0 @@ mode?: 'replace' | 'merge';

import { assertExists, DisposableGroup, Slot } from '@blocksuite/global/utils';
import { nothing, render } from 'lit';
import { VIRGO_ROOT_ATTR } from './consts.js';
import { VirgoHookService } from './services/hook.js';
import { VirgoAttributeService, VirgoDeltaService, VirgoEventService, VirgoRangeService, } from './services/index.js';
import { findDocumentOrShadowRoot, nativePointToTextPoint, textPointToDomPoint, } from './utils/index.js';
import { calculateTextLength, getTextNodesFromElement } from './utils/text.js';
import { nativePointToTextPoint, textPointToDomPoint, } from './utils/index.js';
import { getTextNodesFromElement } from './utils/text.js';
import { intersectVRange } from './utils/v-range.js';

@@ -60,4 +61,2 @@ export class VEditor {

this._mounted = false;
this.shouldLineScrollIntoView = true;
this.shouldCursorScrollIntoView = true;
this.setAttributeSchema = this._attributeService.setAttributeSchema;

@@ -72,3 +71,14 @@ this.setAttributeRenderer = this._attributeService.setAttributeRenderer;

this.getVRange = this.rangeService.getVRange;
this.getVRangeFromElement = this.rangeService.getVRangeFromElement;
this.getNativeSelection = this.rangeService.getNativeSelection;
this.getTextPoint = this.rangeService.getTextPoint;
this.getLine = this.rangeService.getLine;
this.isVRangeValid = this.rangeService.isVRangeValid;
this.isFirstLine = this.rangeService.isFirstLine;
this.isLastLine = this.rangeService.isLastLine;
this.setVRange = this.rangeService.setVRange;
this.focusStart = this.rangeService.focusStart;
this.focusEnd = this.rangeService.focusEnd;
this.selectAll = this.rangeService.selectAll;
this.focusIndex = this.rangeService.focusIndex;
this.syncVRange = this.rangeService.syncVRange;

@@ -85,3 +95,2 @@ // Expose delta service API

Promise.resolve().then(() => {
assertExists(this._rootElement);
this.deltaService.render();

@@ -96,6 +105,7 @@ });

}
const { isEmbed = () => false, hooks = {} } = ops;
const { isEmbed = () => false, hooks = {}, vRangeProvider = null } = ops;
this._yText = yText;
this.isEmbed = isEmbed;
this._hooksService = new VirgoHookService(this, hooks);
this.vRangeProvider = vRangeProvider;
this.slots = {

@@ -107,6 +117,9 @@ mounted: new Slot(),

rangeUpdated: new Slot(),
scrollUpdated: new Slot(),
};
if (vRangeProvider) {
vRangeProvider.vRangeUpdatedSlot.on(prop => {
this.slots.vRangeUpdated.emit(prop);
});
}
this.slots.vRangeUpdated.on(this.rangeService.onVRangeUpdated);
this.slots.scrollUpdated.on(this.rangeService.onScrollUpdated);
}

@@ -121,9 +134,10 @@ mount(rootElement) {

this._bindYTextObserver();
this._deltaService.render();
this._eventService.mount();
this._mounted = true;
this.slots.mounted.emit();
this._deltaService.render();
}
unmount() {
render(nothing, this.rootElement);
this.rootElement.removeAttribute(VIRGO_ROOT_ATTR);
this._rootElement = null;

@@ -135,6 +149,3 @@ this._mounted = false;

requestUpdate(syncVRange = true) {
Promise.resolve().then(() => {
assertExists(this._rootElement);
this._deltaService.render(syncVRange);
});
this._deltaService.render(syncVRange);
}

@@ -145,47 +156,2 @@ async waitForUpdate() {

}
getNativeSelection() {
const selectionRoot = findDocumentOrShadowRoot(this);
const selection = selectionRoot.getSelection();
if (!selection)
return null;
if (selection.rangeCount === 0)
return null;
return selection;
}
getTextPoint(rangeIndex) {
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 += calculateTextLength(text);
}
index += 1;
}
throw new Error('failed to find leaf');
}
// the number is related to the VirgoLine's textLength
getLine(rangeIndex) {
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];
}
if (rangeIndex === index + lineElement.textLength &&
rangeIndex === this.yText.length) {
return [lineElement, rangeIndex - index];
}
index += lineElement.textLength + 1;
}
throw new Error('failed to find line');
}
setReadonly(isReadonly) {

@@ -198,17 +164,2 @@ this.rootElement.contentEditable = isReadonly ? 'false' : 'true';

}
/**
* the vRange is synced to the native selection asynchronically
*/
focusEnd() {
this.rangeService.setVRange({
index: this.yText.length,
length: 0,
});
}
focusByIndex(index) {
this.rangeService.setVRange({
index: index,
length: 0,
});
}
deleteText(vRange) {

@@ -244,2 +195,5 @@ this._transact(() => {

.forEach(([_delta, deltaVRange]) => {
const normalizedAttributes = this._attributeService.normalizeAttributes(attributes);
if (!normalizedAttributes)
return;
const targetVRange = intersectVRange(vRange, deltaVRange);

@@ -252,3 +206,3 @@ if (!targetVRange)

this._transact(() => {
this.yText.format(targetVRange.index, targetVRange.length, attributes);
this.yText.format(targetVRange.index, targetVRange.length, normalizedAttributes);
});

@@ -255,0 +209,0 @@ });

{
"name": "@blocksuite/virgo",
"version": "0.8.0",
"version": "0.9.0",
"description": "A micro editor.",
"main": "dist/index.js",
"type": "module",

@@ -28,3 +27,3 @@ "repository": "toeverything/blocksuite",

"zod": "^3.22.2",
"@blocksuite/global": "0.8.0"
"@blocksuite/global": "0.9.0"
},

@@ -39,3 +38,4 @@ "scripts": {

},
"main": "dist/index.js",
"types": "dist/index.d.ts"
}

@@ -5,61 +5,102 @@ # `@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. 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.
Virgo is a streamlined rich-text editing core that seamlessly synchronizes the state between DOM and [Y.Text](https://docs.yjs.dev/api/shared-types/y.text). What sets it apart from other rich-text editing frameworks is its natively CRDT data model. For comparison, if you want collaborative editing in Slate.js, you'd typically use a plugin like slate-yjs, which acts as a bridge between [Yjs](https://github.com/yjs/yjs) and Slate.js. Within these plugins, all text operations must be translated between Yjs and Slate.js operations, potentially complicating undo/redo functionalities and code maintenance. With Virgo, the synchronization between Yjs and DOM is direct. This means Yjs's state is the singular source of truth, allowing for direct manipulation of the DOM state via the `Y.Text` API, which considerably reduces the editor's complexity.
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.
In BlockSuite, we initially employed Quill for in-block rich-text editing, leveraging only a limited subset of its APIs. Each paragraph in BlockSuite was managed by an individual Quill instance, linked to a `Y.Text` instance for collaborative purposes. Virgo further simplifies this, performing the same function as our usage of the Quill subset. It essentially offers a straightforward rich-text synchronization process, with block-tree-level state management being taken care of by BlockSuite's data store.
A virgo editor state corresponds to `Y.Text`, it's easy to convert between them. Virgo also provides a `Delta` format to represent the editor state, which is also supported by Yjs. So we can use Yjs to manipulate all the states of the text including format.
The Virgo editor's state is compatible with `Y.Text`, simplifying the conversion between them. Virgo uses the Delta format, similar to Yjs, allowing Yjs to manage all text states, including formatting.
## Usage
To use Virgo in your project, all you need to do is to create a `Y.Text` instance from `Y.Doc`, bind it to the virgo editor, then mount it to the DOM:
```ts
const yText = new Y.Text();
const doc = new Y.Doc();
const yText = doc.getText('text');
const vEditor = new VEditor(yText);
// Bind Y.Text to virgo editor, then type 'aaa\nbbb'
// ...
console.log(yText.toString()); // 'aaa\nbbb'
const editorContainer = document.getElementById('editor');
vEditor.mount(editorContainer);
```
console.log(yText.toDelta());
/*
[
{
insert: 'aaa\nbbb',
},
];
*/
You can go to [virgo playground](https://blocksuite-toeverything.vercel.app/examples/virgo/)
for online testing and check out the code in its [repository](https://github.com/toeverything/blocksuite/tree/master/packages/playground/examples/virgo).
### Attributes
Attributes is a property of a delta structure, which is used to store formatting information.
A delta expressing a bold text node would look like this:
```json
{
"insert": "Hello World",
"attributes": {
"bold": true
}
}
```
If you format from the first character to the second character, the string representation in `Y.Text` will still be `aaa\nbbb`. But if we covert it to Delta, you will see the difference:
Virgo use zod to validate attributes, you can use `setAttributesSchema` to set the schema:
```ts
// Continue the example before, format 'aa' to bold
// ...
console.log(yText.toString()); // 'aaa\nbbb'
// you don't have to extend baseTextAttributes
const customSchema = baseTextAttributes.extend({
reference: z
.object({
type: z.enum(['Subpage', 'LinkedPage']),
pageId: z.string(),
})
.optional()
.nullable()
.catch(undefined),
background: z.string().optional().nullable().catch(undefined),
});
console.log(yText.toDelta());
/*
[
{
insert: 'aa',
attributes: {
bold: true,
},
},
{
insert: 'a\nbbb',
},
];
*/
const doc = new Y.Doc();
const yText = doc.getText('text');
const vEditor = new VEditor(yText);
vEditor.setAttributesSchema(customSchema);
const editorContainer = document.getElementById('editor');
vEditor.mount(editorContainer);
```
You will see that there is a `type` attribute in the Delta format, which is used to represent the type of text segments, like base text (bold, italic, line-break, inline-code, link, etc.). This format makes it easy implementing customized inline elements.
Virgo has default attributes schema, so you can skip this step if you think it is enough.
## Usage
```ts
// default attributes schema
const baseTextAttributes = z.object({
bold: z.literal(true).optional().nullable().catch(undefined),
italic: z.literal(true).optional().nullable().catch(undefined),
underline: z.literal(true).optional().nullable().catch(undefined),
strike: z.literal(true).optional().nullable().catch(undefined),
code: z.literal(true).optional().nullable().catch(undefined),
link: z.string().optional().nullable().catch(undefined),
});
```
To use Virgo in your project, all you need to do is to create a `Y.Text` instance from `Y.Doc`, bind it to the virgo editor, then mount it to the DOM:
### Attributes Renderer
Attributes Renderer is a function that takes a delta and returns `TemplateResult<1>`, which is a valid [lit-html](https://github.com/lit/lit/tree/main/packages/lit-html) template result.
Virgo use this function to render text with custom format and it is also the way to customize the text render.
```ts
import * as Y from 'yjs';
import { VEditor } from '@blocksuite/virgo';
type AffineTextAttributes = {
// your custom attributes
};
const attributeRenderer: AttributeRenderer<AffineTextAttributes> = (
delta,
// you can use `selected` to check if the text node is selected
selected
) => {
// generate style from delta
return html`<span style=${style}
><v-text .str=${delta.insert}></v-text
></span>`;
};
const doc = new Y.Doc();
const yText = doc.getText('text');
const vEditor = new VEditor(yText);
vEditor.setAttributeRenderer(attributeRenderer);

@@ -70,5 +111,7 @@ const editorContainer = document.getElementById('editor');

You can go to [virgo playground](https://blocksuite-toeverything.vercel.app/examples/virgo/)
for online testing and check out the code in its [repository](https://github.com/toeverything/blocksuite/tree/master/packages/playground/examples/virgo).
You will see there is a `v-text` in the template, it is a custom element that render text node.
Virgo use them to calculate range so you have to use them to render text content from delta.
> 🚧 The documentation about customizing inline elements and detailed APIs are still in progress. Stay tuned!
### Rich Text
If you find Virgo's features too limited or difficult to use, you can refer to or directly use the [rich-text](https://github.com/toeverything/blocksuite/blob/f71df00ce18e3f300caad914aaedf63267158885/packages/blocks/src/components/rich-text/rich-text.ts) encapsulated in the blocks package. It contains basic editing features like copy/cut/paste, undo/redo (including range restore).

@@ -12,3 +12,3 @@ import { html } from 'lit';

userSelect: 'text',
padding: '0 1px',
padding: '0 0.5px',
outline: 'none',

@@ -15,0 +15,0 @@ })}

@@ -12,3 +12,3 @@ import { html, LitElement } from 'lit';

export class VirgoElement<
T extends BaseTextAttributes = BaseTextAttributes
T extends BaseTextAttributes = BaseTextAttributes,
> extends LitElement {

@@ -15,0 +15,0 @@ @property({ type: Object })

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

import { ZERO_WIDTH_SPACE } from '../consts.js';
import { VIRGO_ROOT_ATTR, ZERO_WIDTH_SPACE } from '../consts.js';
import type { DeltaInsert } from '../types.js';

@@ -21,2 +21,6 @@ import type { VirgoRootElement } from '../virgo.js';

get vTexts() {
return Array.from(this.querySelectorAll('v-text'));
}
get textLength() {

@@ -33,2 +37,3 @@ return this.vElements.reduce((acc, el) => acc + el.delta.insert.length, 0);

await Promise.all(this.vElements.map(el => el.updateComplete));
await Promise.all(this.vTexts.map(el => el.updateComplete));
return result;

@@ -47,3 +52,3 @@ }

const rootElement = this.closest(
'[data-virgo-root="true"]'
`[${VIRGO_ROOT_ATTR}]`
) as VirgoRootElement;

@@ -90,3 +95,3 @@ assertExists(rootElement, 'v-line must be inside a v-root');

// start and end of the line when the first and last element is embed element
padding: '0 1px',
padding: '0 0.5px',
})}>${renderElements}</div>`;

@@ -93,0 +98,0 @@ }

export const ZERO_WIDTH_SPACE = '\u200B';
// see https://en.wikipedia.org/wiki/Zero-width_non-joiner
export const ZERO_WIDTH_NON_JOINER = '\u200C';
export const VIRGO_ROOT_ATTR = 'data-virgo-root';

@@ -13,4 +13,2 @@ import type { z, ZodTypeDef } from 'zod';

export class VirgoAttributeService<TextAttributes extends BaseTextAttributes> {
private readonly _editor: VEditor<TextAttributes>;
private _marks: TextAttributes | null = null;

@@ -24,5 +22,3 @@

constructor(editor: VEditor<TextAttributes>) {
this._editor = editor;
}
constructor(public readonly editor: VEditor<TextAttributes>) {}

@@ -56,3 +52,3 @@ get marks() {

getFormat = (vRange: VRange, loose = false): TextAttributes => {
const deltas = this._editor.deltaService
const deltas = this.editor.deltaService
.getDeltasByVRange(vRange)

@@ -107,5 +103,5 @@ .filter(

// filter out undefined values
Object.entries(attributeResult.data).filter(([_, v]) => v)
Object.entries(attributeResult.data).filter(([_, v]) => v || v === null)
) as TextAttributes;
};
}

@@ -12,10 +12,6 @@ import { html, render } from 'lit';

export class VirgoDeltaService<TextAttributes extends BaseTextAttributes> {
private readonly _editor: VEditor<TextAttributes>;
constructor(public readonly editor: VEditor<TextAttributes>) {}
constructor(editor: VEditor<TextAttributes>) {
this._editor = editor;
}
get deltas() {
return this._editor.yText.toDelta() as DeltaInsert<TextAttributes>[];
return this.editor.yText.toDelta() as DeltaInsert<TextAttributes>[];
}

@@ -29,3 +25,3 @@

for (const delta of this.deltas) {
if (this._editor.isEmbed(delta)) {
if (this.editor.isEmbed(delta)) {
const dividedDeltas = [...delta.insert].map(subInsert => ({

@@ -82,3 +78,3 @@ insert: subInsert,

if (vRange.length >= 1) {
this._editor.mapDeltasInVRange(
this.editor.mapDeltasInVRange(
vRange,

@@ -208,4 +204,6 @@ (_, rangeIndex, deltaIndex) => {

render = async (syncVRange = true) => {
const rootElement = this._editor.rootElement;
if (!this.editor.mounted) return;
const rootElement = this.editor.rootElement;
const normalizedDeltas = this.normalizedDeltas;

@@ -227,3 +225,3 @@ const chunks = deltaInsertsToChunks(normalizedDeltas);

let selected = false;
const vRange = this._editor.getVRange();
const vRange = this.editor.getVRange();
if (vRange) {

@@ -239,3 +237,3 @@ selected = this.isNormalizedDeltaSelected(

delta,
this._editor.attributeService.normalizeAttributes,
this.editor.attributeService.normalizeAttributes,
selected

@@ -265,7 +263,7 @@ ),

// Lit may be crashed by IME input and we need to rerender whole editor for it
this._editor.rerenderWholeEditor();
await this._editor.waitForUpdate();
this.editor.rerenderWholeEditor();
await this.editor.waitForUpdate();
}
await this._editor.waitForUpdate();
await this.editor.waitForUpdate();

@@ -275,7 +273,7 @@ if (syncVRange) {

// otherwise there is a possibility of an error in the cursor position
this._editor.rangeService.syncVRange();
this.editor.rangeService.syncVRange();
}
this._editor.slots.updated.emit();
this.editor.slots.updated.emit();
};
}

@@ -23,11 +23,18 @@ import { assertExists } from '@blocksuite/global/utils';

get vRangeProvider() {
return this.editor.vRangeProvider;
}
mount = () => {
const rootElement = this.editor.rootElement;
if (!this.vRangeProvider) {
this.editor.disposables.addFromEvent(
document,
'selectionchange',
this._onSelectionChange
);
}
this.editor.disposables.addFromEvent(
document,
'selectionchange',
this._onSelectionChange
);
this.editor.disposables.addFromEvent(
rootElement,

@@ -47,3 +54,2 @@ 'beforeinput',

);
this.editor.disposables.addFromEvent(rootElement, 'scroll', this._onScroll);
this.editor.disposables.addFromEvent(

@@ -57,2 +63,28 @@ rootElement,

private _isRangeCompletelyInRoot = () => {
const selectionRoot = findDocumentOrShadowRoot(this.editor);
const selection = selectionRoot.getSelection();
if (!selection || selection.rangeCount === 0) return false;
const range = selection.getRangeAt(0);
const rootElement = this.editor.rootElement;
const rootRange = document.createRange();
rootRange.selectNode(rootElement);
if (
range.startContainer.compareDocumentPosition(range.endContainer) &
Node.DOCUMENT_POSITION_FOLLOWING
) {
return (
rootRange.comparePoint(range.startContainer, range.startOffset) >= 0 &&
rootRange.comparePoint(range.endContainer, range.endOffset) <= 0
);
} else {
return (
rootRange.comparePoint(range.endContainer, range.startOffset) >= 0 &&
rootRange.comparePoint(range.startContainer, range.endOffset) <= 0
);
}
};
private _onSelectionChange = () => {

@@ -70,3 +102,3 @@ const rootElement = this.editor.rootElement;

if (previousVRange !== null) {
this.editor.slots.vRangeUpdated.emit([null, 'native']);
this.editor.setVRange(null, false);
}

@@ -106,3 +138,3 @@

if (previousVRange !== null) {
this.editor.slots.vRangeUpdated.emit([null, 'native']);
this.editor.setVRange(null, false);
}

@@ -118,3 +150,3 @@ return;

if (!isMaybeVRangeEqual(previousVRange, vRange)) {
this.editor.slots.vRangeUpdated.emit([vRange, 'native']);
this.editor.setVRange(vRange, false);
}

@@ -153,3 +185,3 @@

if (this.editor.isReadonly) return;
if (this.editor.isReadonly || !this._isRangeCompletelyInRoot()) return;

@@ -222,3 +254,3 @@ const vRange = this.editor.getVRange();

this.editor.slots.vRangeUpdated.emit([
this.editor.setVRange(
{

@@ -228,4 +260,4 @@ index: newVRange.index + newData.length,

},
'input',
]);
false
);
}

@@ -235,14 +267,27 @@ }

private _firstRecomputeInFrame = true;
private _onBeforeInput = (event: InputEvent) => {
event.preventDefault();
if (this.editor.isReadonly || this._isComposing) return;
if (this._firstRecomputeInFrame) {
this._firstRecomputeInFrame = false;
this._onSelectionChange();
requestAnimationFrame(() => {
this._firstRecomputeInFrame = true;
});
if (
this.editor.isReadonly ||
this._isComposing ||
!this._isRangeCompletelyInRoot()
)
return;
if (!this.editor.getVRange()) return;
const targetRanges = event.getTargetRanges();
if (targetRanges.length > 0) {
const staticRange = targetRanges[0];
const range = document.createRange();
range.setStart(staticRange.startContainer, staticRange.startOffset);
range.setEnd(staticRange.endContainer, staticRange.endOffset);
const vRange = this.editor.toVRange(range);
if (!isMaybeVRangeEqual(this.editor.getVRange(), vRange)) {
this.editor.setVRange(vRange, false);
}
}
const vRange = this.editor.getVRange();

@@ -274,6 +319,2 @@ if (!vRange) return;

private _onScroll = () => {
this.editor.slots.scrollUpdated.emit(this.editor.rootElement.scrollLeft);
};
private _onKeyDown = (event: KeyboardEvent) => {

@@ -280,0 +321,0 @@ if (

@@ -6,3 +6,3 @@ import type { VRange } from '../types.js';

export interface VBeforeinputHookCtx<
TextAttributes extends BaseTextAttributes
TextAttributes extends BaseTextAttributes,
> {

@@ -16,3 +16,3 @@ vEditor: VEditor<TextAttributes>;

export interface VCompositionEndHookCtx<
TextAttributes extends BaseTextAttributes
TextAttributes extends BaseTextAttributes,
> {

@@ -19,0 +19,0 @@ vEditor: VEditor<TextAttributes>;

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

import { VirgoLine } from '../components/index.js';
import type { VRange } from '../types.js';
import type { VRangeUpdatedProp } from '../types.js';
import { assertExists } from '@blocksuite/global/utils';
import type { VirgoLine } from '../components/virgo-line.js';
import type { TextPoint, VRange, VRangeUpdatedProp } from '../types.js';
import type { BaseTextAttributes } from '../utils/base-attributes.js';

@@ -10,2 +11,3 @@ import { findDocumentOrShadowRoot } from '../utils/query.js';

} from '../utils/range-conversion.js';
import { calculateTextLength, getTextNodesFromElement } from '../utils/text.js';
import { isMaybeVRangeEqual } from '../utils/v-range.js';

@@ -15,27 +17,30 @@ import type { VEditor } from '../virgo.js';

export class VirgoRangeService<TextAttributes extends BaseTextAttributes> {
private readonly _editor: VEditor<TextAttributes>;
private _prevVRange: VRange | null = null;
private _vRange: VRange | null = null;
private _lastScrollLeft = 0;
constructor(editor: VEditor<TextAttributes>) {
this._editor = editor;
constructor(public readonly editor: VEditor<TextAttributes>) {}
get vRangeProvider() {
return this.editor.vRangeProvider;
}
onVRangeUpdated = ([newVRange, origin]: VRangeUpdatedProp) => {
get rootElement() {
return this.editor.rootElement;
}
onVRangeUpdated = async ([newVRange, sync]: VRangeUpdatedProp) => {
const eq = isMaybeVRangeEqual(this._vRange, newVRange);
if (eq) {
return;
}
this._vRange = newVRange;
if (
this._editor.mounted &&
newVRange &&
!isMaybeVRangeEqual(this._prevVRange, newVRange)
) {
// no need to sync and native selection behavior about shift+arrow will
// be broken if we sync
this._editor.requestUpdate(false);
// try to trigger update because the `selected` state of the virgo element may change
if (this.editor.mounted) {
// range change may happen before the editor is prepared
await this.editor.waitForUpdate();
this.editor.requestUpdate(false);
}
this._prevVRange = newVRange;
if (origin !== 'other') {
if (!sync) {
return;

@@ -45,7 +50,7 @@ }

if (this._vRange === null) {
const selectionRoot = findDocumentOrShadowRoot(this._editor);
const selectionRoot = findDocumentOrShadowRoot(this.editor);
const selection = selectionRoot.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
if (range.intersectsNode(this._editor.rootElement)) {
if (range.intersectsNode(this.editor.rootElement)) {
selection.removeAllRanges();

@@ -70,7 +75,176 @@ }

getNativeSelection(): Selection | null {
const selectionRoot = findDocumentOrShadowRoot(this.editor);
const selection = selectionRoot.getSelection();
if (!selection) return null;
if (selection.rangeCount === 0) return null;
return selection;
}
getVRange = (): VRange | null => {
if (this.vRangeProvider) {
return this.vRangeProvider.getVRange();
}
return this._vRange;
};
getVRangeFromElement = (element: Element): VRange | null => {
const range = document.createRange();
const text = element.querySelector('[data-virgo-text');
if (!text) {
return null;
}
const textNode = text.childNodes[1];
assertExists(textNode instanceof Text);
range.setStart(textNode, 0);
range.setEnd(textNode, textNode.textContent?.length ?? 0);
const vRange = this.toVRange(range);
return vRange;
};
getTextPoint(rangeIndex: VRange['index']): TextPoint {
const vLines = Array.from(this.rootElement.querySelectorAll('v-line'));
let index = 0;
for (const vLine of vLines) {
const texts = 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 += calculateTextLength(text);
}
index += 1;
}
throw new Error('failed to find leaf');
}
// the number is related to the VirgoLine's textLength
getLine(rangeIndex: VRange['index']): readonly [VirgoLine, number] {
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.editor.yTextLength
) {
return [lineElement, rangeIndex - index] as const;
}
index += lineElement.textLength + 1;
}
throw new Error('failed to find line');
}
isVRangeValid = (vRange: VRange | null): boolean => {
return !(
vRange &&
(vRange.index < 0 ||
vRange.index + vRange.length > this.editor.yText.length)
);
};
/**
* There are two cases to have the second line:
* 1. long text auto wrap in span element
* 2. soft break
*/
isFirstLine = (vRange: VRange | null): boolean => {
if (!vRange) return false;
if (vRange.length > 0) {
throw new Error('vRange should be collapsed');
}
const range = this.toDomRange(vRange);
if (!range) {
throw new Error('failed to convert vRange to domRange');
}
// check case 1:
const beforeText = this.editor.yTextString.slice(0, vRange.index);
if (beforeText.includes('\n')) {
return false;
}
// check case 2:
// If there is a wrapped text, there are two possible positions for
// cursor: (in first line and in second line)
// aaaaaaaa| or aaaaaaaa
// bb |bb
// We have no way to distinguish them and we just assume that the cursor
// can not in the first line because if we apply the vRange manually the
// cursor will jump to the second line.
const container = range.commonAncestorContainer.parentElement;
assertExists(container);
const containerRect = container.getBoundingClientRect();
// There will be two rects if the cursor is at the edge of the line:
// aaaaaaaa| or aaaaaaaa
// bb |bb
const rangeRects = range.getClientRects();
// We use last rect here to make sure we get the second rect.
// (Based on the assumption that the cursor can not in the first line)
const rangeRect = rangeRects[rangeRects.length - 1];
return rangeRect.top === containerRect.top;
};
/**
* There are two cases to have the second line:
* 1. long text auto wrap in span element
* 2. soft break
*/
isLastLine = (vRange: VRange | null): boolean => {
if (!vRange) return false;
if (vRange.length > 0) {
throw new Error('vRange should be collapsed');
}
// check case 1:
const afterText = this.editor.yTextString.slice(vRange.index);
if (afterText.includes('\n')) {
return false;
}
const range = this.toDomRange(vRange);
if (!range) {
throw new Error('failed to convert vRange to domRange');
}
// check case 2:
// If there is a wrapped text, there are two possible positions for
// cursor: (in first line and in second line)
// aaaaaaaa| or aaaaaaaa
// bb |bb
// We have no way to distinguish them and we just assume that the cursor
// can not in the first line because if we apply the vRange manually the
// cursor will jump to the second line.
const container = range.commonAncestorContainer.parentElement;
assertExists(container);
const containerRect = container.getBoundingClientRect();
// There will be two rects if the cursor is at the edge of the line:
// aaaaaaaa| or aaaaaaaa
// bb |bb
const rangeRects = range.getClientRects();
// We use last rect here to make sure we get the second rect.
// (Based on the assumption that the cursor can not in the first line)
const rangeRect = rangeRects[rangeRects.length - 1];
return rangeRect.bottom === containerRect.bottom;
};
/**
* the vRange is synced to the native selection asynchronically

@@ -80,13 +254,42 @@ * if sync is true, the native selection will be synced immediately

setVRange = (vRange: VRange | null, sync = true): void => {
if (
vRange &&
(vRange.index < 0 ||
vRange.index + vRange.length > this._editor.yText.length)
) {
if (!this.isVRangeValid(vRange)) {
throw new Error('invalid vRange');
}
this._editor.slots.vRangeUpdated.emit([vRange, sync ? 'other' : 'silent']);
if (this.vRangeProvider) {
this.vRangeProvider.setVRange(vRange);
return;
}
this.editor.slots.vRangeUpdated.emit([vRange, sync]);
};
focusEnd = (): void => {
this.setVRange({
index: this.editor.yTextLength,
length: 0,
});
};
focusStart = (): void => {
this.setVRange({
index: 0,
length: 0,
});
};
selectAll = (): void => {
this.setVRange({
index: 0,
length: this.editor.yTextLength,
});
};
focusIndex = (index: number): void => {
this.setVRange({
index,
length: 0,
});
};
/**

@@ -96,4 +299,5 @@ * sync the dom selection from vRange for **this Editor**

syncVRange = (): void => {
if (this._vRange && this._editor.mounted) {
this._applyVRange(this._vRange);
const vRange = this.getVRange();
if (vRange && this.editor.mounted) {
this._applyVRange(vRange);
}

@@ -106,3 +310,3 @@ };

toDomRange = (vRange: VRange): Range | null => {
const rootElement = this._editor.rootElement;
const rootElement = this.editor.rootElement;
return virgoRangeToDomRange(rootElement, vRange);

@@ -138,3 +342,3 @@ };

toVRange = (range: Range): VRange | null => {
const { rootElement, yText } = this._editor;
const { rootElement, yText } = this.editor;

@@ -144,8 +348,4 @@ return domRangeToVirgoRange(range, rootElement, yText);

onScrollUpdated = (scrollLeft: number) => {
this._lastScrollLeft = scrollLeft;
};
private _applyVRange = (vRange: VRange): void => {
const selectionRoot = findDocumentOrShadowRoot(this._editor);
const selectionRoot = findDocumentOrShadowRoot(this.editor);
const selection = selectionRoot.getSelection();

@@ -163,37 +363,4 @@ if (!selection) {

selection.addRange(newRange);
this._scrollLineIntoViewIfNeeded(newRange);
this._scrollCursorIntoViewIfNeeded(newRange);
this._editor.slots.rangeUpdated.emit(newRange);
this.editor.slots.rangeUpdated.emit(newRange);
};
private _scrollLineIntoViewIfNeeded = (range: Range) => {
if (this._editor.shouldLineScrollIntoView) {
let lineElement: HTMLElement | null = range.endContainer.parentElement;
while (!(lineElement instanceof VirgoLine)) {
lineElement = lineElement?.parentElement ?? null;
}
lineElement?.scrollIntoView({
block: 'nearest',
});
}
};
private _scrollCursorIntoViewIfNeeded = (range: Range) => {
if (this._editor.shouldCursorScrollIntoView) {
const root = this._editor.rootElement;
const rootRect = root.getBoundingClientRect();
const rangeRect = range.getBoundingClientRect();
let moveX = 0;
if (rangeRect.left > rootRect.left) {
moveX = Math.max(this._lastScrollLeft, rangeRect.left - rootRect.right);
}
root.scrollLeft = moveX;
this._lastScrollLeft = moveX;
}
};
}

@@ -798,7 +798,3 @@ import { expect, test } from '@playwright/test';

await type(page, 'abc');
await press(page, 'Enter');
await type(page, 'def');
await press(page, 'Enter');
await type(page, 'ghi');
await type(page, 'abc\ndef\nghi');

@@ -875,2 +871,3 @@ expect(await editorA.innerText()).toBe('abc\ndef\nghi');

await press(page, 'ArrowLeft');
await page.waitForTimeout(100);
page.keyboard.down('Shift');

@@ -877,0 +874,0 @@ await press(page, 'ArrowLeft');

@@ -6,3 +6,3 @@ import type { TemplateResult } from 'lit';

export type DeltaInsert<
TextAttributes extends BaseTextAttributes = BaseTextAttributes
TextAttributes extends BaseTextAttributes = BaseTextAttributes,
> = {

@@ -14,3 +14,3 @@ insert: string;

export type AttributeRenderer<
TextAttributes extends BaseTextAttributes = BaseTextAttributes
TextAttributes extends BaseTextAttributes = BaseTextAttributes,
> = (

@@ -26,9 +26,6 @@ delta: DeltaInsert<TextAttributes>,

export type VRangeUpdatedProp = [
range: VRange | null,
type: 'native' | 'input' | 'other' | 'silent'
];
export type VRangeUpdatedProp = [range: VRange | null, sync: boolean];
export type DeltaEntry<
TextAttributes extends BaseTextAttributes = BaseTextAttributes
TextAttributes extends BaseTextAttributes = BaseTextAttributes,
> = [delta: DeltaInsert<TextAttributes>, range: VRange];

@@ -35,0 +32,0 @@

import { z } from 'zod';
export const baseTextAttributes = z.object({
bold: z.literal(true).optional().catch(undefined),
italic: z.literal(true).optional().catch(undefined),
underline: z.literal(true).optional().catch(undefined),
strike: z.literal(true).optional().catch(undefined),
code: z.literal(true).optional().catch(undefined),
link: z.string().optional().catch(undefined),
bold: z.literal(true).optional().nullable().catch(undefined),
italic: z.literal(true).optional().nullable().catch(undefined),
underline: z.literal(true).optional().nullable().catch(undefined),
strike: z.literal(true).optional().nullable().catch(undefined),
code: z.literal(true).optional().nullable().catch(undefined),
link: z.string().optional().nullable().catch(undefined),
});
// .partial();
export type BaseTextAttributes = z.infer<typeof baseTextAttributes>;
import type { VirgoElement, VirgoLine } from '../components/index.js';
import { ZERO_WIDTH_SPACE } from '../consts.js';
import { VIRGO_ROOT_ATTR, ZERO_WIDTH_SPACE } from '../consts.js';
import type { DomPoint, TextPoint } from '../types.js';

@@ -101,4 +101,4 @@ import { isNativeTextInVText, isVElement, isVLine, isVRoot } from './guard.js';

node instanceof Element
? node.closest('[data-virgo-root="true"]')
: node.parentElement?.closest('[data-virgo-root="true"]');
? node.closest(`[${VIRGO_ROOT_ATTR}]`)
: node.parentElement?.closest(`[${VIRGO_ROOT_ATTR}]`);

@@ -105,0 +105,0 @@ if (container) {

import { assertExists } from '@blocksuite/global/utils';
import { VIRGO_ROOT_ATTR } from '../consts.js';
import type { VEditor, VirgoRootElement } from '../virgo.js';

@@ -7,3 +8,3 @@ import type { BaseTextAttributes } from './base-attributes.js';

export function findDocumentOrShadowRoot<
TextAttributes extends BaseTextAttributes
TextAttributes extends BaseTextAttributes,
>(editor: VEditor<TextAttributes>): Document {

@@ -30,3 +31,3 @@ const el = editor.rootElement;

const rootElement = element.closest(
'[data-virgo-root="true"]'
`[${VIRGO_ROOT_ATTR}]`
) as VirgoRootElement;

@@ -33,0 +34,0 @@ assertExists(rootElement, 'element must be inside a v-root');

@@ -11,164 +11,35 @@ import type { VRange } from '../types.js';

) {
if (vRange.index >= 0 && data) {
editor.slots.vRangeUpdated.emit([
{
index: vRange.index + data.length,
length: 0,
},
'input',
]);
editor.insertText(vRange, data, attributes);
}
if (!data) return;
editor.insertText(vRange, data, attributes);
editor.setVRange(
{
index: vRange.index + data.length,
length: 0,
},
false
);
}
function handleInsertParagraph(vRange: VRange, editor: VEditor) {
if (vRange.index >= 0) {
editor.slots.vRangeUpdated.emit([
{
index: vRange.index + 1,
length: 0,
},
'input',
]);
editor.insertLineBreak(vRange);
}
}
function handleDeleteBackward(vRange: VRange, editor: VEditor) {
if (vRange.index >= 0) {
if (vRange.length > 0) {
editor.slots.vRangeUpdated.emit([
{
index: vRange.index,
length: 0,
},
'input',
]);
editor.deleteText(vRange);
return;
}
if (vRange.index > 0) {
const originalString = editor.yText.toString().slice(0, vRange.index);
const segments = [...new Intl.Segmenter().segment(originalString)];
const deletedLength = segments[segments.length - 1].segment.length;
editor.slots.vRangeUpdated.emit([
{
index: vRange.index - deletedLength,
length: 0,
},
'input',
]);
editor.deleteText({
index: vRange.index - deletedLength,
length: deletedLength,
});
}
}
}
function handleDeleteForward(editor: VEditor, vRange: VRange) {
if (vRange.index < editor.yText.length) {
if (vRange.length > 0) {
editor.slots.vRangeUpdated.emit([
{
index: vRange.index,
length: 0,
},
'input',
]);
editor.deleteText(vRange);
} else {
const originalString = editor.yText.toString();
const segments = [...new Intl.Segmenter().segment(originalString)];
const slicedString = originalString.slice(0, vRange.index);
const slicedSegments = [...new Intl.Segmenter().segment(slicedString)];
const deletedLength = segments[slicedSegments.length].segment.length;
editor.slots.vRangeUpdated.emit([
{
index: vRange.index,
length: 0,
},
'input',
]);
editor.deleteText({
index: vRange.index,
length: deletedLength,
});
}
}
}
function handleDeleteWordBackward(editor: VEditor, vRange: VRange) {
const matches = /\S+\s*$/.exec(
editor.yText.toString().slice(0, vRange.index)
editor.insertLineBreak(vRange);
editor.setVRange(
{
index: vRange.index + 1,
length: 0,
},
false
);
if (matches) {
const deleteLength = matches[0].length;
editor.slots.vRangeUpdated.emit([
{
index: vRange.index - deleteLength,
length: 0,
},
'input',
]);
editor.deleteText({
index: vRange.index - deleteLength,
length: deleteLength,
});
}
}
function handleDeleteWordForward(editor: VEditor, vRange: VRange) {
const matches = /^\s*\S+/.exec(editor.yText.toString().slice(vRange.index));
if (matches) {
const deleteLength = matches[0].length;
editor.slots.vRangeUpdated.emit([
{
index: vRange.index,
length: 0,
},
'input',
]);
editor.deleteText({
function handleDelete(vRange: VRange, editor: VEditor) {
editor.deleteText(vRange);
editor.setVRange(
{
index: vRange.index,
length: deleteLength,
});
}
length: 0,
},
false
);
}
function handleDeleteLine(editor: VEditor, vRange: VRange) {
if (vRange.length > 0) {
editor.slots.vRangeUpdated.emit([
{
index: vRange.index,
length: 0,
},
'input',
]);
editor.deleteText(vRange);
return;
}
if (vRange.index > 0) {
const str = editor.yText.toString();
const deleteLength =
vRange.index - Math.max(0, str.slice(0, vRange.index).lastIndexOf('\n'));
editor.slots.vRangeUpdated.emit([
{
index: vRange.index - deleteLength,
length: 0,
},
'input',
]);
editor.deleteText({
index: vRange.index - deleteLength,
length: deleteLength,
});
}
}
export function transformInput<TextAttributes extends BaseTextAttributes>(

@@ -181,51 +52,16 @@ inputType: string,

) {
// You can find explanation of inputType here:
// [Input Events Level 2](https://w3c.github.io/input-events/#interface-InputEvent-Attributes)
switch (inputType) {
case 'insertText': {
handleInsertText(vRange, data, editor, attributes);
return;
}
if (!editor.isVRangeValid(vRange)) return;
case 'insertParagraph': {
handleInsertParagraph(vRange, editor);
return;
}
// Chrome and Safari on Mac: Backspace or Ctrl + H
case 'deleteContentBackward':
case 'deleteByCut': {
handleDeleteBackward(vRange, editor);
return;
}
// Chrome on Mac: Fn + Backspace or Ctrl + D
// Safari on Mac: Ctrl + K or Ctrl + D
case 'deleteContentForward': {
handleDeleteForward(editor, vRange);
return;
}
// On Mac: Option + Backspace
// On iOS: Hold the backspace for a while and the whole words will start to disappear
case 'deleteWordBackward': {
handleDeleteWordBackward(editor, vRange);
return;
}
// onMac: Fn + Option + Backspace
// onWindows: Control + Delete
case 'deleteWordForward': {
handleDeleteWordForward(editor, vRange);
return;
}
// deleteHardLineBackward: Safari on Mac: Cmd + Backspace
// deleteSoftLineBackward: Chrome on Mac: Cmd + Backspace
case 'deleteHardLineBackward':
case 'deleteSoftLineBackward': {
handleDeleteLine(editor, vRange);
return;
}
if (inputType === 'insertText') {
handleInsertText(vRange, data, editor, attributes);
} else if (
inputType === 'insertParagraph' ||
inputType === 'insertLineBreak'
) {
handleInsertParagraph(vRange, editor);
} else if (inputType.startsWith('delete')) {
handleDelete(vRange, editor);
} else {
return;
}
}

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

import type { NullablePartial } from '@blocksuite/global/utils';
import { assertExists, DisposableGroup, Slot } from '@blocksuite/global/utils';

@@ -6,3 +5,3 @@ import { nothing, render } from 'lit';

import type { VirgoLine } from './components/index.js';
import { VIRGO_ROOT_ATTR } from './consts.js';
import { VirgoHookService } from './services/hook.js';

@@ -15,19 +14,13 @@ import {

} from './services/index.js';
import type {
DeltaInsert,
TextPoint,
VRange,
VRangeUpdatedProp,
} from './types.js';
import type { DeltaInsert, VRange, VRangeUpdatedProp } from './types.js';
import {
type BaseTextAttributes,
findDocumentOrShadowRoot,
nativePointToTextPoint,
textPointToDomPoint,
} from './utils/index.js';
import { calculateTextLength, getTextNodesFromElement } from './utils/text.js';
import { getTextNodesFromElement } from './utils/text.js';
import { intersectVRange } from './utils/v-range.js';
export type VirgoRootElement<
T extends BaseTextAttributes = BaseTextAttributes
T extends BaseTextAttributes = BaseTextAttributes,
> = HTMLElement & {

@@ -37,4 +30,10 @@ virgoEditor: VEditor<T>;

export interface VRangeProvider {
getVRange(): VRange | null;
setVRange(vRange: VRange | null): void;
vRangeUpdatedSlot: Slot<VRangeUpdatedProp>;
}
export class VEditor<
TextAttributes extends BaseTextAttributes = BaseTextAttributes
TextAttributes extends BaseTextAttributes = BaseTextAttributes,
> {

@@ -70,6 +69,4 @@ static nativePointToTextPoint = nativePointToTextPoint;

shouldLineScrollIntoView = true;
shouldCursorScrollIntoView = true;
readonly isEmbed: (delta: DeltaInsert<TextAttributes>) => boolean;
readonly vRangeProvider: VRangeProvider | null;

@@ -82,4 +79,4 @@ slots: {

rangeUpdated: Slot<Range>;
scrollUpdated: Slot<number>;
};
get yText() {

@@ -141,3 +138,14 @@ return this._yText;

getVRange = this.rangeService.getVRange;
getVRangeFromElement = this.rangeService.getVRangeFromElement;
getNativeSelection = this.rangeService.getNativeSelection;
getTextPoint = this.rangeService.getTextPoint;
getLine = this.rangeService.getLine;
isVRangeValid = this.rangeService.isVRangeValid;
isFirstLine = this.rangeService.isFirstLine;
isLastLine = this.rangeService.isLastLine;
setVRange = this.rangeService.setVRange;
focusStart = this.rangeService.focusStart;
focusEnd = this.rangeService.focusEnd;
selectAll = this.rangeService.selectAll;
focusIndex = this.rangeService.focusIndex;
syncVRange = this.rangeService.syncVRange;

@@ -161,2 +169,3 @@

hooks?: VirgoHookService<TextAttributes>['hooks'];
vRangeProvider?: VRangeProvider;
} = {}

@@ -174,6 +183,7 @@ ) {

const { isEmbed = () => false, hooks = {} } = ops;
const { isEmbed = () => false, hooks = {}, vRangeProvider = null } = ops;
this._yText = yText;
this.isEmbed = isEmbed;
this._hooksService = new VirgoHookService(this, hooks);
this.vRangeProvider = vRangeProvider;

@@ -186,7 +196,10 @@ this.slots = {

rangeUpdated: new Slot<Range>(),
scrollUpdated: new Slot<number>(),
};
if (vRangeProvider) {
vRangeProvider.vRangeUpdatedSlot.on(prop => {
this.slots.vRangeUpdated.emit(prop);
});
}
this.slots.vRangeUpdated.on(this.rangeService.onVRangeUpdated);
this.slots.scrollUpdated.on(this.rangeService.onScrollUpdated);
}

@@ -204,4 +217,2 @@

this._deltaService.render();
this._eventService.mount();

@@ -211,2 +222,3 @@

this.slots.mounted.emit();
this._deltaService.render();
}

@@ -216,2 +228,3 @@

render(nothing, this.rootElement);
this.rootElement.removeAttribute(VIRGO_ROOT_ATTR);
this._rootElement = null;

@@ -224,7 +237,3 @@ this._mounted = false;

requestUpdate(syncVRange = true): void {
Promise.resolve().then(() => {
assertExists(this._rootElement);
this._deltaService.render(syncVRange);
});
this._deltaService.render(syncVRange);
}

@@ -237,59 +246,2 @@

getNativeSelection(): Selection | null {
const selectionRoot = findDocumentOrShadowRoot(this);
const selection = selectionRoot.getSelection();
if (!selection) return null;
if (selection.rangeCount === 0) return null;
return selection;
}
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 += calculateTextLength(text);
}
index += 1;
}
throw new Error('failed to find leaf');
}
// the number is related 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');
}
setReadonly(isReadonly: boolean): void {

@@ -304,19 +256,2 @@ this.rootElement.contentEditable = isReadonly ? 'false' : 'true';

/**
* the vRange is synced to the native selection asynchronically
*/
focusEnd(): void {
this.rangeService.setVRange({
index: this.yText.length,
length: 0,
});
}
focusByIndex(index: number): void {
this.rangeService.setVRange({
index: index,
length: 0,
});
}
deleteText(vRange: VRange): void {

@@ -358,3 +293,3 @@ this._transact(() => {

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

@@ -371,4 +306,7 @@ match?: (delta: DeltaInsert, deltaVRange: VRange) => boolean;

.forEach(([_delta, deltaVRange]) => {
const normalizedAttributes =
this._attributeService.normalizeAttributes(attributes);
if (!normalizedAttributes) return;
const targetVRange = intersectVRange(vRange, deltaVRange);
if (!targetVRange) return;

@@ -384,3 +322,3 @@

targetVRange.length,
attributes
normalizedAttributes
);

@@ -438,4 +376,2 @@ });

Promise.resolve().then(() => {
assertExists(this._rootElement);
this.deltaService.render();

@@ -442,0 +378,0 @@ });

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

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