@blocksuite/virgo
Advanced tools
Comparing version 0.5.0-alpha.0 to 0.5.0-alpha.1
{ | ||
"name": "@blocksuite/virgo", | ||
"version": "0.5.0-alpha.0", | ||
"version": "0.5.0-alpha.1", | ||
"description": "A micro editor.", | ||
"main": "src/index.ts", | ||
"main": "dist/index.js", | ||
"type": "module", | ||
"scripts": { | ||
"build": "tsc", | ||
"test:unit": "vitest --run", | ||
"test:unit:coverage": "vitest run --coverage", | ||
"test:unit:ui": "vitest --ui", | ||
"test:e2e": "playwright test", | ||
"test": "pnpm test:unit && pnpm test:e2e" | ||
}, | ||
"keywords": [], | ||
@@ -20,3 +12,3 @@ "author": "toeverything", | ||
"lit": "^2.6.1", | ||
"yjs": "^13.5.46" | ||
"yjs": "^13.5.48" | ||
}, | ||
@@ -28,18 +20,21 @@ "peerDependencies": { | ||
"exports": { | ||
"./*": "./src/*.ts", | ||
".": "./src/index.ts" | ||
"./*": "./dist/*.js", | ||
".": "./dist/index.js" | ||
}, | ||
"publishConfig": { | ||
"access": "public", | ||
"main": "dist/index.js", | ||
"types": "dist/index.d.ts", | ||
"exports": { | ||
"./*": "./dist/*.js", | ||
".": "./dist/index.js" | ||
} | ||
"access": "public" | ||
}, | ||
"dependencies": { | ||
"@blocksuite/global": "workspace:*", | ||
"@blocksuite/global": "0.5.0-alpha.1", | ||
"zod": "^3.20.6" | ||
} | ||
} | ||
}, | ||
"scripts": { | ||
"build": "tsc", | ||
"test:unit": "vitest --run", | ||
"test:unit:coverage": "vitest run --coverage", | ||
"test:unit:ui": "vitest --ui", | ||
"test:e2e": "playwright test", | ||
"test": "pnpm test:unit && pnpm test:e2e" | ||
}, | ||
"types": "dist/index.d.ts" | ||
} |
@@ -1,3 +0,3 @@ | ||
export * from './base-text.js'; | ||
export * from './virgo-element.js'; | ||
export * from './virgo-line.js'; | ||
export * from './virgo-unit-text.js'; | ||
export * from './virgo-text.js'; |
import { html, LitElement } from 'lit'; | ||
import { customElement, property } from 'lit/decorators.js'; | ||
import type { TextElement } from '../types.js'; | ||
import type { BaseTextAttributes } from '../utils/index.js'; | ||
import type { VirgoElement } from './virgo-element.js'; | ||
@customElement('virgo-line') | ||
export class VirgoLine extends LitElement { | ||
@customElement('v-line') | ||
export class VirgoLine< | ||
TextAttributes extends BaseTextAttributes = BaseTextAttributes | ||
> extends LitElement { | ||
@property({ attribute: false }) | ||
elements: TextElement[] = []; | ||
elements: VirgoElement<TextAttributes>[] = []; | ||
get textLength() { | ||
return this.elements.reduce((acc, el) => acc + el.delta.insert.length, 0); | ||
} | ||
get textContent() { | ||
return this.elements.reduce((acc, el) => acc + el.delta.insert, ''); | ||
} | ||
render() { | ||
return html` | ||
<style> | ||
virgo-line { | ||
return html`<style> | ||
v-line { | ||
display: block; | ||
} | ||
</style> | ||
<div>${this.elements}</div> | ||
`; | ||
<div>${this.elements}</div>`; | ||
} | ||
@@ -29,4 +38,4 @@ | ||
interface HTMLElementTagNameMap { | ||
'virgo-line': VirgoLine; | ||
'v-line': VirgoLine; | ||
} | ||
} |
@@ -9,2 +9,6 @@ import { getDefaultPlaygroundURL } from '@blocksuite/global/utils'; | ||
export async function press(page: Page, content: string) { | ||
await page.keyboard.press(content, { delay: 50 }); | ||
} | ||
export async function enterVirgoPlayground(page: Page) { | ||
@@ -75,1 +79,25 @@ const url = new URL( | ||
} | ||
export async function getVirgoRichTextLine( | ||
page: Page, | ||
index: number, | ||
i = 0 | ||
): Promise<readonly [string, number]> { | ||
return await page.evaluate( | ||
([index, i]) => { | ||
const richTexts = document | ||
.querySelector('test-page') | ||
?.shadowRoot?.querySelectorAll('rich-text'); | ||
if (!richTexts) { | ||
throw new Error('Cannot find rich-text'); | ||
} | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
const editor = (richTexts[i] as any).vEditor as VEditor; | ||
const line = editor.getLine(index); | ||
return [line[0].textContent, line[1]] as const; | ||
}, | ||
[index, i] | ||
); | ||
} |
@@ -8,2 +8,4 @@ import { expect, test } from '@playwright/test'; | ||
getDeltaFromVirgoRichText, | ||
getVirgoRichTextLine, | ||
press, | ||
setVirgoRichTextRange, | ||
@@ -42,6 +44,7 @@ type, | ||
await focusVirgoRichText(page); | ||
await page.keyboard.press('Backspace'); | ||
await page.keyboard.press('Backspace'); | ||
await page.keyboard.press('Backspace'); | ||
await page.keyboard.press('Backspace'); | ||
await page.waitForTimeout(50); | ||
await press(page, 'Backspace'); | ||
await press(page, 'Backspace'); | ||
await press(page, 'Backspace'); | ||
await press(page, 'Backspace'); | ||
@@ -62,4 +65,5 @@ expect(await editorA.innerText()).toBe('abc'); | ||
await focusVirgoRichText(page); | ||
await page.keyboard.press('Enter'); | ||
await page.keyboard.press('Enter'); | ||
await page.waitForTimeout(50); | ||
await press(page, 'Enter'); | ||
await press(page, 'Enter'); | ||
await type(page, 'bbb'); | ||
@@ -83,7 +87,8 @@ | ||
await focusVirgoRichText(page); | ||
await page.keyboard.press('Backspace'); | ||
await page.keyboard.press('Backspace'); | ||
await page.keyboard.press('Backspace'); | ||
await page.keyboard.press('Backspace'); | ||
await page.keyboard.press('Backspace'); | ||
await page.waitForTimeout(50); | ||
await press(page, 'Backspace'); | ||
await press(page, 'Backspace'); | ||
await press(page, 'Backspace'); | ||
await press(page, 'Backspace'); | ||
await press(page, 'Backspace'); | ||
@@ -103,7 +108,8 @@ expect(await editorA.innerText()).toBe('abc'); | ||
await focusVirgoRichText(page); | ||
await page.keyboard.press('ArrowLeft'); | ||
await page.keyboard.press('ArrowLeft'); | ||
await page.waitForTimeout(50); | ||
await press(page, 'ArrowLeft'); | ||
await press(page, 'ArrowLeft'); | ||
await type(page, 'bb'); | ||
await page.keyboard.press('ArrowRight'); | ||
await page.keyboard.press('ArrowRight'); | ||
await press(page, 'ArrowRight'); | ||
await press(page, 'ArrowRight'); | ||
await type(page, 'dd'); | ||
@@ -124,6 +130,7 @@ | ||
await focusVirgoRichText(page); | ||
await page.keyboard.press('ArrowLeft'); | ||
await page.keyboard.press('ArrowLeft'); | ||
await page.keyboard.press('Enter'); | ||
await page.keyboard.press('Enter'); | ||
await page.waitForTimeout(50); | ||
await press(page, 'ArrowLeft'); | ||
await press(page, 'ArrowLeft'); | ||
await press(page, 'Enter'); | ||
await press(page, 'Enter'); | ||
@@ -169,3 +176,3 @@ expect(await editorA.innerText()).toBe('abbbc\n' + ZERO_WIDTH_SPACE + '\ndd'); | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
(richTextA as any).vEditor.setReadOnly(true); | ||
(richTextA as any).vEditor.setReadonly(true); | ||
}); | ||
@@ -189,4 +196,4 @@ | ||
const editorAUnderline = page.getByText('underline').nth(0); | ||
const editorAStrikethrough = page.getByText('strikethrough').nth(0); | ||
const editorAInlineCode = page.getByText('inline-code').nth(0); | ||
const editorAStrike = page.getByText('strike').nth(0); | ||
const editorACode = page.getByText('code').nth(0); | ||
@@ -214,2 +221,3 @@ const editorAUndo = page.getByText('undo').nth(0); | ||
editorABold.click(); | ||
page.waitForTimeout(50); | ||
delta = await getDeltaFromVirgoRichText(page); | ||
@@ -232,2 +240,3 @@ expect(delta).toEqual([ | ||
editorAItalic.click(); | ||
page.waitForTimeout(50); | ||
delta = await getDeltaFromVirgoRichText(page); | ||
@@ -251,2 +260,3 @@ expect(delta).toEqual([ | ||
editorAUnderline.click(); | ||
page.waitForTimeout(50); | ||
delta = await getDeltaFromVirgoRichText(page); | ||
@@ -270,3 +280,4 @@ expect(delta).toEqual([ | ||
editorAStrikethrough.click(); | ||
editorAStrike.click(); | ||
page.waitForTimeout(50); | ||
delta = await getDeltaFromVirgoRichText(page); | ||
@@ -283,3 +294,3 @@ expect(delta).toEqual([ | ||
underline: true, | ||
strikethrough: true, | ||
strike: true, | ||
}, | ||
@@ -292,3 +303,4 @@ }, | ||
editorAInlineCode.click(); | ||
editorACode.click(); | ||
page.waitForTimeout(50); | ||
delta = await getDeltaFromVirgoRichText(page); | ||
@@ -305,4 +317,4 @@ expect(delta).toEqual([ | ||
underline: true, | ||
strikethrough: true, | ||
inlineCode: true, | ||
strike: true, | ||
code: true, | ||
}, | ||
@@ -318,2 +330,3 @@ }, | ||
}); | ||
page.waitForTimeout(50); | ||
delta = await getDeltaFromVirgoRichText(page); | ||
@@ -329,2 +342,3 @@ expect(delta).toEqual([ | ||
}); | ||
page.waitForTimeout(50); | ||
delta = await getDeltaFromVirgoRichText(page); | ||
@@ -341,4 +355,4 @@ expect(delta).toEqual([ | ||
underline: true, | ||
strikethrough: true, | ||
inlineCode: true, | ||
strike: true, | ||
code: true, | ||
}, | ||
@@ -352,2 +366,3 @@ }, | ||
editorABold.click(); | ||
page.waitForTimeout(50); | ||
delta = await getDeltaFromVirgoRichText(page); | ||
@@ -363,4 +378,4 @@ expect(delta).toEqual([ | ||
underline: true, | ||
strikethrough: true, | ||
inlineCode: true, | ||
strike: true, | ||
code: true, | ||
}, | ||
@@ -374,2 +389,3 @@ }, | ||
editorAItalic.click(); | ||
page.waitForTimeout(50); | ||
delta = await getDeltaFromVirgoRichText(page); | ||
@@ -384,4 +400,4 @@ expect(delta).toEqual([ | ||
underline: true, | ||
strikethrough: true, | ||
inlineCode: true, | ||
strike: true, | ||
code: true, | ||
}, | ||
@@ -395,2 +411,3 @@ }, | ||
editorAUnderline.click(); | ||
page.waitForTimeout(50); | ||
delta = await getDeltaFromVirgoRichText(page); | ||
@@ -404,4 +421,4 @@ expect(delta).toEqual([ | ||
attributes: { | ||
strikethrough: true, | ||
inlineCode: true, | ||
strike: true, | ||
code: true, | ||
}, | ||
@@ -414,3 +431,4 @@ }, | ||
editorAStrikethrough.click(); | ||
editorAStrike.click(); | ||
page.waitForTimeout(50); | ||
delta = await getDeltaFromVirgoRichText(page); | ||
@@ -424,3 +442,3 @@ expect(delta).toEqual([ | ||
attributes: { | ||
inlineCode: true, | ||
code: true, | ||
}, | ||
@@ -433,3 +451,4 @@ }, | ||
editorAInlineCode.click(); | ||
editorACode.click(); | ||
page.waitForTimeout(50); | ||
delta = await getDeltaFromVirgoRichText(page); | ||
@@ -635,8 +654,9 @@ expect(delta).toEqual([ | ||
await focusVirgoRichText(page); | ||
await page.keyboard.press('ArrowLeft'); | ||
await page.keyboard.press('ArrowLeft'); | ||
await page.keyboard.press('ArrowLeft'); | ||
await page.keyboard.press('ArrowLeft'); | ||
await page.waitForTimeout(50); | ||
await press(page, 'ArrowLeft'); | ||
await press(page, 'ArrowLeft'); | ||
await press(page, 'ArrowLeft'); | ||
await press(page, 'ArrowLeft'); | ||
await page.keyboard.press('Enter'); | ||
await press(page, 'Enter'); | ||
@@ -658,5 +678,5 @@ expect(await editorA.innerText()).toBe('abc \n' + ' def'); | ||
await type(page, 'abc'); | ||
await page.keyboard.press('Enter', { delay: 50 }); | ||
await press(page, 'Enter'); | ||
await type(page, 'def'); | ||
await page.keyboard.press('Enter', { delay: 50 }); | ||
await press(page, 'Enter'); | ||
await type(page, 'ghi'); | ||
@@ -672,5 +692,5 @@ | ||
*/ | ||
await page.keyboard.press('ArrowLeft'); | ||
await page.keyboard.press('ArrowLeft'); | ||
await page.keyboard.press('ArrowLeft'); | ||
await press(page, 'ArrowLeft'); | ||
await press(page, 'ArrowLeft'); | ||
await press(page, 'ArrowLeft'); | ||
@@ -683,4 +703,4 @@ /** | ||
await page.keyboard.down('Shift'); | ||
await page.keyboard.press('ArrowUp'); | ||
await page.keyboard.press('ArrowUp'); | ||
await press(page, 'ArrowUp'); | ||
await press(page, 'ArrowUp'); | ||
@@ -692,4 +712,4 @@ /** | ||
*/ | ||
await page.keyboard.press('ArrowRight'); | ||
await page.keyboard.press('Backspace'); | ||
await press(page, 'ArrowRight'); | ||
await press(page, 'Backspace'); | ||
@@ -699,1 +719,41 @@ expect(await editorA.innerText()).toBe('aghi'); | ||
}); | ||
test('getLine', async ({ page }) => { | ||
await enterVirgoPlayground(page); | ||
await focusVirgoRichText(page); | ||
const editorA = page.locator('[data-virgo-root="true"]').nth(0); | ||
const editorB = page.locator('[data-virgo-root="true"]').nth(1); | ||
expect(await editorA.innerText()).toBe(ZERO_WIDTH_SPACE); | ||
expect(await editorB.innerText()).toBe(ZERO_WIDTH_SPACE); | ||
await type(page, 'abc'); | ||
await press(page, 'Enter'); | ||
await type(page, 'def'); | ||
await press(page, 'Enter'); | ||
await type(page, 'ghi'); | ||
expect(await editorA.innerText()).toBe('abc\ndef\nghi'); | ||
expect(await editorB.innerText()).toBe('abc\ndef\nghi'); | ||
const [line1, offset1] = await getVirgoRichTextLine(page, 0); | ||
const [line2, offset2] = await getVirgoRichTextLine(page, 1); | ||
const [line3, offset3] = await getVirgoRichTextLine(page, 4); | ||
const [line4, offset4] = await getVirgoRichTextLine(page, 5); | ||
const [line5, offset5] = await getVirgoRichTextLine(page, 8); | ||
const [line6, offset6] = await getVirgoRichTextLine(page, 11); | ||
expect(line1).toEqual('abc'); | ||
expect(offset1).toEqual(0); | ||
expect(line2).toEqual('abc'); | ||
expect(offset2).toEqual(1); | ||
expect(line3).toEqual('def'); | ||
expect(offset3).toEqual(0); | ||
expect(line4).toEqual('def'); | ||
expect(offset4).toEqual(1); | ||
expect(line5).toEqual('ghi'); | ||
expect(offset5).toEqual(0); | ||
expect(line6).toEqual('ghi'); | ||
expect(offset6).toEqual(3); | ||
}); |
@@ -1,18 +0,15 @@ | ||
import type { BaseText, BaseTextAttributes } from './components/base-text.js'; | ||
import type { TemplateResult } from 'lit'; | ||
export interface CustomTypes { | ||
[key: string]: unknown; | ||
} | ||
import type { VText } from './components/index.js'; | ||
import type { BaseTextAttributes } from './utils/index.js'; | ||
type ExtendableKeys = 'Element' | 'Attributes'; | ||
type ExtendedType<K extends ExtendableKeys, B> = unknown extends CustomTypes[K] | ||
? B | ||
: CustomTypes[K]; | ||
export type TextAttributes = ExtendedType<'Attributes', BaseTextAttributes>; | ||
export type TextElement = ExtendedType<'Element', BaseText>; | ||
export type DeltaInsert<A extends TextAttributes = TextAttributes> = { | ||
export type DeltaInsert< | ||
TextAttributes extends BaseTextAttributes = BaseTextAttributes | ||
> = { | ||
insert: string; | ||
attributes?: A; | ||
attributes?: TextAttributes; | ||
}; | ||
export type AttributesRenderer< | ||
TextAttributes extends BaseTextAttributes = BaseTextAttributes | ||
> = (vText: VText, attributes?: TextAttributes) => TemplateResult<1>; |
import type { DeltaInsert } from '../types.js'; | ||
import type { BaseTextAttributes } from './base-attributes.js'; | ||
export function transformDelta(delta: DeltaInsert): (DeltaInsert | '\n')[] { | ||
const result: (DeltaInsert | '\n')[] = []; | ||
export function transformDelta<TextAttributes extends BaseTextAttributes>( | ||
delta: DeltaInsert<TextAttributes> | ||
): (DeltaInsert<TextAttributes> | '\n')[] { | ||
const result: (DeltaInsert<TextAttributes> | '\n')[] = []; | ||
@@ -34,3 +37,5 @@ let tmpString = delta.insert; | ||
*/ | ||
export function deltaInsertsToChunks(delta: DeltaInsert[]): DeltaInsert[][] { | ||
export function deltaInsertsToChunks<TextAttributes extends BaseTextAttributes>( | ||
delta: DeltaInsert<TextAttributes>[] | ||
): DeltaInsert<TextAttributes>[][] { | ||
if (delta.length === 0) { | ||
@@ -42,3 +47,3 @@ return [[]]; | ||
function* chunksGenerator(arr: (DeltaInsert | '\n')[]) { | ||
function* chunksGenerator(arr: (DeltaInsert<TextAttributes> | '\n')[]) { | ||
let start = 0; | ||
@@ -49,5 +54,5 @@ for (let i = 0; i < arr.length; i++) { | ||
start = i + 1; | ||
yield chunk as DeltaInsert[]; | ||
yield chunk as DeltaInsert<TextAttributes>[]; | ||
} else if (i === arr.length - 1) { | ||
yield arr.slice(start) as DeltaInsert[]; | ||
yield arr.slice(start) as DeltaInsert<TextAttributes>[]; | ||
} | ||
@@ -54,0 +59,0 @@ } |
@@ -0,1 +1,4 @@ | ||
export * from './attributes-renderer.js'; | ||
export * from './base-attributes.js'; | ||
export * from './convert.js'; | ||
export * from './renderer.js'; |
917
src/virgo.ts
@@ -1,10 +0,14 @@ | ||
import { assertExists, Signal } from '@blocksuite/global/utils'; | ||
import { assertExists, Slot } from '@blocksuite/global/utils'; | ||
import type * as Y from 'yjs'; | ||
import type { z } from 'zod'; | ||
import { BaseText } from './components/base-text.js'; | ||
import { VirgoElement } from './components/virgo-element.js'; | ||
import { VirgoLine } from './components/virgo-line.js'; | ||
import { ZERO_WIDTH_SPACE } from './constant.js'; | ||
import type { DeltaInsert, TextAttributes, TextElement } from './types.js'; | ||
import type { AttributesRenderer, DeltaInsert } from './types.js'; | ||
import { getDefaultAttributeRenderer } from './utils/attributes-renderer.js'; | ||
import { deltaInsertsToChunks } from './utils/convert.js'; | ||
import { baseRenderElement } from './utils/render.js'; | ||
import type { BaseTextAttributes } from './utils/index.js'; | ||
import { baseTextAttributes } from './utils/index.js'; | ||
import { renderElement } from './utils/renderer.js'; | ||
@@ -16,7 +20,14 @@ export interface VRange { | ||
export type UpdateVRangeProp = [VRange | null, 'native' | 'input' | 'other']; | ||
export type UpdateVRangeProp = [ | ||
range: VRange | null, | ||
type: 'native' | 'input' | 'other' | ||
]; | ||
export type DeltaEntry = [DeltaInsert, VRange]; | ||
export type DeltaEntry = [delta: DeltaInsert, range: VRange]; | ||
interface DomPoint { | ||
// corresponding to [anchorNode/focusNode, anchorOffset/focusOffset] | ||
export type NativePoint = readonly [node: Node, offset: number]; | ||
// the number here is relative to the text node | ||
export type TextPoint = readonly [text: Text, offset: number]; | ||
export interface DomPoint { | ||
// which text node this point is in | ||
@@ -28,45 +39,297 @@ text: Text; | ||
export class VEditor { | ||
export class VEditor< | ||
TextAttributes extends BaseTextAttributes = BaseTextAttributes | ||
> { | ||
static nativePointToTextPoint( | ||
node: unknown, | ||
offset: number | ||
): TextPoint | null { | ||
let text: Text | null = null; | ||
let textOffset = offset; | ||
if (isVText(node)) { | ||
text = node; | ||
textOffset = offset; | ||
} else if (isVElement(node)) { | ||
const texts = VEditor.getTextNodesFromElement(node); | ||
for (let i = 0; i < texts.length; i++) { | ||
if (offset <= texts[i].length) { | ||
text = texts[i]; | ||
textOffset = offset; | ||
break; | ||
} | ||
offset -= texts[i].length; | ||
} | ||
} else if (isVLine(node) || isVRoot(node)) { | ||
const texts = VEditor.getTextNodesFromElement(node); | ||
if (texts.length > 0) { | ||
text = texts[0]; | ||
textOffset = offset === 0 ? offset : text.length; | ||
} | ||
} else { | ||
if (node instanceof Node) { | ||
const vLine = node.parentElement?.closest('v-line'); | ||
if (vLine) { | ||
const vElements = Array.from(vLine.querySelectorAll('v-element')); | ||
for (let i = 0; i < vElements.length; i++) { | ||
if ( | ||
node.compareDocumentPosition(vElements[i]) === | ||
Node.DOCUMENT_POSITION_CONTAINED_BY || | ||
node.compareDocumentPosition(vElements[i]) === 20 | ||
) { | ||
const texts = VEditor.getTextNodesFromElement(vElements[0]); | ||
if (texts.length === 0) return null; | ||
for (let i = 0; i < texts.length; i++) { | ||
text = texts[i]; | ||
textOffset = offset === 0 ? offset : text.length; | ||
} | ||
break; | ||
} | ||
if ( | ||
i === 0 && | ||
node.compareDocumentPosition(vElements[i]) === | ||
Node.DOCUMENT_POSITION_FOLLOWING | ||
) { | ||
const texts = VEditor.getTextNodesFromElement(vElements[i]); | ||
if (texts.length === 0) return null; | ||
text = texts[0]; | ||
textOffset = offset === 0 ? offset : text.length; | ||
break; | ||
} else if ( | ||
i === vElements.length - 1 && | ||
node.compareDocumentPosition(vElements[i]) === | ||
Node.DOCUMENT_POSITION_PRECEDING | ||
) { | ||
const texts = VEditor.getTextNodesFromElement(vElements[i]); | ||
if (texts.length === 0) return null; | ||
text = texts[0]; | ||
textOffset = calculateTextLength(text); | ||
break; | ||
} | ||
if ( | ||
i < vElements.length - 1 && | ||
node.compareDocumentPosition(vElements[i]) === | ||
Node.DOCUMENT_POSITION_PRECEDING && | ||
node.compareDocumentPosition(vElements[i + 1]) === | ||
Node.DOCUMENT_POSITION_FOLLOWING | ||
) { | ||
const texts = VEditor.getTextNodesFromElement(vElements[i]); | ||
if (texts.length === 0) return null; | ||
text = texts[0]; | ||
textOffset = calculateTextLength(text); | ||
break; | ||
} | ||
} | ||
} | ||
} | ||
} | ||
if (!text) { | ||
return null; | ||
} | ||
return [text, textOffset] as const; | ||
} | ||
static textPointToDomPoint( | ||
text: Text, | ||
offset: number, | ||
rootElement: HTMLElement | ||
): DomPoint | null { | ||
if (rootElement.dataset.virgoRoot !== 'true') { | ||
throw new Error( | ||
'textRangeToDomPoint should be called with editor root element' | ||
); | ||
} | ||
if (!rootElement.contains(text)) { | ||
return null; | ||
} | ||
const texts = VEditor.getTextNodesFromElement(rootElement); | ||
const goalIndex = texts.indexOf(text); | ||
let index = 0; | ||
for (const text of texts.slice(0, goalIndex)) { | ||
index += calculateTextLength(text); | ||
} | ||
if (text.wholeText !== ZERO_WIDTH_SPACE) { | ||
index += offset; | ||
} | ||
const textParentElement = text.parentElement; | ||
if (!textParentElement) { | ||
throw new Error('text element parent not found'); | ||
} | ||
const lineElement = textParentElement.closest('v-line'); | ||
if (!lineElement) { | ||
throw new Error('line element not found'); | ||
} | ||
const lineIndex = Array.from( | ||
rootElement.querySelectorAll('v-line') | ||
).indexOf(lineElement); | ||
return { text, index: index + lineIndex }; | ||
} | ||
static getTextNodesFromElement(element: Element): Text[] { | ||
const textSpanElements = Array.from( | ||
element.querySelectorAll('[data-virgo-text="true"]') | ||
); | ||
const textNodes = textSpanElements.map(textSpanElement => { | ||
const textNode = Array.from(textSpanElement.childNodes).find( | ||
(node): node is Text => node instanceof Text | ||
); | ||
if (!textNode) { | ||
throw new Error('text node not found'); | ||
} | ||
return textNode; | ||
}); | ||
return textNodes; | ||
} | ||
private _rootElement: HTMLElement | null = null; | ||
private _rootElementAbort: AbortController | null = null; | ||
private _mountAbortController: AbortController | null = null; | ||
private _handlerAbortController: AbortController | null = null; | ||
private _vRange: VRange | null = null; | ||
private _isComposing = false; | ||
private _isReadOnly = false; | ||
private _renderElement: (delta: DeltaInsert) => TextElement = | ||
baseRenderElement; | ||
private _onKeyDown: (event: KeyboardEvent) => void = () => { | ||
return; | ||
private _isReadonly = false; | ||
private _yText: Y.Text; | ||
private _attributesRenderer: AttributesRenderer<TextAttributes> = | ||
getDefaultAttributeRenderer<TextAttributes>(); | ||
private _attributesSchema: z.ZodSchema<TextAttributes> = | ||
baseTextAttributes as z.ZodSchema<TextAttributes>; | ||
private _handlers: { | ||
keydown?: (event: KeyboardEvent) => void; | ||
paste?: (event: ClipboardEvent) => void; | ||
virgoInput?: (event: InputEvent) => boolean; | ||
} = {}; | ||
private _defaultHandlers: VEditor['_handlers'] = { | ||
paste: (event: ClipboardEvent) => { | ||
const data = event.clipboardData?.getData('text/plain'); | ||
if (data) { | ||
const vRange = this._vRange; | ||
if (vRange) { | ||
this.insertText(vRange, data); | ||
this.slots.updateVRange.emit([ | ||
{ | ||
index: vRange.index + data.length, | ||
length: 0, | ||
}, | ||
'input', | ||
]); | ||
} | ||
} | ||
}, | ||
}; | ||
signals: { | ||
updateVRange: Signal<UpdateVRangeProp>; | ||
private _parseSchema = (textAttributes?: TextAttributes) => { | ||
return this._attributesSchema.optional().parse(textAttributes); | ||
}; | ||
yText: Y.Text; | ||
constructor( | ||
yText: VEditor['yText'], | ||
opts: { | ||
renderElement?: (delta: DeltaInsert) => TextElement; | ||
onKeyDown?: (event: KeyboardEvent) => void; | ||
} = {} | ||
) { | ||
this.yText = yText; | ||
const { renderElement, onKeyDown } = opts; | ||
private _renderDeltas = () => { | ||
assertExists(this._rootElement); | ||
if (renderElement) { | ||
this._renderElement = renderElement; | ||
} | ||
const deltas = this.yText.toDelta() as DeltaInsert<TextAttributes>[]; | ||
const chunks = deltaInsertsToChunks(deltas); | ||
if (onKeyDown) { | ||
this._onKeyDown = onKeyDown; | ||
// every chunk is a line | ||
const lines = chunks.map(chunk => { | ||
const virgoLine = new VirgoLine<TextAttributes>(); | ||
if (chunk.length === 0) { | ||
virgoLine.elements.push(new VirgoElement()); | ||
} else { | ||
chunk.forEach(delta => { | ||
const element = renderElement( | ||
delta, | ||
this._parseSchema, | ||
this._attributesRenderer | ||
); | ||
virgoLine.elements.push(element); | ||
}); | ||
} | ||
return virgoLine; | ||
}); | ||
this._rootElement.replaceChildren(...lines); | ||
}; | ||
slots: { | ||
mounted: Slot; | ||
unmounted: Slot; | ||
updateVRange: Slot<UpdateVRangeProp>; | ||
}; | ||
get yText() { | ||
return this._yText; | ||
} | ||
get rootElement() { | ||
assertExists(this._rootElement); | ||
return this._rootElement; | ||
} | ||
constructor(yText: VEditor['yText']) { | ||
if (!yText.doc) { | ||
throw new Error('yText must be attached to a Y.Doc'); | ||
} | ||
this.signals = { | ||
updateVRange: new Signal<UpdateVRangeProp>(), | ||
this._yText = yText; | ||
this.slots = { | ||
mounted: new Slot(), | ||
unmounted: new Slot(), | ||
updateVRange: new Slot<UpdateVRangeProp>(), | ||
}; | ||
this.signals.updateVRange.on(this._onUpdateVRange); | ||
this.slots.updateVRange.on(this._onUpdateVRange); | ||
} | ||
mount(rootElement: HTMLElement): void { | ||
setAttributesSchema = (schema: z.ZodSchema<TextAttributes>) => { | ||
this._attributesSchema = schema; | ||
}; | ||
setAttributesRenderer = (renderer: AttributesRenderer<TextAttributes>) => { | ||
this._attributesRenderer = renderer; | ||
}; | ||
bindHandlers(handlers: VEditor['_handlers'] = this._defaultHandlers) { | ||
this._handlers = handlers; | ||
if (this._handlerAbortController) { | ||
this._handlerAbortController.abort(); | ||
} | ||
this._handlerAbortController = new AbortController(); | ||
assertExists(this._rootElement, 'you need to mount the editor first'); | ||
if (this._handlers.paste) { | ||
this._rootElement.addEventListener('paste', this._handlers.paste, { | ||
signal: this._handlerAbortController.signal, | ||
}); | ||
} | ||
if (this._handlers.keydown) { | ||
this._rootElement.addEventListener('keydown', this._handlers.keydown, { | ||
signal: this._handlerAbortController.signal, | ||
}); | ||
} | ||
} | ||
mount(rootElement: HTMLElement) { | ||
this._rootElement = rootElement; | ||
@@ -79,15 +342,12 @@ this._rootElement.replaceChildren(); | ||
this._rootElementAbort = new AbortController(); | ||
this._mountAbortController = new AbortController(); | ||
const deltas = this.yText.toDelta() as DeltaInsert[]; | ||
renderDeltas(deltas, this._rootElement, this._renderElement); | ||
this._renderDeltas(); | ||
this._rootElement.addEventListener( | ||
'beforeinput', | ||
this._onBeforeInput.bind(this), | ||
{ | ||
signal: this._rootElementAbort.signal, | ||
} | ||
); | ||
this._rootElement | ||
const signal = this._mountAbortController.signal; | ||
rootElement.addEventListener('beforeinput', this._onBeforeInput, { | ||
signal, | ||
}); | ||
rootElement | ||
.querySelectorAll('[data-virgo-text="true"]') | ||
@@ -100,37 +360,41 @@ .forEach(textNode => { | ||
this._rootElement.addEventListener( | ||
'compositionstart', | ||
this._onCompositionStart.bind(this), | ||
{ | ||
signal: this._rootElementAbort.signal, | ||
} | ||
); | ||
this._rootElement.addEventListener( | ||
'compositionend', | ||
this._onCompositionEnd.bind(this), | ||
{ | ||
signal: this._rootElementAbort.signal, | ||
} | ||
); | ||
this._rootElement.addEventListener('keydown', this._onKeyDown, { | ||
signal: this._rootElementAbort.signal, | ||
rootElement.addEventListener('compositionstart', this._onCompositionStart, { | ||
signal, | ||
}); | ||
this._rootElement.addEventListener('paste', this._onPaste, { | ||
signal: this._rootElementAbort.signal, | ||
rootElement.addEventListener('compositionend', this._onCompositionEnd, { | ||
signal, | ||
}); | ||
this.slots.mounted.emit(); | ||
} | ||
unmount(): void { | ||
unmount() { | ||
document.removeEventListener('selectionchange', this._onSelectionChange); | ||
if (this._rootElementAbort) { | ||
this._rootElementAbort.abort(); | ||
this._rootElementAbort = null; | ||
if (this._mountAbortController) { | ||
this._mountAbortController.abort(); | ||
this._mountAbortController = null; | ||
} | ||
if (this._handlerAbortController) { | ||
this._handlerAbortController.abort(); | ||
this._handlerAbortController = null; | ||
} | ||
this._handlers = this._defaultHandlers; | ||
this._rootElement?.replaceChildren(); | ||
this._rootElement = null; | ||
this.slots.unmounted.emit(); | ||
} | ||
requestUpdate(): void { | ||
Promise.resolve().then(() => { | ||
assertExists(this._rootElement); | ||
this._renderDeltas(); | ||
}); | ||
} | ||
getNativeSelection(): Selection | null { | ||
@@ -160,2 +424,51 @@ const selectionRoot = findDocumentOrShadowRoot(this); | ||
getTextPoint(rangeIndex: VRange['index']): TextPoint { | ||
assertExists(this._rootElement); | ||
const vLines = Array.from(this._rootElement.querySelectorAll('v-line')); | ||
let index = 0; | ||
for (const vLine of vLines) { | ||
const texts = VEditor.getTextNodesFromElement(vLine); | ||
for (const text of texts) { | ||
if (!text.textContent) { | ||
throw new Error('text element should have textContent'); | ||
} | ||
if (index + text.textContent.length >= rangeIndex) { | ||
return [text, rangeIndex - index]; | ||
} | ||
index += text.textContent.length; | ||
} | ||
index += 1; | ||
} | ||
throw new Error('failed to find leaf'); | ||
} | ||
// the number is releated to the VirgoLine's textLength | ||
getLine(rangeIndex: VRange['index']): readonly [VirgoLine, number] { | ||
assertExists(this._rootElement); | ||
const lineElements = Array.from( | ||
this._rootElement.querySelectorAll('v-line') | ||
); | ||
let index = 0; | ||
for (const lineElement of lineElements) { | ||
if (rangeIndex >= index && rangeIndex <= index + lineElement.textLength) { | ||
return [lineElement, rangeIndex - index] as const; | ||
} | ||
if ( | ||
rangeIndex === index + lineElement.textLength && | ||
rangeIndex === this.yText.length | ||
) { | ||
return [lineElement, rangeIndex - index] as const; | ||
} | ||
index += lineElement.textLength + 1; | ||
} | ||
throw new Error('failed to find line'); | ||
} | ||
getDeltasByVRange(vRange: VRange): DeltaEntry[] { | ||
@@ -170,3 +483,4 @@ const deltas = this.yText.toDelta() as DeltaInsert[]; | ||
index + delta.insert.length >= vRange.index && | ||
index < vRange.index + vRange.length | ||
(index < vRange.index + vRange.length || | ||
(vRange.length === 0 && index === vRange.index)) | ||
) { | ||
@@ -181,6 +495,2 @@ result.push([delta, { index, length: delta.insert.length }]); | ||
getRootElement(): HTMLElement | null { | ||
return this._rootElement; | ||
} | ||
getVRange(): VRange | null { | ||
@@ -190,12 +500,41 @@ return this._vRange; | ||
getReadOnly(): boolean { | ||
return this._isReadOnly; | ||
getFormat(vRange: VRange): TextAttributes { | ||
const deltas = this.getDeltasByVRange(vRange).filter( | ||
([delta, position]) => | ||
position.index + position.length > vRange.index && | ||
position.index <= vRange.index + vRange.length | ||
); | ||
const maybeAttributesArray = deltas.map(([delta]) => delta.attributes); | ||
if ( | ||
!maybeAttributesArray.length || | ||
// some text does not have any attributes | ||
maybeAttributesArray.some(attributes => !attributes) | ||
) { | ||
return {} as TextAttributes; | ||
} | ||
const attributesArray = maybeAttributesArray as TextAttributes[]; | ||
return attributesArray.reduce((acc, cur) => { | ||
const newFormat = {} as TextAttributes; | ||
for (const key in acc) { | ||
const typedKey = key as keyof TextAttributes; | ||
// If the given range contains multiple different formats | ||
// such as links with different values, | ||
// we will treat it as having no format | ||
if (acc[typedKey] === cur[typedKey]) { | ||
// This cast is secure because we have checked that the value of the key is the same. | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
newFormat[typedKey] = acc[typedKey] as any; | ||
} | ||
} | ||
return newFormat; | ||
}); | ||
} | ||
setReadOnly(isReadOnly: boolean): void { | ||
this._isReadOnly = isReadOnly; | ||
setReadonly(isReadonly: boolean): void { | ||
this.rootElement.contentEditable = isReadonly ? 'false' : 'true'; | ||
this._isReadonly = isReadonly; | ||
} | ||
setVRange(vRange: VRange): void { | ||
this.signals.updateVRange.emit([vRange, 'other']); | ||
this.slots.updateVRange.emit([vRange, 'other']); | ||
} | ||
@@ -218,6 +557,10 @@ | ||
insertText(vRange: VRange, text: string): void { | ||
insertText( | ||
vRange: VRange, | ||
text: string, | ||
attributes: TextAttributes = {} as TextAttributes | ||
): void { | ||
this._transact(() => { | ||
this.yText.delete(vRange.index, vRange.length); | ||
this.yText.insert(vRange.index, text); | ||
this.yText.insert(vRange.index, text, attributes); | ||
}); | ||
@@ -235,3 +578,5 @@ } | ||
vRange: VRange, | ||
attributes: TextAttributes, | ||
attributes: Partial< | ||
Record<keyof TextAttributes, TextAttributes[keyof TextAttributes] | null> | ||
>, | ||
options: { | ||
@@ -242,3 +587,3 @@ match?: (delta: DeltaInsert, deltaVRange: VRange) => boolean; | ||
): void { | ||
const { match = () => true, mode = 'replace' } = options; | ||
const { match = () => true, mode = 'merge' } = options; | ||
const deltas = this.getDeltasByVRange(vRange); | ||
@@ -321,5 +666,4 @@ | ||
assertExists(this._rootElement); | ||
const lineElements = Array.from( | ||
this._rootElement.querySelectorAll('virgo-line') | ||
this._rootElement.querySelectorAll('v-line') | ||
); | ||
@@ -339,27 +683,19 @@ | ||
const textElements = Array.from( | ||
lineElements[i].querySelectorAll('[data-virgo-text="true"]') | ||
); | ||
const texts = VEditor.getTextNodesFromElement(lineElements[i]); | ||
for (const text of texts) { | ||
const textLength = calculateTextLength(text); | ||
for (let j = 0; j < textElements.length; j++) { | ||
if (anchorText && focusText) { | ||
break; | ||
} | ||
const textNode = getTextNodeFromElement(textElements[j]); | ||
if (!textNode) { | ||
return null; | ||
} | ||
const textLength = calculateTextLength(textNode); | ||
if (!anchorText && index + textLength >= vRange.index) { | ||
anchorText = textNode; | ||
anchorText = text; | ||
anchorOffset = vRange.index - index; | ||
} | ||
if (!focusText && index + textLength >= vRange.index + vRange.length) { | ||
focusText = textNode; | ||
focusText = text; | ||
focusOffset = vRange.index + vRange.length - index; | ||
} | ||
if (anchorText && focusText) { | ||
break; | ||
} | ||
index += textLength; | ||
@@ -410,2 +746,3 @@ } | ||
assertExists(this._rootElement); | ||
const root = this._rootElement; | ||
@@ -417,7 +754,7 @@ const { anchorNode, anchorOffset, focusNode, focusOffset } = selection; | ||
const [anchorText, anchorTextOffset] = getTextAndOffset( | ||
const anchorTextPoint = VEditor.nativePointToTextPoint( | ||
anchorNode, | ||
anchorOffset | ||
); | ||
const [focusText, focusTextOffset] = getTextAndOffset( | ||
const focusTextPoint = VEditor.nativePointToTextPoint( | ||
focusNode, | ||
@@ -427,5 +764,12 @@ focusOffset | ||
if (!anchorTextPoint || !focusTextPoint) { | ||
return null; | ||
} | ||
const [anchorText, anchorTextOffset] = anchorTextPoint; | ||
const [focusText, focusTextOffset] = focusTextPoint; | ||
// case 1 | ||
if (anchorText && focusText) { | ||
const anchorDomPoint = textPointToDomPoint( | ||
if (root.contains(anchorText) && root.contains(focusText)) { | ||
const anchorDomPoint = VEditor.textPointToDomPoint( | ||
anchorText, | ||
@@ -435,3 +779,3 @@ anchorTextOffset, | ||
); | ||
const focusDomPoint = textPointToDomPoint( | ||
const focusDomPoint = VEditor.textPointToDomPoint( | ||
focusText, | ||
@@ -452,23 +796,33 @@ focusTextOffset, | ||
// case 2 | ||
if (anchorText && !focusText) { | ||
const anchorDomPoint = textPointToDomPoint( | ||
anchorText, | ||
anchorTextOffset, | ||
this._rootElement | ||
); | ||
// case 2.1 | ||
if (!root.contains(anchorText) && root.contains(focusText)) { | ||
if (isSelectionBackwards(selection)) { | ||
const anchorDomPoint = VEditor.textPointToDomPoint( | ||
anchorText, | ||
anchorTextOffset, | ||
this._rootElement | ||
); | ||
if (!anchorDomPoint) { | ||
return null; | ||
} | ||
if (!anchorDomPoint) { | ||
return null; | ||
} | ||
if (isSelectionBackwards(selection)) { | ||
return { | ||
index: 0, | ||
length: anchorDomPoint.index, | ||
index: anchorDomPoint.index, | ||
length: this.yText.length - anchorDomPoint.index, | ||
}; | ||
} else { | ||
const focusDomPoint = VEditor.textPointToDomPoint( | ||
focusText, | ||
focusTextOffset, | ||
this._rootElement | ||
); | ||
if (!focusDomPoint) { | ||
return null; | ||
} | ||
return { | ||
index: anchorDomPoint.index, | ||
length: anchorDomPoint.text.wholeText.length - anchorDomPoint.index, | ||
index: 0, | ||
length: focusDomPoint.index, | ||
}; | ||
@@ -478,23 +832,33 @@ } | ||
// case 2 | ||
if (!anchorText && focusText) { | ||
const focusDomPoint = textPointToDomPoint( | ||
focusText, | ||
focusTextOffset, | ||
this._rootElement | ||
); | ||
// case 2.2 | ||
if (root.contains(anchorText) && !root.contains(focusText)) { | ||
if (isSelectionBackwards(selection)) { | ||
const focusDomPoint = VEditor.textPointToDomPoint( | ||
focusText, | ||
focusTextOffset, | ||
this._rootElement | ||
); | ||
if (!focusDomPoint) { | ||
return null; | ||
} | ||
if (!focusDomPoint) { | ||
return null; | ||
} | ||
if (isSelectionBackwards(selection)) { | ||
return { | ||
index: focusDomPoint.index, | ||
length: focusDomPoint.text.wholeText.length - focusDomPoint.index, | ||
index: 0, | ||
length: focusDomPoint.index, | ||
}; | ||
} else { | ||
const anchorDomPoint = VEditor.textPointToDomPoint( | ||
anchorText, | ||
anchorTextOffset, | ||
this._rootElement | ||
); | ||
if (!anchorDomPoint) { | ||
return null; | ||
} | ||
return { | ||
index: 0, | ||
length: focusDomPoint.index, | ||
index: anchorDomPoint.index, | ||
length: this.yText.length - anchorDomPoint.index, | ||
}; | ||
@@ -505,7 +869,3 @@ } | ||
// case 3 | ||
if ( | ||
!anchorText && | ||
!focusText && | ||
selection.containsNode(this._rootElement) | ||
) { | ||
if (!root.contains(anchorText) && !root.contains(focusText)) { | ||
return { | ||
@@ -520,21 +880,23 @@ index: 0, | ||
private _onBeforeInput(event: InputEvent): void { | ||
private _onBeforeInput = (event: InputEvent) => { | ||
event.preventDefault(); | ||
if (this._isReadOnly) { | ||
return; | ||
} | ||
if (this._isComposing) return; | ||
if (!this._vRange) { | ||
return; | ||
let ifSkip = false; | ||
if (this._handlers.virgoInput) { | ||
ifSkip = this._handlers.virgoInput(event); | ||
} | ||
if (this._isReadonly) return; | ||
if (ifSkip) return; | ||
if (!this._vRange) return; | ||
const { inputType, data } = event; | ||
const currentVRange = this._vRange; | ||
if (inputType === 'insertText' && this._vRange.index >= 0 && data) { | ||
this.insertText(this._vRange, data); | ||
this.signals.updateVRange.emit([ | ||
if (inputType === 'insertText' && currentVRange.index >= 0 && data) { | ||
this.slots.updateVRange.emit([ | ||
{ | ||
index: this._vRange.index + data.length, | ||
index: currentVRange.index + data.length, | ||
length: 0, | ||
@@ -544,8 +906,8 @@ }, | ||
]); | ||
} else if (inputType === 'insertParagraph' && this._vRange.index >= 0) { | ||
this.insertLineBreak(this._vRange); | ||
this.signals.updateVRange.emit([ | ||
this.insertText(currentVRange, data); | ||
} else if (inputType === 'insertParagraph' && currentVRange.index >= 0) { | ||
this.slots.updateVRange.emit([ | ||
{ | ||
index: this._vRange.index + 1, | ||
index: currentVRange.index + 1, | ||
length: 0, | ||
@@ -555,12 +917,12 @@ }, | ||
]); | ||
this.insertLineBreak(currentVRange); | ||
} else if ( | ||
inputType === 'deleteContentBackward' && | ||
this._vRange.index >= 0 | ||
currentVRange.index >= 0 | ||
) { | ||
if (this._vRange.length > 0) { | ||
this.deleteText(this._vRange); | ||
this.signals.updateVRange.emit([ | ||
if (currentVRange.length > 0) { | ||
this.slots.updateVRange.emit([ | ||
{ | ||
index: this._vRange.index, | ||
index: currentVRange.index, | ||
length: 0, | ||
@@ -570,14 +932,11 @@ }, | ||
]); | ||
} else if (this._vRange.index > 0) { | ||
this.deleteText(currentVRange); | ||
} else if (currentVRange.index > 0) { | ||
// https://dev.to/acanimal/how-to-slice-or-get-symbols-from-a-unicode-string-with-emojis-in-javascript-lets-learn-how-javascript-represent-strings-h3a | ||
const tmpString = this.yText.toString().slice(0, this._vRange.index); | ||
const tmpString = this.yText.toString().slice(0, currentVRange.index); | ||
const deletedCharacter = [...tmpString].slice(-1).join(''); | ||
this.deleteText({ | ||
index: this._vRange.index - deletedCharacter.length, | ||
length: deletedCharacter.length, | ||
}); | ||
this.signals.updateVRange.emit([ | ||
this.slots.updateVRange.emit([ | ||
{ | ||
index: this._vRange.index - deletedCharacter.length, | ||
index: currentVRange.index - deletedCharacter.length, | ||
length: 0, | ||
@@ -587,11 +946,35 @@ }, | ||
]); | ||
this.deleteText({ | ||
index: currentVRange.index - deletedCharacter.length, | ||
length: deletedCharacter.length, | ||
}); | ||
} | ||
} else if (inputType === 'deleteWordBackward') { | ||
const matchs = /\S+\s*$/.exec( | ||
this.yText.toString().substring(0, currentVRange.index) | ||
); | ||
if (!matchs) return; | ||
const deleteLength = matchs[0].length; | ||
this.slots.updateVRange.emit([ | ||
{ | ||
index: currentVRange.index - deleteLength, | ||
length: 0, | ||
}, | ||
'input', | ||
]); | ||
this.deleteText({ | ||
index: currentVRange.index - deleteLength, | ||
length: deleteLength, | ||
}); | ||
} | ||
} | ||
}; | ||
private _onCompositionStart(): void { | ||
private _onCompositionStart = () => { | ||
this._isComposing = true; | ||
} | ||
}; | ||
private _onCompositionEnd(event: CompositionEvent): void { | ||
private _onCompositionEnd = (event: CompositionEvent) => { | ||
this._isComposing = false; | ||
@@ -608,3 +991,3 @@ | ||
this.signals.updateVRange.emit([ | ||
this.slots.updateVRange.emit([ | ||
{ | ||
@@ -617,12 +1000,10 @@ index: this._vRange.index + data.length, | ||
} | ||
} | ||
}; | ||
private _onYTextChange = () => { | ||
assertExists(this._rootElement); | ||
Promise.resolve().then(() => { | ||
assertExists(this._rootElement); | ||
renderDeltas( | ||
this.yText.toDelta() as DeltaInsert[], | ||
this._rootElement, | ||
this._renderElement | ||
); | ||
this._renderDeltas(); | ||
}); | ||
}; | ||
@@ -641,30 +1022,15 @@ | ||
const { anchorNode, focusNode } = selection; | ||
if ( | ||
!this._rootElement.contains(anchorNode) || | ||
!this._rootElement.contains(focusNode) | ||
) { | ||
return; | ||
} | ||
const range = selection.getRangeAt(0); | ||
if (!range || !range.intersectsNode(this._rootElement)) return; | ||
const vRange = this.toVRange(selection); | ||
if (vRange) { | ||
this.signals.updateVRange.emit([vRange, 'native']); | ||
this.slots.updateVRange.emit([vRange, 'native']); | ||
} | ||
}; | ||
private _onPaste = (event: ClipboardEvent) => { | ||
const data = event.clipboardData?.getData('text/plain'); | ||
if (data) { | ||
const vRange = this._vRange; | ||
if (vRange) { | ||
this.insertText(vRange, data); | ||
this.signals.updateVRange.emit([ | ||
{ | ||
index: vRange.index + data.length, | ||
length: 0, | ||
}, | ||
'input', | ||
]); | ||
} | ||
if ( | ||
range.startContainer.nodeType !== Node.TEXT_NODE || | ||
range.endContainer.nodeType !== Node.TEXT_NODE | ||
) { | ||
this.syncVRange(); | ||
} | ||
@@ -702,3 +1068,3 @@ }; | ||
// updates in lit are performed asynchronously | ||
setTimeout(fn, 0); | ||
requestAnimationFrame(fn); | ||
}; | ||
@@ -716,52 +1082,2 @@ | ||
function textPointToDomPoint( | ||
text: Text, | ||
offset: number, | ||
rootElement: HTMLElement | ||
): DomPoint | null { | ||
if (rootElement.dataset.virgoRoot !== 'true') { | ||
throw new Error( | ||
'textRangeToDomPoint should be called with editor root element' | ||
); | ||
} | ||
if (!rootElement.contains(text)) { | ||
throw new Error('text is not in root element'); | ||
} | ||
const textNodes = Array.from( | ||
rootElement.querySelectorAll('[data-virgo-text="true"]') | ||
).map(textElement => getTextNodeFromElement(textElement)); | ||
const goalIndex = textNodes.indexOf(text); | ||
let index = 0; | ||
for (const textNode of textNodes.slice(0, goalIndex)) { | ||
if (!textNode) { | ||
return null; | ||
} | ||
index += calculateTextLength(textNode); | ||
} | ||
if (text.wholeText !== ZERO_WIDTH_SPACE) { | ||
index += offset; | ||
} | ||
const textElement = text.parentElement; | ||
if (!textElement) { | ||
throw new Error('text element not found'); | ||
} | ||
const lineElement = textElement.closest('virgo-line'); | ||
if (!lineElement) { | ||
throw new Error('line element not found'); | ||
} | ||
const lineIndex = Array.from( | ||
rootElement.querySelectorAll('virgo-line') | ||
).indexOf(lineElement); | ||
return { text, index: index + lineIndex }; | ||
} | ||
function isSelectionBackwards(selection: Selection): boolean { | ||
@@ -787,24 +1103,2 @@ let backwards = false; | ||
function getTextNodeFromElement(element: Element): Text | null { | ||
let spanElement: Element | null = element; | ||
if (element instanceof HTMLElement && element.dataset.virgoText === 'true') { | ||
spanElement = element; | ||
} else { | ||
spanElement = element.querySelector('[data-virgo-text="true"]'); | ||
} | ||
if (!spanElement) { | ||
return null; | ||
} | ||
const textNode = Array.from(spanElement.childNodes).find( | ||
(node): node is Text => node instanceof Text | ||
); | ||
if (textNode) { | ||
return textNode; | ||
} | ||
return null; | ||
} | ||
function isVText(text: unknown): text is Text { | ||
@@ -824,35 +1118,13 @@ return ( | ||
function isVLine(element: unknown): element is HTMLElement { | ||
return ( | ||
element instanceof HTMLElement && element.parentElement instanceof VirgoLine | ||
); | ||
return element instanceof HTMLElement && element instanceof VirgoLine; | ||
} | ||
function getTextAndOffset(node: unknown, offset: number) { | ||
let text: Text | null = null; | ||
let textOffset = offset; | ||
if (isVText(node)) { | ||
text = node; | ||
textOffset = offset; | ||
} else if (isVElement(node)) { | ||
const textNode = getTextNodeFromElement(node); | ||
if (textNode) { | ||
text = textNode; | ||
textOffset = offset; | ||
} | ||
} else if (isVLine(node)) { | ||
const firstTextElement = node.querySelector('v-text'); | ||
if (firstTextElement) { | ||
const textNode = getTextNodeFromElement(firstTextElement); | ||
if (textNode) { | ||
text = textNode; | ||
textOffset = 0; | ||
} | ||
} | ||
} | ||
return [text, textOffset] as const; | ||
function isVRoot(element: unknown): element is HTMLElement { | ||
return element instanceof HTMLElement && element.dataset.virgoRoot === 'true'; | ||
} | ||
function findDocumentOrShadowRoot(editor: VEditor): Document { | ||
const el = editor.getRootElement(); | ||
function findDocumentOrShadowRoot<TextAttributes extends BaseTextAttributes>( | ||
editor: VEditor<TextAttributes> | ||
): Document { | ||
const el = editor.rootElement; | ||
@@ -874,28 +1146,1 @@ if (!el) { | ||
} | ||
function renderDeltas( | ||
deltas: DeltaInsert[], | ||
rootElement: HTMLElement, | ||
render: (delta: DeltaInsert) => TextElement | ||
) { | ||
const chunks = deltaInsertsToChunks(deltas); | ||
// every chunk is a line | ||
const lines = chunks.map(chunk => { | ||
const virgoLine = new VirgoLine(); | ||
if (chunk.length === 0) { | ||
virgoLine.elements.push(new BaseText()); | ||
} else { | ||
chunk.forEach(delta => { | ||
const element = render(delta); | ||
virgoLine.elements.push(element); | ||
}); | ||
} | ||
return virgoLine; | ||
}); | ||
rootElement.replaceChildren(...lines); | ||
} |
@@ -10,5 +10,5 @@ import { defineConfig } from 'vitest/config'; | ||
reporter: ['lcov'], | ||
reportsDirectory: '../../.coverage/store', | ||
reportsDirectory: '../../.coverage/virgo', | ||
}, | ||
}, | ||
}); |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
358990
92
4827
2
1
+ Added@blocksuite/global@0.5.0-alpha.1(transitive)
+ Addedansi-colors@4.1.3(transitive)