From bfd11a45dfe7dd4f4647e17e7fbe9989ec1634c0 Mon Sep 17 00:00:00 2001 From: Damien Daspit Date: Fri, 6 Dec 2024 18:03:36 -0500 Subject: [PATCH] Add support for Delta documents --- package-lock.json | 100 ++- .../core/src/diagnostic/diagnostic-fix.ts | 4 +- .../src/diagnostic/diagnostic-provider.ts | 5 +- .../core/src/document/document-accessor.ts | 35 ++ .../core/src/document/document-factory.ts | 13 +- .../src/document/document-manager.test.ts | 87 ++- .../core/src/document/document-manager.ts | 67 +- packages/core/src/document/document.ts | 7 + packages/core/src/document/edit-factory.ts | 7 + packages/core/src/document/index.ts | 21 +- packages/core/src/document/scripture-cell.ts | 8 +- .../src/document/scripture-character-style.ts | 4 +- .../core/src/document/scripture-container.ts | 10 +- .../core/src/document/scripture-document.ts | 83 +-- .../src/document/scripture-edit-factory.ts | 9 + packages/core/src/document/scripture-note.ts | 7 +- .../core/src/document/scripture-paragraph.ts | 14 +- .../core/src/document/scripture-serializer.ts | 5 - .../src/document/scripture-text-document.ts | 75 +++ .../document/scripture-text-edit-factory.ts | 17 + .../core/src/document/text-document-change.ts | 6 + .../src/document/text-document-factory.ts | 9 +- packages/core/src/document/text-document.ts | 28 +- .../core/src/document/text-edit-factory.ts | 10 + .../formatting/on-type-formatting-provider.ts | 4 +- packages/core/src/workspace/workspace.ts | 18 +- packages/delta/eslint.config.js | 17 + packages/delta/package.json | 50 ++ packages/delta/src/delta-document-factory.ts | 15 + packages/delta/src/delta-document.test.ts | 135 +++++ packages/delta/src/delta-document.ts | 212 +++++++ packages/delta/src/delta-edit-factory.ts | 22 + packages/delta/src/index.ts | 3 + .../src/scripture-delta-document.test.ts | 285 +++++++++ .../delta/src/scripture-delta-document.ts | 570 ++++++++++++++++++ .../src/scripture-delta-edit-factory.test.ts | 88 +++ .../delta/src/scripture-delta-edit-factory.ts | 135 +++++ packages/delta/tsconfig.json | 8 + packages/delta/tsup.config.js | 4 + .../src/simple-quote-formatting-provider.ts | 33 +- .../verse-order-diagnostic-provider.test.ts | 82 +-- .../src/verse-order-diagnostic-provider.ts | 42 +- packages/usfm/src/index.ts | 1 + packages/usfm/src/usfm-document-factory.ts | 15 +- packages/usfm/src/usfm-document.ts | 10 +- packages/usfm/src/usfm-edit-factory.ts | 18 + .../src/usfm-scripture-serializer.test.ts | 2 +- .../usfm/src/usfm-scripture-serializer.ts | 278 +++++---- packages/vscode/src/server.ts | 8 +- 49 files changed, 2247 insertions(+), 439 deletions(-) create mode 100644 packages/core/src/document/document-accessor.ts create mode 100644 packages/core/src/document/edit-factory.ts create mode 100644 packages/core/src/document/scripture-edit-factory.ts delete mode 100644 packages/core/src/document/scripture-serializer.ts create mode 100644 packages/core/src/document/scripture-text-document.ts create mode 100644 packages/core/src/document/scripture-text-edit-factory.ts create mode 100644 packages/core/src/document/text-document-change.ts create mode 100644 packages/core/src/document/text-edit-factory.ts create mode 100644 packages/delta/eslint.config.js create mode 100644 packages/delta/package.json create mode 100644 packages/delta/src/delta-document-factory.ts create mode 100644 packages/delta/src/delta-document.test.ts create mode 100644 packages/delta/src/delta-document.ts create mode 100644 packages/delta/src/delta-edit-factory.ts create mode 100644 packages/delta/src/index.ts create mode 100644 packages/delta/src/scripture-delta-document.test.ts create mode 100644 packages/delta/src/scripture-delta-document.ts create mode 100644 packages/delta/src/scripture-delta-edit-factory.test.ts create mode 100644 packages/delta/src/scripture-delta-edit-factory.ts create mode 100644 packages/delta/tsconfig.json create mode 100644 packages/delta/tsup.config.js create mode 100644 packages/usfm/src/usfm-edit-factory.ts diff --git a/package-lock.json b/package-lock.json index 4e93bf3..a5a0416 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1535,6 +1535,10 @@ "resolved": "packages/core", "link": true }, + "node_modules/@sillsdev/lynx-delta": { + "resolved": "packages/delta", + "link": true + }, "node_modules/@sillsdev/lynx-examples": { "resolved": "packages/examples", "link": true @@ -1608,6 +1612,33 @@ "optional": true, "peer": true }, + "node_modules/@types/lodash": { + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", + "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash.clonedeep": { + "version": "4.5.9", + "resolved": "https://registry.npmjs.org/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.9.tgz", + "integrity": "sha512-19429mWC+FyaAhOLzsS8kZUsI+/GmBAQ0HFiCPsKGU+7pBXOQWhyrY6xNNDwUSX8SMZMJvuFVMF9O5dQOlQK9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/lodash.isequal": { + "version": "4.5.8", + "resolved": "https://registry.npmjs.org/@types/lodash.isequal/-/lodash.isequal-4.5.8.tgz", + "integrity": "sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/node": { "version": "20.16.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.11.tgz", @@ -3415,6 +3446,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "license": "Apache-2.0" + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -4617,6 +4654,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5316,6 +5365,20 @@ ], "license": "MIT" }, + "node_modules/quill-delta": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", + "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", + "license": "MIT", + "dependencies": { + "fast-diff": "^1.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.isequal": "^4.5.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/read-yaml-file": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz", @@ -6631,6 +6694,19 @@ "punycode": "^2.1.0" } }, + "node_modules/uuid": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz", + "integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vite": { "version": "5.4.11", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", @@ -7509,7 +7585,7 @@ }, "packages/core": { "name": "@sillsdev/lynx", - "version": "0.0.0", + "version": "0.1.0", "license": "MIT", "dependencies": { "i18next": "^23.16.5", @@ -7525,6 +7601,26 @@ "vitest-mock-extended": "^2.0.2" } }, + "packages/delta": { + "name": "@sillsdev/lynx-delta", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@sillsdev/lynx": "*", + "quill-delta": "^5.1.0", + "uuid": "^11.0.3" + }, + "devDependencies": { + "@repo/eslint-config": "*", + "@repo/tsup-config": "*", + "@repo/typescript-config": "*", + "@types/lodash.clonedeep": "^4.5.9", + "@types/lodash.isequal": "^4.5.8", + "eslint": "^9.9.1", + "tsup": "^8.3.0", + "typescript": "^5.5.4" + } + }, "packages/eslint-config": { "name": "@repo/eslint-config", "version": "0.0.0", @@ -7569,7 +7665,7 @@ }, "packages/usfm": { "name": "@sillsdev/lynx-usfm", - "version": "0.0.0", + "version": "0.1.0", "license": "MIT", "dependencies": { "@sillsdev/lynx": "*", diff --git a/packages/core/src/diagnostic/diagnostic-fix.ts b/packages/core/src/diagnostic/diagnostic-fix.ts index a6cfb6a..5d45822 100644 --- a/packages/core/src/diagnostic/diagnostic-fix.ts +++ b/packages/core/src/diagnostic/diagnostic-fix.ts @@ -1,9 +1,9 @@ import { TextEdit } from '../common/text-edit'; import { Diagnostic } from './diagnostic'; -export interface DiagnosticFix { +export interface DiagnosticFix { title: string; diagnostic: Diagnostic; isPreferred?: boolean; - edits: TextEdit[]; + edits: T[]; } diff --git a/packages/core/src/diagnostic/diagnostic-provider.ts b/packages/core/src/diagnostic/diagnostic-provider.ts index ce66ad4..9c4f70b 100644 --- a/packages/core/src/diagnostic/diagnostic-provider.ts +++ b/packages/core/src/diagnostic/diagnostic-provider.ts @@ -1,5 +1,6 @@ import { Observable } from 'rxjs'; +import { TextEdit } from '../common/text-edit'; import { Diagnostic } from './diagnostic'; import { DiagnosticFix } from './diagnostic-fix'; @@ -9,10 +10,10 @@ export interface DiagnosticsChanged { diagnostics: Diagnostic[]; } -export interface DiagnosticProvider { +export interface DiagnosticProvider { readonly id: string; readonly diagnosticsChanged$: Observable; init(): Promise; getDiagnostics(uri: string): Promise; - getDiagnosticFixes(uri: string, diagnostic: Diagnostic): Promise; + getDiagnosticFixes(uri: string, diagnostic: Diagnostic): Promise[]>; } diff --git a/packages/core/src/document/document-accessor.ts b/packages/core/src/document/document-accessor.ts new file mode 100644 index 0000000..4b73392 --- /dev/null +++ b/packages/core/src/document/document-accessor.ts @@ -0,0 +1,35 @@ +import { Observable } from 'rxjs'; + +import { Document } from './document'; + +export interface DocumentCreated { + document: T; +} + +export interface DocumentClosed { + uri: string; +} + +export interface DocumentOpened { + document: T; +} + +export interface DocumentDeleted { + uri: string; +} + +export interface DocumentChanged { + document: T; +} + +export interface DocumentAccessor { + readonly created$: Observable>; + readonly closed$: Observable; + readonly opened$: Observable>; + readonly deleted$: Observable; + readonly changed$: Observable>; + + get(uri: string): Promise; + all(): Promise; + active(): Promise; +} diff --git a/packages/core/src/document/document-factory.ts b/packages/core/src/document/document-factory.ts index 5b97704..560c496 100644 --- a/packages/core/src/document/document-factory.ts +++ b/packages/core/src/document/document-factory.ts @@ -1,12 +1,7 @@ -import { Range } from '../common/range'; import { Document } from './document'; +import { TextDocumentChange } from './text-document-change'; -export interface DocumentChange { - range?: Range; - text: string; -} - -export interface DocumentFactory { - create(uri: string, format: string, version: number, content: string): T; - update(document: T, changes: readonly DocumentChange[], version: number): T; +export interface DocumentFactory { + create(uri: string, format: string, version: number, content: string): TDoc; + update(document: TDoc, changes: readonly TChange[], version: number): TDoc; } diff --git a/packages/core/src/document/document-manager.test.ts b/packages/core/src/document/document-manager.test.ts index 9db362a..90de374 100644 --- a/packages/core/src/document/document-manager.test.ts +++ b/packages/core/src/document/document-manager.test.ts @@ -1,102 +1,97 @@ +import { firstValueFrom } from 'rxjs'; import { describe, expect, it } from 'vitest'; import { mock, MockProxy } from 'vitest-mock-extended'; -import { Document } from './document'; -import { DocumentFactory } from './document-factory'; import { DocumentManager } from './document-manager'; import { DocumentReader } from './document-reader'; +import { TextDocument } from './text-document'; +import { TextDocumentFactory } from './text-document-factory'; describe('DocumentManager', () => { it('all', async () => { const env = new TestEnvironment(); - await expect(env.docManager.all()).resolves.toEqual([ - { uri: 'file1', format: 'plaintext', version: 1, content: 'This is file1.' }, - { uri: 'file2', format: 'plaintext', version: 1, content: 'This is file2.' }, - ]); + const docs = await env.docManager.all(); + expect(docs).toHaveLength(2); + expect(docs[0].content).toEqual('This is file1.'); + expect(docs[1].content).toEqual('This is file2.'); }); it('get', async () => { const env = new TestEnvironment(); - await expect(env.docManager.get('file2')).resolves.toEqual({ - uri: 'file2', - format: 'plaintext', - version: 1, - content: 'This is file2.', - }); + const doc = await env.docManager.get('file2'); + expect(doc).not.toBeNull(); + expect(doc?.content).toEqual('This is file2.'); }); it('fire created event', async () => { const env = new TestEnvironment(); - expect.assertions(1); - env.docManager.created$.subscribe((e) => { - expect(e.document).toEqual({ uri: 'file1', format: 'plaintext', version: 1, content: 'This is file1.' }); - }); + const createdPromise = firstValueFrom(env.docManager.created$); await env.docManager.fireCreated('file1'); + const createdEvent = await createdPromise; + expect(createdEvent.document.uri).toEqual('file1'); + expect(createdEvent.document.content).toEqual('This is file1.'); }); it('fire opened event', async () => { const env = new TestEnvironment(); - expect.assertions(3); - env.docManager.opened$.subscribe((e) => { - expect(e.document).toEqual({ uri: 'file1', format: 'plaintext', version: 1, content: 'This is opened file1.' }); - }); + const openedPromise = firstValueFrom(env.docManager.opened$); await expect(env.docManager.active()).resolves.toHaveLength(0); await env.docManager.fireOpened('file1', 'plaintext', 1, 'This is opened file1.'); await expect(env.docManager.active()).resolves.toHaveLength(1); + const openedEvent = await openedPromise; + expect(openedEvent.document.uri).toEqual('file1'); + expect(openedEvent.document.content).toEqual('This is opened file1.'); }); it('fire closed event', async () => { const env = new TestEnvironment(); await env.docManager.fireOpened('file1', 'plaintext', 1, 'content'); - expect.assertions(3); - env.docManager.closed$.subscribe((e) => { - expect(e.uri).toEqual('file1'); - }); + const closedPromise = firstValueFrom(env.docManager.closed$); await expect(env.docManager.active()).resolves.toHaveLength(1); await env.docManager.fireClosed('file1'); await expect(env.docManager.active()).resolves.toHaveLength(0); + const closedEvent = await closedPromise; + expect(closedEvent.uri).toEqual('file1'); }); it('fire deleted event', async () => { const env = new TestEnvironment(); - expect.assertions(2); - env.docManager.deleted$.subscribe((e) => { - expect(e.uri).toEqual('file1'); - }); + const deletedPromise = firstValueFrom(env.docManager.deleted$); await env.docManager.fireDeleted('file1'); env.docReader.keys.mockReturnValue(['file2']); await expect(env.docManager.all()).resolves.toHaveLength(1); + const deletedEvent = await deletedPromise; + expect(deletedEvent.uri).toEqual('file1'); }); it('fire changed event', async () => { const env = new TestEnvironment(); - expect.assertions(2); - const sub = env.docManager.changed$.subscribe((e) => { - expect(e.document).toEqual({ uri: 'file1', format: 'plaintext', version: 2, content: 'This is changed file1.' }); - }); + const changedPromise = firstValueFrom(env.docManager.changed$); await env.docManager.fireChanged('file1', [{ text: 'This is changed file1.' }], 2); - sub.unsubscribe(); + const changedEvent = await changedPromise; + expect(changedEvent.document.uri).toEqual('file1'); + expect(changedEvent.document.version).toEqual(2); + expect(changedEvent.document.content).toEqual('This is changed file1.'); + + // reload document from reader await env.docManager.fireChanged('file1'); - await expect(env.docManager.get('file1')).resolves.toEqual({ - uri: 'file1', - format: 'plaintext', - version: 1, - content: 'This is file1.', - }); + const doc = await env.docManager.get('file1'); + expect(doc).not.toBeNull(); + expect(doc?.content).toEqual('This is file1.'); }); }); class TestEnvironment { readonly docReader: MockProxy; - readonly docFactory: MockProxy>; - readonly docManager: DocumentManager; + readonly docFactory: TextDocumentFactory; + readonly docManager: DocumentManager; constructor() { this.docReader = mock(); @@ -104,15 +99,7 @@ class TestEnvironment { return Promise.resolve({ format: 'plaintext', version: 1, content: `This is ${uri}.` }); }); this.docReader.keys.mockReturnValue(['file1', 'file2']); - - this.docFactory = mock>(); - this.docFactory.create.mockImplementation((uri, format, version, content) => { - return { uri, format, version, content }; - }); - this.docFactory.update.mockImplementation((document, changes, version) => { - return { uri: document.uri, format: 'plaintext', version, content: changes[0].text }; - }); - + this.docFactory = new TextDocumentFactory(); this.docManager = new DocumentManager(this.docFactory, this.docReader); } } diff --git a/packages/core/src/document/document-manager.ts b/packages/core/src/document/document-manager.ts index 4f9a5ab..de9a2eb 100644 --- a/packages/core/src/document/document-manager.ts +++ b/packages/core/src/document/document-manager.ts @@ -1,44 +1,35 @@ import { Observable, Subject } from 'rxjs'; import { Document } from './document'; -import { DocumentChange, DocumentFactory } from './document-factory'; +import { + DocumentAccessor, + DocumentChanged, + DocumentClosed, + DocumentCreated, + DocumentDeleted, + DocumentOpened, +} from './document-accessor'; +import { DocumentFactory } from './document-factory'; import { DocumentReader } from './document-reader'; +import { TextDocumentChange } from './text-document-change'; -export interface DocumentCreated { - document: T; -} - -export interface DocumentClosed { - uri: string; -} - -export interface DocumentOpened { - document: T; -} - -export interface DocumentDeleted { - uri: string; -} - -export interface DocumentChanged { - document: T; -} - -export class DocumentManager { - private readonly documents = new Map(); +export class DocumentManager + implements DocumentAccessor +{ + private readonly documents = new Map(); private readonly activeDocuments = new Set(); - private readonly createdSubject = new Subject>(); + private readonly createdSubject = new Subject>(); private readonly closedSubject = new Subject(); - private readonly openedSubject = new Subject>(); + private readonly openedSubject = new Subject>(); private readonly deletedSubject = new Subject(); - private readonly changedSubject = new Subject>(); + private readonly changedSubject = new Subject>(); constructor( - private readonly factory: DocumentFactory, + private readonly factory: DocumentFactory, private readonly reader?: DocumentReader, ) {} - get created$(): Observable> { + get created$(): Observable> { return this.createdSubject.asObservable(); } @@ -46,7 +37,7 @@ export class DocumentManager { return this.closedSubject.asObservable(); } - get opened$(): Observable> { + get opened$(): Observable> { return this.openedSubject.asObservable(); } @@ -54,16 +45,16 @@ export class DocumentManager { return this.deletedSubject.asObservable(); } - get changed$(): Observable> { + get changed$(): Observable> { return this.changedSubject.asObservable(); } - add(doc: T): void { + add(doc: TDoc): void { this.documents.set(doc.uri, doc); this.activeDocuments.add(doc.uri); } - async get(uri: string): Promise { + async get(uri: string): Promise { let doc = this.documents.get(uri); if (doc == null) { doc = await this.reload(uri); @@ -71,7 +62,7 @@ export class DocumentManager { return doc; } - async all(): Promise { + async all(): Promise { const docs = Array.from(this.documents.values()); if (this.reader != null) { for (const id of this.reader.keys()) { @@ -85,8 +76,8 @@ export class DocumentManager { return docs; } - async active(): Promise { - const docs: T[] = []; + async active(): Promise { + const docs: TDoc[] = []; for (const uri of this.activeDocuments) { const doc = await this.get(uri); if (doc != null) { @@ -124,8 +115,8 @@ export class DocumentManager { return Promise.resolve(); } - async fireChanged(uri: string, changes?: readonly DocumentChange[], version?: number): Promise { - let doc: T | undefined = undefined; + async fireChanged(uri: string, changes?: readonly TChange[], version?: number): Promise { + let doc: TDoc | undefined = undefined; if (changes == null) { doc = await this.reload(uri); } else { @@ -140,7 +131,7 @@ export class DocumentManager { } } - private async reload(uri: string): Promise { + private async reload(uri: string): Promise { const data = await this.reader?.read(uri); const doc = data == null ? undefined : this.factory.create(uri, data.format, data.version, data.content); if (doc != null) { diff --git a/packages/core/src/document/document.ts b/packages/core/src/document/document.ts index 70509bb..501298c 100644 --- a/packages/core/src/document/document.ts +++ b/packages/core/src/document/document.ts @@ -1,3 +1,10 @@ +import { Position } from '../common/position'; + export interface Document { readonly uri: string; + readonly version: number; + readonly format: string; + + getText(): string; + positionAt(offset: number): Position; } diff --git a/packages/core/src/document/edit-factory.ts b/packages/core/src/document/edit-factory.ts new file mode 100644 index 0000000..89676e8 --- /dev/null +++ b/packages/core/src/document/edit-factory.ts @@ -0,0 +1,7 @@ +import { Range } from '../common/range'; +import { TextEdit } from '../common/text-edit'; +import { Document } from './document'; + +export interface EditFactory { + createTextEdit(document: TDoc, range: Range, newText: string): TEdit[]; +} diff --git a/packages/core/src/document/index.ts b/packages/core/src/document/index.ts index bc14b54..ba164fa 100644 --- a/packages/core/src/document/index.ts +++ b/packages/core/src/document/index.ts @@ -1,14 +1,24 @@ export type { Document } from './document'; -export type { DocumentChange, DocumentFactory } from './document-factory'; +export type { + DocumentAccessor, + DocumentChanged, + DocumentClosed, + DocumentCreated, + DocumentDeleted, + DocumentOpened, +} from './document-accessor'; +export type { DocumentFactory } from './document-factory'; export { DocumentManager } from './document-manager'; export type { DocumentReader } from './document-reader'; +export type { EditFactory } from './edit-factory'; export { ScriptureBook } from './scripture-book'; export { ScriptureCell } from './scripture-cell'; export { ScriptureChapter } from './scripture-chapter'; export { ScriptureCharacterStyle } from './scripture-character-style'; export { ScriptureContainer } from './scripture-container'; -export type { ScriptureNode } from './scripture-document'; -export { ScriptureDocument, ScriptureNodeType } from './scripture-document'; +export type { ScriptureDocument, ScriptureNode } from './scripture-document'; +export { findScriptureNodes, ScriptureNodeType } from './scripture-document'; +export type { ScriptureEditFactory } from './scripture-edit-factory'; export { ScriptureLeaf } from './scripture-leaf'; export { ScriptureMilestone } from './scripture-milestone'; export { ScriptureNote } from './scripture-note'; @@ -16,10 +26,13 @@ export { ScriptureOptBreak } from './scripture-optbreak'; export { ScriptureParagraph } from './scripture-paragraph'; export { ScriptureRef } from './scripture-ref'; export { ScriptureRow } from './scripture-row'; -export type { ScriptureSerializer } from './scripture-serializer'; export { ScriptureSidebar } from './scripture-sidebar'; export { ScriptureTable } from './scripture-table'; export { ScriptureText } from './scripture-text'; +export { ScriptureTextDocument } from './scripture-text-document'; +export { ScriptureTextEditFactory } from './scripture-text-edit-factory'; export { ScriptureVerse } from './scripture-verse'; export { TextDocument } from './text-document'; +export type { TextDocumentChange } from './text-document-change'; export { TextDocumentFactory } from './text-document-factory'; +export { TextEditFactory } from './text-edit-factory'; diff --git a/packages/core/src/document/scripture-cell.ts b/packages/core/src/document/scripture-cell.ts index 0f8165e..4d88c90 100644 --- a/packages/core/src/document/scripture-cell.ts +++ b/packages/core/src/document/scripture-cell.ts @@ -1,14 +1,16 @@ +import { Range } from '../common/range'; import { ScriptureContainer } from './scripture-container'; -import { ScriptureNodeType } from './scripture-document'; +import { ScriptureNode, ScriptureNodeType } from './scripture-document'; export class ScriptureCell extends ScriptureContainer { constructor( public readonly style: string, public readonly align: string, public readonly colSpan: number, - children?: ScriptureContainer[], + children?: ScriptureNode[], + range: Range = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, ) { - super(children); + super(children, range); } get type(): ScriptureNodeType { diff --git a/packages/core/src/document/scripture-character-style.ts b/packages/core/src/document/scripture-character-style.ts index d0497df..9b63971 100644 --- a/packages/core/src/document/scripture-character-style.ts +++ b/packages/core/src/document/scripture-character-style.ts @@ -1,3 +1,4 @@ +import { Range } from '../common/range'; import { ScriptureContainer } from './scripture-container'; import { ScriptureNode, ScriptureNodeType } from './scripture-document'; @@ -6,8 +7,9 @@ export class ScriptureCharacterStyle extends ScriptureContainer { public readonly style: string, public readonly attributes: Record = {}, children?: ScriptureNode[], + range: Range = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, ) { - super(children); + super(children, range); } get type(): ScriptureNodeType { diff --git a/packages/core/src/document/scripture-container.ts b/packages/core/src/document/scripture-container.ts index 803c3fa..4add9bc 100644 --- a/packages/core/src/document/scripture-container.ts +++ b/packages/core/src/document/scripture-container.ts @@ -1,15 +1,17 @@ import { Position } from '../common/position'; import { Range } from '../common/range'; import { ScriptureDocument } from './scripture-document'; -import { findNodes, ScriptureNode, ScriptureNodeType } from './scripture-document'; +import { findScriptureNodes, ScriptureNode, ScriptureNodeType } from './scripture-document'; export abstract class ScriptureContainer implements ScriptureNode { private _parent?: ScriptureNode; private readonly _children: ScriptureNode[] = []; readonly isLeaf = false; - range: Range = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }; - constructor(children?: ScriptureNode[]) { + constructor( + children?: ScriptureNode[], + public range: Range = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, + ) { if (children != null) { for (const child of children) { this.appendChild(child); @@ -52,7 +54,7 @@ export abstract class ScriptureContainer implements ScriptureNode { findNodes( filter?: ScriptureNodeType | ((node: ScriptureNode) => boolean) | ScriptureNodeType[], ): IterableIterator { - return findNodes(this, filter); + return findScriptureNodes(this, filter); } positionAt(offset: number): Position { diff --git a/packages/core/src/document/scripture-document.ts b/packages/core/src/document/scripture-document.ts index 1496791..ab2a74d 100644 --- a/packages/core/src/document/scripture-document.ts +++ b/packages/core/src/document/scripture-document.ts @@ -1,83 +1,14 @@ import { Position } from '../common/position'; import { Range } from '../common/range'; import { Document } from './document'; -import { TextDocument } from './text-document'; - -export class ScriptureDocument extends TextDocument implements Document, ScriptureNode { - private readonly _children: ScriptureNode[] = []; - readonly parent: undefined = undefined; - readonly isLeaf = false; - readonly type = ScriptureNodeType.Document; - readonly document = this; - range: Range = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }; - - constructor( - public readonly uri: string, - version: number, - content: string, - children?: ScriptureNode[], - ) { - super(uri, version, content); - if (children != null) { - for (const child of children) { - this.appendChild(child); - } - } - } - - get children(): readonly ScriptureNode[] { - return this._children; - } - - updateParent(_parent: ScriptureNode | undefined): void { - throw new Error('The method is not supported.'); - } - - remove(): void { - throw new Error('The method is not supported.'); - } +export interface ScriptureDocument extends Document, ScriptureNode { findNodes( filter?: ScriptureNodeType | ((node: ScriptureNode) => boolean) | ScriptureNodeType[], - ): IterableIterator { - return findNodes(this, filter); - } - - appendChild(child: ScriptureNode): void { - this._children.push(child); - child.updateParent(this); - } - - insertChild(index: number, child: ScriptureNode): void { - this._children.splice(index, 0, child); - child.updateParent(this); - } - - removeChild(child: ScriptureNode): void { - if (child.parent !== this) { - throw new Error('This node does not contain the specified child.'); - } - const index = this._children.indexOf(child); - if (index === -1) { - throw new Error('This node does not contain the specified child.'); - } - this._children.splice(index, 1); - child.updateParent(undefined); - } - - spliceChildren(start: number, deleteCount: number, ...items: ScriptureNode[]): void { - const removed = this._children.splice(start, deleteCount, ...items); - for (const child of removed) { - child.updateParent(undefined); - } - for (const child of items) { - child.updateParent(this); - } - } - - clearChildren(): void { - this._children.length = 0; - } + ): IterableIterator; + getText(range?: Range): string; + offsetAt(position: Position): number; + positionAt(offset: number, range?: Range): Position; } export enum ScriptureNodeType { @@ -118,7 +49,7 @@ export interface ScriptureNode { clearChildren(): void; } -export function* findNodes( +export function* findScriptureNodes( node: ScriptureNode, filter?: ScriptureNodeType | ((node: ScriptureNode) => boolean) | ScriptureNodeType[], ): IterableIterator { @@ -131,6 +62,6 @@ export function* findNodes( ) { yield child; } - yield* findNodes(child, filter); + yield* findScriptureNodes(child, filter); } } diff --git a/packages/core/src/document/scripture-edit-factory.ts b/packages/core/src/document/scripture-edit-factory.ts new file mode 100644 index 0000000..a21a1ef --- /dev/null +++ b/packages/core/src/document/scripture-edit-factory.ts @@ -0,0 +1,9 @@ +import { Range } from '../common/range'; +import { TextEdit } from '../common/text-edit'; +import { EditFactory } from './edit-factory'; +import { ScriptureDocument, ScriptureNode } from './scripture-document'; + +export interface ScriptureEditFactory + extends EditFactory { + createScriptureEdit(document: TDoc, range: Range, nodes: ScriptureNode[] | ScriptureNode): TEdit[]; +} diff --git a/packages/core/src/document/scripture-note.ts b/packages/core/src/document/scripture-note.ts index 42dd425..9b703b0 100644 --- a/packages/core/src/document/scripture-note.ts +++ b/packages/core/src/document/scripture-note.ts @@ -1,13 +1,16 @@ +import { Range } from '../common/range'; import { ScriptureContainer } from './scripture-container'; -import { ScriptureNodeType } from './scripture-document'; +import { ScriptureNode, ScriptureNodeType } from './scripture-document'; export class ScriptureNote extends ScriptureContainer { constructor( public readonly style: string, public readonly caller: string, public readonly category?: string, + children?: ScriptureNode[], + range: Range = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, ) { - super(); + super(children, range); } get type(): ScriptureNodeType { diff --git a/packages/core/src/document/scripture-paragraph.ts b/packages/core/src/document/scripture-paragraph.ts index c328486..06a69c9 100644 --- a/packages/core/src/document/scripture-paragraph.ts +++ b/packages/core/src/document/scripture-paragraph.ts @@ -1,3 +1,4 @@ +import { Range } from '../common/range'; import { ScriptureContainer } from './scripture-container'; import { ScriptureNode, ScriptureNodeType } from './scripture-document'; @@ -5,18 +6,13 @@ export class ScriptureParagraph extends ScriptureContainer { readonly type = ScriptureNodeType.Paragraph; readonly attributes: Record; - constructor(style: string, children?: ScriptureNode[]); - constructor(style: string, attributes?: Record, children?: ScriptureNode[]); constructor( public readonly style: string, - attributesOrChildren: Record | ScriptureNode[] = {}, + attributes: Record = {}, children?: ScriptureNode[], + range: Range = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, ) { - if (Array.isArray(attributesOrChildren)) { - children = attributesOrChildren; - attributesOrChildren = {}; - } - super(children); - this.attributes = attributesOrChildren; + super(children, range); + this.attributes = attributes; } } diff --git a/packages/core/src/document/scripture-serializer.ts b/packages/core/src/document/scripture-serializer.ts deleted file mode 100644 index cad6b90..0000000 --- a/packages/core/src/document/scripture-serializer.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ScriptureNode } from './scripture-document'; - -export interface ScriptureSerializer { - serialize(nodes: ScriptureNode[] | ScriptureNode): string; -} diff --git a/packages/core/src/document/scripture-text-document.ts b/packages/core/src/document/scripture-text-document.ts new file mode 100644 index 0000000..60c0c85 --- /dev/null +++ b/packages/core/src/document/scripture-text-document.ts @@ -0,0 +1,75 @@ +import { Range } from '../common'; +import { findScriptureNodes, ScriptureDocument, ScriptureNode, ScriptureNodeType } from './scripture-document'; +import { TextDocument } from './text-document'; + +export class ScriptureTextDocument extends TextDocument implements ScriptureDocument { + private readonly _children: ScriptureNode[] = []; + readonly type = ScriptureNodeType.Document; + readonly document = this; + readonly parent = undefined; + readonly isLeaf = false; + range: Range = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }; + + constructor(uri: string, format: string, version: number, content: string, children?: ScriptureNode[]) { + super(uri, format, version, content); + if (children != null) { + for (const child of children) { + this.appendChild(child); + } + } + } + + get children(): readonly ScriptureNode[] { + return this._children; + } + + updateParent(_parent: ScriptureNode | undefined): void { + throw new Error('The method is not supported.'); + } + + remove(): void { + throw new Error('The method is not supported.'); + } + + findNodes( + filter?: ScriptureNodeType | ((node: ScriptureNode) => boolean) | ScriptureNodeType[], + ): IterableIterator { + return findScriptureNodes(this, filter); + } + + appendChild(child: ScriptureNode): void { + this._children.push(child); + child.updateParent(this); + } + + insertChild(index: number, child: ScriptureNode): void { + this._children.splice(index, 0, child); + child.updateParent(this); + } + + removeChild(child: ScriptureNode): void { + if (child.parent !== this) { + throw new Error('This node does not contain the specified child.'); + } + const index = this._children.indexOf(child); + if (index === -1) { + throw new Error('This node does not contain the specified child.'); + } + this._children.splice(index, 1); + child.updateParent(undefined); + } + + spliceChildren(start: number, deleteCount: number, ...items: ScriptureNode[]): void { + const removed = this._children.splice(start, deleteCount, ...items); + for (const child of removed) { + child.updateParent(undefined); + } + for (const child of items) { + child.updateParent(this); + } + } + + clearChildren(): void { + this._children.length = 0; + } +} diff --git a/packages/core/src/document/scripture-text-edit-factory.ts b/packages/core/src/document/scripture-text-edit-factory.ts new file mode 100644 index 0000000..5befbc7 --- /dev/null +++ b/packages/core/src/document/scripture-text-edit-factory.ts @@ -0,0 +1,17 @@ +import { TextEdit } from '../common'; +import { Range } from '../common/range'; +import { ScriptureDocument, ScriptureNode } from './scripture-document'; +import { ScriptureEditFactory } from './scripture-edit-factory'; +import { ScriptureTextDocument } from './scripture-text-document'; +import { TextEditFactory } from './text-edit-factory'; + +export abstract class ScriptureTextEditFactory + extends TextEditFactory + implements ScriptureEditFactory +{ + abstract createScriptureEdit( + document: ScriptureDocument, + range: Range, + nodes: ScriptureNode[] | ScriptureNode, + ): TextEdit[]; +} diff --git a/packages/core/src/document/text-document-change.ts b/packages/core/src/document/text-document-change.ts new file mode 100644 index 0000000..b2af345 --- /dev/null +++ b/packages/core/src/document/text-document-change.ts @@ -0,0 +1,6 @@ +import { Range } from '../common/range'; + +export interface TextDocumentChange { + range?: Range; + text: string; +} diff --git a/packages/core/src/document/text-document-factory.ts b/packages/core/src/document/text-document-factory.ts index cf46fc8..4b91f7f 100644 --- a/packages/core/src/document/text-document-factory.ts +++ b/packages/core/src/document/text-document-factory.ts @@ -1,12 +1,13 @@ -import { DocumentChange, DocumentFactory } from './document-factory'; +import { DocumentFactory } from './document-factory'; import { TextDocument } from './text-document'; +import { TextDocumentChange } from './text-document-change'; export class TextDocumentFactory implements DocumentFactory { - create(uri: string, _format: string, version: number, content: string): TextDocument { - return new TextDocument(uri, version, content); + create(uri: string, format: string, version: number, content: string): TextDocument { + return new TextDocument(uri, format, version, content); } - update(document: TextDocument, changes: readonly DocumentChange[], version: number): TextDocument { + update(document: TextDocument, changes: readonly TextDocumentChange[], version: number): TextDocument { document.update(changes, version); return document; } diff --git a/packages/core/src/document/text-document.ts b/packages/core/src/document/text-document.ts index d1269c5..f21eeeb 100644 --- a/packages/core/src/document/text-document.ts +++ b/packages/core/src/document/text-document.ts @@ -1,19 +1,19 @@ import { Position } from '../common/position'; import { Range } from '../common/range'; +import { TextEdit } from '../common/text-edit'; import { Document } from './document'; -import { DocumentChange } from './document-factory'; +import { TextDocumentChange } from './text-document-change'; export class TextDocument implements Document { private _lineOffsets: number[] | undefined = undefined; private _content: string; - private _version: number; constructor( public readonly uri: string, - version: number, + public readonly format: string, + public version: number, content: string, ) { - this._version = version; this._content = content; } @@ -21,14 +21,6 @@ export class TextDocument implements Document { return this._content; } - get version(): number { - return this._version; - } - - protected set version(value: number) { - this._version = value; - } - getText(range?: Range): string { if (range != null) { const start = this.offsetAt(range.start); @@ -95,14 +87,14 @@ export class TextDocument implements Document { return { line, character: contentOffset - lineOffsets[line] }; } - update(changes: readonly DocumentChange[], version: number): void { + update(changes: readonly TextDocumentChange[], version: number): void { for (const change of changes) { this.updateContent(change); } this.version = version; } - protected updateContent(change: DocumentChange): void { + updateContent(change: TextDocumentChange): void { if (change.range == null) { this._content = change.text; this._lineOffsets = undefined; @@ -143,14 +135,18 @@ export class TextDocument implements Document { } } - public getLineOffsets(): number[] { + createTextEdit(startOffset: number, endOffset: number, newText: string): TextEdit[] { + return [{ range: { start: this.positionAt(startOffset), end: this.positionAt(endOffset) }, newText }]; + } + + private getLineOffsets(): number[] { if (this._lineOffsets === undefined) { this._lineOffsets = computeLineOffsets(this._content, true); } return this._lineOffsets; } - public ensureBeforeEndOfLine(offset: number, lineOffset: number): number { + private ensureBeforeEndOfLine(offset: number, lineOffset: number): number { while (offset > lineOffset && (this._content[offset - 1] === '\r' || this._content[offset - 1] === '\n')) { offset--; } diff --git a/packages/core/src/document/text-edit-factory.ts b/packages/core/src/document/text-edit-factory.ts new file mode 100644 index 0000000..287e305 --- /dev/null +++ b/packages/core/src/document/text-edit-factory.ts @@ -0,0 +1,10 @@ +import { Range } from '../common/range'; +import { TextEdit } from '../common/text-edit'; +import { EditFactory } from './edit-factory'; +import { TextDocument } from './text-document'; + +export class TextEditFactory implements EditFactory { + createTextEdit(_document: T, range: Range, newText: string): TextEdit[] { + return [{ range, newText }]; + } +} diff --git a/packages/core/src/formatting/on-type-formatting-provider.ts b/packages/core/src/formatting/on-type-formatting-provider.ts index 8908918..94d72c8 100644 --- a/packages/core/src/formatting/on-type-formatting-provider.ts +++ b/packages/core/src/formatting/on-type-formatting-provider.ts @@ -1,11 +1,11 @@ import { Position } from '../common/position'; import { TextEdit } from '../common/text-edit'; -export interface OnTypeFormattingProvider { +export interface OnTypeFormattingProvider { readonly id: string; readonly onTypeTriggerCharacters: ReadonlySet; init(): Promise; - getOnTypeEdits(uri: string, position: Position, ch: string): Promise; + getOnTypeEdits(uri: string, position: Position, ch: string): Promise; } diff --git a/packages/core/src/workspace/workspace.ts b/packages/core/src/workspace/workspace.ts index a58b10c..9eb94d6 100644 --- a/packages/core/src/workspace/workspace.ts +++ b/packages/core/src/workspace/workspace.ts @@ -8,21 +8,21 @@ import { DiagnosticProvider, DiagnosticsChanged } from '../diagnostic/diagnostic import { OnTypeFormattingProvider } from '../formatting/on-type-formatting-provider'; import { Localizer } from './localizer'; -export interface WorkspaceConfig { +export interface WorkspaceConfig { localizer: Localizer; - diagnosticProviders?: DiagnosticProvider[]; - onTypeFormattingProviders?: OnTypeFormattingProvider[]; + diagnosticProviders?: DiagnosticProvider[]; + onTypeFormattingProviders?: OnTypeFormattingProvider[]; } -export class Workspace { +export class Workspace { private readonly localizer: Localizer; - private readonly diagnosticProviders: Map; - private readonly onTypeFormattingProviders: Map; + private readonly diagnosticProviders: Map>; + private readonly onTypeFormattingProviders: Map>; private readonly lastDiagnosticChangedEvents = new Map(); public readonly diagnosticsChanged$: Observable; - constructor(config: WorkspaceConfig) { + constructor(config: WorkspaceConfig) { this.localizer = config.localizer; this.diagnosticProviders = new Map(config.diagnosticProviders?.map((provider) => [provider.id, provider])); this.diagnosticsChanged$ = merge( @@ -57,7 +57,7 @@ export class Workspace { return diagnostics; } - async getDiagnosticFixes(uri: string, diagnostic: Diagnostic): Promise { + async getDiagnosticFixes(uri: string, diagnostic: Diagnostic): Promise[]> { const provider = this.diagnosticProviders.get(diagnostic.source); if (provider == null) { return []; @@ -75,7 +75,7 @@ export class Workspace { return Array.from(characters); } - async getOnTypeEdits(uri: string, position: Position, ch: string): Promise { + async getOnTypeEdits(uri: string, position: Position, ch: string): Promise { for (const provider of this.onTypeFormattingProviders.values()) { if (provider.onTypeTriggerCharacters.has(ch)) { const edits = await provider.getOnTypeEdits(uri, position, ch); diff --git a/packages/delta/eslint.config.js b/packages/delta/eslint.config.js new file mode 100644 index 0000000..1e55cc4 --- /dev/null +++ b/packages/delta/eslint.config.js @@ -0,0 +1,17 @@ +import library from '@repo/eslint-config/library.js'; + +export default [ + { + languageOptions: { + parserOptions: { + project: './tsconfig.json', + }, + }, + }, + ...library, + { + rules: { + '@typescript-eslint/no-explicit-any': 'off', + }, + }, +]; diff --git a/packages/delta/package.json b/packages/delta/package.json new file mode 100644 index 0000000..160c700 --- /dev/null +++ b/packages/delta/package.json @@ -0,0 +1,50 @@ +{ + "name": "@sillsdev/lynx-delta", + "version": "0.0.0", + "description": "", + "type": "module", + "types": "./dist/index.d.ts", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "scripts": { + "build": "tsup-node", + "dev": "tsup-node --watch --sourcemap", + "check-types": "tsc --noEmit", + "lint": "eslint ." + }, + "keywords": [], + "author": "SIL Global", + "license": "MIT", + "dependencies": { + "@sillsdev/lynx": "*", + "quill-delta": "^5.1.0", + "uuid": "^11.0.3" + }, + "devDependencies": { + "@repo/eslint-config": "*", + "@repo/tsup-config": "*", + "@repo/typescript-config": "*", + "@types/lodash.clonedeep": "^4.5.9", + "@types/lodash.isequal": "^4.5.8", + "eslint": "^9.9.1", + "tsup": "^8.3.0", + "typescript": "^5.5.4" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ] +} diff --git a/packages/delta/src/delta-document-factory.ts b/packages/delta/src/delta-document-factory.ts new file mode 100644 index 0000000..6e45fed --- /dev/null +++ b/packages/delta/src/delta-document-factory.ts @@ -0,0 +1,15 @@ +import { DocumentFactory } from '@sillsdev/lynx'; +import Delta, { Op } from 'quill-delta'; + +import { DeltaDocument } from './delta-document'; + +export class DeltaDocumentFactory implements DocumentFactory { + create(uri: string, format: string, version: number, content: string): DeltaDocument { + return new DeltaDocument(uri, format, version, new Delta(JSON.parse(content))); + } + + update(document: DeltaDocument, changes: readonly Op[], version: number): DeltaDocument { + document.update(changes, version); + return document; + } +} diff --git a/packages/delta/src/delta-document.test.ts b/packages/delta/src/delta-document.test.ts new file mode 100644 index 0000000..ae0af22 --- /dev/null +++ b/packages/delta/src/delta-document.test.ts @@ -0,0 +1,135 @@ +import Delta from 'quill-delta'; +import { describe, expect, it } from 'vitest'; + +import { DeltaDocument } from './delta-document'; + +describe('DeltaDocument', () => { + it('getText', () => { + const document = new DeltaDocument( + 'uri', + 'rich-text', + 1, + new Delta() + .insert('Hello\n\n') + .insert('World') + .insert({ image: 'octocat.png' }) + .insert('\n', { align: 'right' }) + .insert('!') + .insert('\n'), + ); + + expect(document.getText()).toEqual('Hello\n\nWorld\uFFFC\n!\n'); + }); + + it('update single line, single op delta', () => { + const document = new DeltaDocument( + 'uri', + 'rich-text', + 1, + new Delta().insert('Hello World!').insert('\n').insert('Hello World!').insert('\n'), + ); + document.update(new Delta().retain(19).delete(5).insert('everybody').ops, 2); + + expect(document.version).toEqual(2); + expect(document.getText()).toEqual('Hello World!\nHello everybody!\n'); + }); + + it('update single line, multiple op delta', () => { + const document = new DeltaDocument( + 'uri', + 'rich-text', + 1, + new Delta() + .insert('Line one.') + .insert('\n', { para: true }) + .insert('Line two.') + .insert('\n', { para: true }) + .insert('Line three.') + .insert('\n', { para: true }), + ); + document.update(new Delta().retain(5).delete(3).insert('1').ops, 2); + + expect(document.version).toEqual(2); + expect(document.getText()).toEqual('Line 1.\nLine two.\nLine three.\n'); + expect(document.offsetAt({ line: 1, character: 0 })).toEqual(8); + expect(document.offsetAt({ line: 2, character: 0 })).toEqual(18); + }); + + it('add new line', () => { + const document = new DeltaDocument( + 'uri', + 'rich-text', + 1, + new Delta() + .insert('Line one.') + .insert('\n') + .insert('Line two.') + .insert('\n') + .insert('Line three.\n', { style: 'p' }), + ); + document.update(new Delta().retain(10).insert('Hello everybody!\n').ops, 2); + + expect(document.version).toEqual(2); + expect(document.getText()).toEqual('Line one.\nHello everybody!\nLine two.\nLine three.\n'); + expect(document.offsetAt({ line: 2, character: 0 })).toEqual(27); + expect(document.offsetAt({ line: 3, character: 0 })).toEqual(37); + }); + + it('add new line with different style', () => { + const document = new DeltaDocument( + 'uri', + 'rich-text', + 1, + new Delta().insert('Hello World!').insert('\n').insert('Hello World!').insert('\n'), + ); + document.update(new Delta().retain(13).insert('Hello everybody!\n', { style: 'p' }).ops, 2); + + expect(document.version).toEqual(2); + expect(document.getText()).toEqual('Hello World!\nHello everybody!\nHello World!\n'); + expect(document.offsetAt({ line: 2, character: 0 })).toEqual(30); + }); + + it('offsetAt', () => { + const document = new DeltaDocument( + 'uri', + 'rich-text', + 1, + new Delta() + .insert('Hello\n\n') + .insert('World') + .insert({ image: 'octocat.png' }) + .insert('\n', { align: 'right' }) + .insert('!') + .insert('\n'), + ); + + expect(document.offsetAt({ line: 0, character: 0 })).toEqual(0); + expect(document.offsetAt({ line: 0, character: 5 })).toEqual(5); + expect(document.offsetAt({ line: 1, character: 0 })).toEqual(6); + expect(document.offsetAt({ line: 2, character: 0 })).toEqual(7); + expect(document.offsetAt({ line: 2, character: 6 })).toEqual(13); + expect(document.offsetAt({ line: 3, character: 0 })).toEqual(14); + }); + + it('positionAt', () => { + const document = new DeltaDocument( + 'uri', + 'rich-text', + 1, + new Delta() + .insert('Hello\n\n') + .insert('World') + .insert({ image: 'octocat.png' }) + .insert('\n', { align: 'right' }) + .insert('!') + .insert('\n'), + ); + + expect(document.positionAt(0)).toEqual({ line: 0, character: 0 }); + expect(document.positionAt(5)).toEqual({ line: 0, character: 5 }); + expect(document.positionAt(6)).toEqual({ line: 1, character: 0 }); + expect(document.positionAt(7)).toEqual({ line: 2, character: 0 }); + expect(document.positionAt(13)).toEqual({ line: 2, character: 6 }); + expect(document.positionAt(14)).toEqual({ line: 3, character: 0 }); + }); +}); diff --git a/packages/delta/src/delta-document.ts b/packages/delta/src/delta-document.ts new file mode 100644 index 0000000..cb37662 --- /dev/null +++ b/packages/delta/src/delta-document.ts @@ -0,0 +1,212 @@ +import { Document, Position, Range } from '@sillsdev/lynx'; +import Delta, { Op } from 'quill-delta'; + +export class DeltaDocument implements Document { + protected _content: Delta; + protected lineOffsets: number[] | undefined = undefined; + protected lineOps: number[] | undefined = undefined; + + constructor( + public readonly uri: string, + public readonly format: string, + public version: number, + content: Delta, + ) { + this._content = content; + } + + get content(): Delta { + return this._content; + } + + update(changes: readonly Op[], version: number): void { + const change = new Delta(changes as Op[]); + const [changeStartOffset, changeEndOffset, insertLength] = getChangeOffsetRange(change); + const changeStart = this.positionAt(changeStartOffset); + const changeEnd = this.positionAt(changeEndOffset); + let changeStartLine = changeStart.line; + const changeEndLine = changeEnd.line + 1; + + const updated = this._content.compose(change); + const opDiff = updated.ops.length - this._content.ops.length; + this._content = updated; + + let lineOps = this.lineOps!; + const opStartIndex = lineOps[changeStartLine]; + while (changeStartLine > 0 && lineOps[changeStartLine - 1] === opStartIndex) { + changeStartLine--; + } + const opEndIndex = lineOps[changeEndLine] + opDiff; + const subDelta = new Delta(updated.ops.slice(opStartIndex, opEndIndex)); + const addedLineOffsets: number[] = []; + const addedLineOps: number[] = []; + computeLineOffsets( + subDelta, + addedLineOffsets, + addedLineOps, + this.offsetAt({ line: changeStartLine, character: 0 }), + ); + + // update line indexes + let lineOffsets = this.lineOffsets!; + const addedLineLength = addedLineOffsets.length; + const deletedLineLength = changeEndLine - changeStartLine; + const charDiff = insertLength - (changeEndOffset - changeStartOffset); + const lineDiff = addedLineLength - deletedLineLength; + if (lineDiff === 0) { + for (let i = 0; i < deletedLineLength; i++) { + lineOffsets[i + changeStartLine + 1] = addedLineOffsets[i]; + lineOps[i + changeStartLine + 1] = addedLineOps[i]; + } + } else { + if (addedLineLength < 10000) { + lineOffsets.splice(changeStartLine + 1, deletedLineLength, ...addedLineOffsets); + lineOps.splice(changeStartLine + 1, deletedLineLength, ...addedLineOps); + } else { + // avoid too many arguments for splice + this.lineOffsets = lineOffsets = lineOffsets + .slice(0, changeStartLine) + .concat(addedLineOffsets, lineOffsets.slice(changeEndLine)); + this.lineOps = lineOps = lineOps.slice(0, changeStartLine).concat(addedLineOps, lineOps.slice(changeEndLine)); + } + } + + if (charDiff !== 0 || opDiff !== 0) { + const newLineLength = lineOffsets.length; + for (let i = changeStartLine + 1 + addedLineLength; i < newLineLength; i++) { + if (charDiff !== 0) { + lineOffsets[i] += charDiff; + } + if (opDiff !== 0) { + lineOps[i] += opDiff; + } + } + } + + this.version = version; + } + + getText(range?: Range): string { + let content = this._content; + if (range != null) { + const start = this.offsetAt(range.start); + const end = this.offsetAt(range.end); + content = this._content.slice(start, end); + } + return content.map((op) => (typeof op.insert === 'string' ? op.insert : '\ufffc')).join(''); + } + + positionAt(offset: number, range?: Range): Position { + const lineOffsets = this.getLineOffsets(); + if (range == null) { + range = { start: { line: 0, character: 0 }, end: { line: lineOffsets.length - 1, character: 0 } }; + } + + if (range.start.line === range.end.line) { + return { + line: range.start.line, + character: Math.min(range.start.character + offset, range.end.character), + }; + } + + const startOffset = this.offsetAt(range.start); + const endOffset = this.offsetAt(range.end); + if (startOffset === endOffset) { + return range.start; + } + let contentOffset = startOffset + offset; + contentOffset = Math.max(Math.min(contentOffset, endOffset), 0); + + let low = 0; + let high = range.end.line + 1; + while (low < high) { + const mid = Math.floor((low + high) / 2); + if (lineOffsets[mid] > contentOffset) { + high = mid; + } else { + low = mid + 1; + } + } + // low is the least x for which the line offset is larger than the current offset + // or array.length if no line offset is larger than the current offset + const line = low - 1; + + return { line, character: contentOffset - lineOffsets[line] }; + } + + offsetAt(position: Position): number { + const lineOffsets = this.getLineOffsets(); + if (position.line >= lineOffsets.length) { + return lineOffsets[lineOffsets.length - 1] + 1; + } else if (position.line < 0) { + return 0; + } + const lineOffset = lineOffsets[position.line]; + if (position.character <= 0) { + return lineOffset; + } + + const nextLineOffset = + position.line + 1 < lineOffsets.length ? lineOffsets[position.line + 1] : lineOffsets[lineOffsets.length - 1] + 1; + return Math.min(lineOffset + position.character, nextLineOffset); + } + + private getLineOffsets(): number[] { + if (this.lineOffsets === undefined) { + this.lineOffsets = [0]; + this.lineOps = [0]; + computeLineOffsets(this._content, this.lineOffsets, this.lineOps); + } + return this.lineOffsets; + } +} + +function computeLineOffsets(delta: Delta, lineOffsets: number[], lineOps: number[], textOffset = 0): void { + let offset = textOffset; + let i = 0; + for (const op of delta.ops) { + if (typeof op.insert === 'string') { + let startIndex = 0; + while (startIndex < op.insert.length) { + const endIndex = op.insert.indexOf('\n', startIndex); + if (endIndex === -1) { + break; + } + lineOffsets.push(offset + endIndex + 1); + lineOps.push(endIndex + 1 === op.insert.length ? i + 1 : i); + startIndex = endIndex + 1; + } + offset += op.insert.length; + } else { + offset++; + } + i++; + } +} + +export function getChangeOffsetRange(change: Delta): [number, number, number] { + let startOffset = 0; + let i = 0; + while (change.ops[i].retain != null && change.ops[i].attributes == null) { + startOffset += change.ops[i].retain as number; + i++; + } + + let endOffset = startOffset; + let insertLength = 0; + for (; i < change.ops.length; i++) { + const op = change.ops[i]; + if (op.retain != null) { + endOffset += op.retain as number; + } else if (op.delete != null) { + endOffset += op.delete; + } else if (op.insert != null) { + if (typeof op.insert === 'string') { + insertLength += op.insert.length; + } else { + insertLength += 1; + } + } + } + return [startOffset, endOffset, insertLength]; +} diff --git a/packages/delta/src/delta-edit-factory.ts b/packages/delta/src/delta-edit-factory.ts new file mode 100644 index 0000000..4cb4127 --- /dev/null +++ b/packages/delta/src/delta-edit-factory.ts @@ -0,0 +1,22 @@ +import { EditFactory, Range } from '@sillsdev/lynx'; +import { Op } from 'quill-delta'; + +import { DeltaDocument } from './delta-document'; + +export class DeltaEditFactory implements EditFactory { + createTextEdit(document: T, range: Range, newText: string): Op[] { + const startOffset = document.offsetAt(range.start); + const endOffset = document.offsetAt(range.end); + const ops: Op[] = []; + if (startOffset > 0) { + ops.push({ retain: startOffset }); + } + if (endOffset - startOffset > 0) { + ops.push({ delete: endOffset - startOffset }); + } + if (newText.length > 0) { + ops.push({ insert: newText }); + } + return ops; + } +} diff --git a/packages/delta/src/index.ts b/packages/delta/src/index.ts new file mode 100644 index 0000000..1af14c2 --- /dev/null +++ b/packages/delta/src/index.ts @@ -0,0 +1,3 @@ +export { DeltaDocument } from './delta-document'; +export { DeltaDocumentFactory } from './delta-document-factory'; +export { DeltaEditFactory } from './delta-edit-factory'; diff --git a/packages/delta/src/scripture-delta-document.test.ts b/packages/delta/src/scripture-delta-document.test.ts new file mode 100644 index 0000000..fe0933f --- /dev/null +++ b/packages/delta/src/scripture-delta-document.test.ts @@ -0,0 +1,285 @@ +import { + ScriptureCell, + ScriptureChapter, + ScriptureParagraph, + ScriptureRow, + ScriptureTable, + ScriptureText, +} from '@sillsdev/lynx'; +import Delta from 'quill-delta'; +import { describe, expect, it } from 'vitest'; + +import { ScriptureDeltaDocument } from './scripture-delta-document'; + +describe('ScriptureDeltaDocument', () => { + it('paragraph', () => { + const document = new ScriptureDeltaDocument( + 'uri', + 1, + new Delta() + .insert({ chapter: { number: '1', style: 'c' } }) + .insert({ verse: { number: '1', style: 'v' } }) + .insert('This is a test.', { segment: 'verse_1_1' }) + .insert('\n', { para: { style: 'p' } }) + .insert({ verse: { number: '2', style: 'v' } }) + .insert('This is a test.', { segment: 'verse_1_2' }) + .insert('\n', { para: { style: 'p' } }), + ); + + expect(document.children.length).toEqual(3); + + expect(document.children[0]).toBeInstanceOf(ScriptureChapter); + const book = document.children[0] as ScriptureChapter; + expect(book.number).toEqual('1'); + expect(book.range).toEqual({ start: { line: 0, character: 0 }, end: { line: 1, character: 0 } }); + + expect(document.children[1]).toBeInstanceOf(ScriptureParagraph); + const paragraph1 = document.children[1] as ScriptureParagraph; + expect(paragraph1.style).toEqual('p'); + expect(paragraph1.range).toEqual({ start: { line: 1, character: 0 }, end: { line: 2, character: 0 } }); + + expect(document.children[2]).toBeInstanceOf(ScriptureParagraph); + const paragraph2 = document.children[2] as ScriptureParagraph; + expect(paragraph2.style).toEqual('p'); + expect(paragraph2.range).toEqual({ start: { line: 2, character: 0 }, end: { line: 3, character: 0 } }); + }); + + it('table', () => { + const document = new ScriptureDeltaDocument( + 'uri', + 1, + new Delta() + .insert({ chapter: { number: '1', style: 'c' } }) + .insert('Before verse.', { segment: 'cell_1_1_1' }) + .insert({ verse: { number: '1', style: 'v' } }) + .insert('This is verse ', { segment: 'verse_1_1' }) + .insert('1', { char: { cid: '123', style: 'it' }, segment: 'verse_1_1' }) + .insert('.', { segment: 'verse_1_1' }) + .insert('\n', { table: { id: 'table_1' }, row: { id: 'row_1' }, cell: { style: 'tc1', align: 'start' } }) + .insert({ blank: true }, { segment: 'cell_1_1_2' }) + .insert({ verse: { number: '2', style: 'v' } }) + .insert('This is verse 2.', { segment: 'verse_1_2' }) + .insert('\n', { table: { id: 'table_1' }, row: { id: 'row_1' }, cell: { style: 'tc2', align: 'start' } }) + .insert({ blank: true }, { segment: 'cell_1_2_1' }) + .insert('\n', { table: { id: 'table_1' }, row: { id: 'row_2' }, cell: { style: 'tc1', align: 'start' } }) + .insert({ blank: true }, { segment: 'cell_1_2_2' }) + .insert({ verse: { number: '3', style: 'v' } }) + .insert('This is verse 3.', { segment: 'verse_1_3' }) + .insert('\n', { table: { id: 'table_1' }, row: { id: 'row_2' }, cell: { style: 'tc2', align: 'start' } }), + ); + + expect(document.children.length).toEqual(2); + + expect(document.children[1]).toBeInstanceOf(ScriptureTable); + const table = document.children[1] as ScriptureTable; + expect(table.children.length).toEqual(2); + expect(table.children[0]).toBeInstanceOf(ScriptureRow); + const row1 = table.children[0] as ScriptureRow; + expect(row1.children.length).toEqual(2); + expect(row1.children[1]).toBeInstanceOf(ScriptureCell); + const cell12 = row1.children[1] as ScriptureCell; + expect(cell12.getText()).toEqual('\ufffc\ufffcThis is verse 2.\n'); + expect(table.children[1]).toBeInstanceOf(ScriptureRow); + const row2 = table.children[1] as ScriptureRow; + expect(row2.children.length).toEqual(2); + expect(row2.children[0]).toBeInstanceOf(ScriptureCell); + const cell21 = row2.children[0] as ScriptureCell; + expect(cell21.getText()).toEqual('\ufffc\n'); + }); + + it('collapses adjacent newlines', () => { + const document = new ScriptureDeltaDocument( + 'uri', + 1, + new Delta() + .insert({ chapter: { number: '1', style: 'c' } }) + .insert({ blank: true }, { segment: 'p_1' }) + .insert({ verse: { number: '1', style: 'v' } }) + .insert('Verse text.', { segment: 'verse_1_1' }) + .insert('\n', { para: { style: 'p' } }) + .insert('\n\n'), + ); + + expect(document.children.length).toEqual(2); + const paragraph1 = document.children[1]; + expect(paragraph1.getText()).toEqual(`\ufffc\ufffcVerse text.\n`); + }); + + it('no paragraph', () => { + const document = new ScriptureDeltaDocument( + 'uri', + 1, + new Delta() + .insert({ chapter: { number: '1', style: 'c' } }) + .insert({ verse: { number: '1', style: 'v' } }) + .insert('This is verse 1.', { segment: 'verse_1_1' }) + .insert({ verse: { number: '2', style: 'v' } }) + .insert({ blank: true }, { segment: 'verse_1_2' }) + .insert({ verse: { number: '3', style: 'v' } }) + .insert('This is verse 3.', { segment: 'verse_1_3' }) + .insert('\n'), + ); + + expect(document.children.length).toEqual(2); + expect(document.children[1]).toBeInstanceOf(ScriptureParagraph); + const paragraph1 = document.children[1] as ScriptureParagraph; + expect(paragraph1.style).toEqual('p'); + expect(paragraph1.getText()).toEqual(`\ufffcThis is verse 1.\ufffc\ufffc\ufffcThis is verse 3.\n`); + expect(paragraph1.range).toEqual({ start: { line: 1, character: 0 }, end: { line: 2, character: 0 } }); + }); + + it('update single line in a paragraph', () => { + const document = new ScriptureDeltaDocument( + 'uri', + 1, + new Delta() + .insert({ chapter: { number: '1', style: 'c' } }) + .insert({ verse: { number: '1', style: 'v' } }) + .insert('This is a test.', { segment: 'verse_1_1' }) + .insert('\n', { para: { style: 'p' } }) + .insert({ verse: { number: '2', style: 'v' } }) + .insert('This is a test.', { segment: 'verse_1_2' }) + .insert('\n', { para: { style: 'p' } }), + ); + + document.update(new Delta().retain(16).insert(' again', { segment: 'verse_1_1' }).ops, 2); + + expect(document.children.length).toEqual(3); + + expect(document.children[1]).toBeInstanceOf(ScriptureParagraph); + const paragraph1 = document.children[1] as ScriptureParagraph; + expect(paragraph1.style).toEqual('p'); + expect(paragraph1.children[1]).toBeInstanceOf(ScriptureText); + const text1 = paragraph1.children[1] as ScriptureText; + expect(text1.text).toEqual('This is a test again.'); + expect(text1.range).toEqual({ start: { line: 1, character: 1 }, end: { line: 1, character: 22 } }); + expect(document.children[2].getText()).toEqual(`\ufffcThis is a test.\n`); + }); + + it('add new paragraph', () => { + const document = new ScriptureDeltaDocument( + 'uri', + 1, + new Delta() + .insert({ chapter: { number: '1', style: 'c' } }) + .insert({ verse: { number: '1', style: 'v' } }) + .insert('This is a test.', { segment: 'verse_1_1' }) + .insert('\n', { para: { style: 'p' } }) + .insert({ verse: { number: '2', style: 'v' } }) + .insert('This is a test.', { segment: 'verse_1_2' }) + .insert('\n', { para: { style: 'p' } }), + ); + + document.update( + new Delta() + .retain(18) + .insert('section header.', { segment: 's_1' }) + .insert('\n', { para: { style: 's' } }).ops, + 2, + ); + + expect(document.children.length).toEqual(4); + + const paragraph2 = document.children[2]; + expect(paragraph2.getText()).toEqual(`section header.\n`); + expect(paragraph2.range).toEqual({ start: { line: 2, character: 0 }, end: { line: 3, character: 0 } }); + + const paragraph3 = document.children[3]; + expect(paragraph3.getText()).toEqual(`\ufffcThis is a test.\n`); + expect(paragraph3.range).toEqual({ start: { line: 3, character: 0 }, end: { line: 4, character: 0 } }); + }); + + it('add verse at end of a paragraph', () => { + const document = new ScriptureDeltaDocument( + 'uri', + 1, + new Delta() + .insert({ chapter: { number: '1', style: 'c' } }) + .insert({ verse: { number: '1', style: 'v' } }) + .insert('This is a test.', { segment: 'verse_1_1' }) + .insert('\n', { para: { style: 'p' } }) + .insert({ verse: { number: '2', style: 'v' } }) + .insert('This is a test.', { segment: 'verse_1_2' }) + .insert('\n', { para: { style: 'p' } }), + ); + + document.update( + new Delta() + .retain(17) + .insert({ verse: { number: '1a', style: 'v' } }) + .insert('This is a new verse.', { segment: 'verse_1_1a' }).ops, + 2, + ); + + expect(document.children.length).toEqual(3); + + const paragraph1 = document.children[1]; + expect(paragraph1.getText()).toEqual(`\ufffcThis is a test.\ufffcThis is a new verse.\n`); + expect(paragraph1.range).toEqual({ start: { line: 1, character: 0 }, end: { line: 2, character: 0 } }); + + const paragraph2 = document.children[2]; + expect(paragraph2.getText()).toEqual(`\ufffcThis is a test.\n`); + expect(paragraph2.range).toEqual({ start: { line: 2, character: 0 }, end: { line: 3, character: 0 } }); + }); + + it('update multiple paragraphs', () => { + const document = new ScriptureDeltaDocument( + 'uri', + 1, + new Delta() + .insert({ chapter: { number: '1', style: 'c' } }) + .insert({ verse: { number: '1', style: 'v' } }) + .insert('This is a test.', { segment: 'verse_1_1' }) + .insert('\n', { para: { style: 'p' } }) + .insert({ verse: { number: '2', style: 'v' } }) + .insert('This is a test.', { segment: 'verse_1_2' }) + .insert('\n', { para: { style: 'p' } }), + ); + + document.update( + new Delta() + .retain(10) + .delete(17) + .insert('verse one.', { segment: 'verse_1_1' }) + .insert('\n', { para: { style: 'p' } }) + .insert({ verse: { number: '2', style: 'v' } }) + .insert('This is not ', { segment: 'verse_1_2' }).ops, + 2, + ); + + expect(document.children.length).toEqual(3); + + const paragraph1 = document.children[1]; + expect(paragraph1.getText()).toEqual(`\ufffcThis is verse one.\n`); + expect(paragraph1.range).toEqual({ start: { line: 1, character: 0 }, end: { line: 2, character: 0 } }); + + const paragraph2 = document.children[2]; + expect(paragraph2.getText()).toEqual(`\ufffcThis is not a test.\n`); + expect(paragraph2.range).toEqual({ start: { line: 2, character: 0 }, end: { line: 3, character: 0 } }); + }); + + it('update no paragraph', () => { + const document = new ScriptureDeltaDocument( + 'uri', + 1, + new Delta() + .insert({ chapter: { number: '1', style: 'c' } }) + .insert({ verse: { number: '1', style: 'v' } }) + .insert('This is verse 1.', { segment: 'verse_1_1' }) + .insert({ verse: { number: '2', style: 'v' } }) + .insert({ blank: true }, { segment: 'verse_1_2' }) + .insert({ verse: { number: '3', style: 'v' } }) + .insert('This is verse 3.', { segment: 'verse_1_3' }) + .insert('\n'), + ); + + document.update(new Delta().retain(16).delete(1).insert('one').ops, 2); + + expect(document.children.length).toEqual(2); + expect(document.children[1]).toBeInstanceOf(ScriptureParagraph); + const paragraph1 = document.children[1] as ScriptureParagraph; + expect(paragraph1.style).toEqual('p'); + expect(paragraph1.getText()).toEqual(`\ufffcThis is verse one.\ufffc\ufffc\ufffcThis is verse 3.\n`); + expect(paragraph1.range).toEqual({ start: { line: 1, character: 0 }, end: { line: 2, character: 0 } }); + }); +}); diff --git a/packages/delta/src/scripture-delta-document.ts b/packages/delta/src/scripture-delta-document.ts new file mode 100644 index 0000000..2cb9eb4 --- /dev/null +++ b/packages/delta/src/scripture-delta-document.ts @@ -0,0 +1,570 @@ +import { + findScriptureNodes, + Position, + Range, + ScriptureCell, + ScriptureChapter, + ScriptureCharacterStyle, + ScriptureDocument, + ScriptureMilestone, + ScriptureNode, + ScriptureNodeType, + ScriptureNote, + ScriptureOptBreak, + ScriptureParagraph, + ScriptureRef, + ScriptureRow, + ScriptureTable, + ScriptureText, + ScriptureVerse, +} from '@sillsdev/lynx'; +import isEqual from 'lodash.isequal'; +import Delta, { Op } from 'quill-delta'; + +import { DeltaDocument, getChangeOffsetRange } from './delta-document'; + +export class ScriptureDeltaDocument extends DeltaDocument implements ScriptureDocument { + private readonly _children: ScriptureNode[] = []; + private lineChildren: number[] | undefined = undefined; + readonly type = ScriptureNodeType.Document; + readonly document = this; + readonly parent = undefined; + readonly isLeaf = false; + range: Range = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }; + + constructor(uri: string, version: number, content: Delta) { + super(uri, 'scripture-delta', version, content); + this.lineOffsets = [0]; + this.lineOps = [0]; + this.lineChildren = [0]; + for (const child of processDelta(content, this.lineOffsets, this.lineChildren, this.lineOps)) { + this.appendChild(child); + } + } + + get children(): readonly ScriptureNode[] { + return this._children; + } + + updateParent(_parent: ScriptureNode | undefined): void { + throw new Error('The method is not supported.'); + } + + remove(): void { + throw new Error('The method is not supported.'); + } + + findNodes( + filter?: ScriptureNodeType | ((node: ScriptureNode) => boolean) | ScriptureNodeType[], + ): IterableIterator { + return findScriptureNodes(this, filter); + } + + appendChild(child: ScriptureNode): void { + this._children.push(child); + child.updateParent(this); + } + + insertChild(index: number, child: ScriptureNode): void { + this._children.splice(index, 0, child); + child.updateParent(this); + } + + removeChild(child: ScriptureNode): void { + if (child.parent !== this) { + throw new Error('This node does not contain the specified child.'); + } + const index = this._children.indexOf(child); + if (index === -1) { + throw new Error('This node does not contain the specified child.'); + } + this._children.splice(index, 1); + child.updateParent(undefined); + } + + spliceChildren(start: number, deleteCount: number, ...items: ScriptureNode[]): void { + const removed = this._children.splice(start, deleteCount, ...items); + for (const child of removed) { + child.updateParent(undefined); + } + for (const child of items) { + child.updateParent(this); + } + } + + clearChildren(): void { + this._children.length = 0; + } + + update(changes: readonly Op[], version: number): void { + const change = new Delta(changes as Op[]); + const [changeStartOffset, changeEndOffset, insertLength] = getChangeOffsetRange(change); + const changeStart = this.positionAt(changeStartOffset); + const changeEnd = this.positionAt(changeEndOffset); + const changeStartLine = changeStart.line; + const changeEndLine = changeEnd.line; + + let lineChildren = this.lineChildren!; + const childStartIndex = lineChildren[changeStartLine]; + const childEndIndex = lineChildren[changeEndLine]; + const childStart = this.children[childStartIndex]; + const childEnd = this.children[childEndIndex]; + const childStartLine = childStart.range.start.line; + const childEndLine = childEnd.range.end.line; + + const updated = this._content.compose(change); + const opDiff = updated.ops.length - this._content.ops.length; + this._content = updated; + + let lineOps = this.lineOps!; + const opStartIndex = lineOps[childStartLine]; + const opEndIndex = lineOps[childEndLine]; + const subDelta = new Delta(updated.ops.slice(opStartIndex, opEndIndex + opDiff)); + const addedLineOffsets: number[] = []; + const addedLineChildren: number[] = []; + const addedLineOps: number[] = []; + const children = processDelta( + subDelta, + addedLineOffsets, + addedLineChildren, + addedLineOps, + childStart.range.start, + this.offsetAt(childStart.range.start), + childStartIndex, + opStartIndex, + ); + + // update nodes + this.spliceChildren(childStartIndex, childEndIndex - childStartIndex + 1, ...children); + + // update line indexes + let lineOffsets = this.lineOffsets!; + const addedLineLength = addedLineOffsets.length; + const deletedLineLength = childEndLine - childStartLine; + const charDiff = insertLength - (changeEndOffset - changeStartOffset); + const lineDiff = addedLineLength - deletedLineLength; + const childDiff = children.length - 1; + if (lineDiff === 0) { + for (let i = 0; i < deletedLineLength; i++) { + lineOffsets[i + childStartLine + 1] = addedLineOffsets[i]; + lineOps[i + childStartLine + 1] = addedLineOps[i]; + lineChildren[i + childStartLine + 1] = addedLineChildren[i]; + } + } else { + if (addedLineLength < 10000) { + lineOffsets.splice(childStartLine + 1, deletedLineLength, ...addedLineOffsets); + lineOps.splice(childStartLine + 1, deletedLineLength, ...addedLineOps); + lineChildren.splice(childStartLine + 1, deletedLineLength, ...addedLineChildren); + } else { + // avoid too many arguments for splice + this.lineOffsets = lineOffsets = lineOffsets + .slice(0, childStartLine) + .concat(addedLineOffsets, lineOffsets.slice(childEndLine)); + this.lineOps = lineOps = lineOps.slice(0, childStartLine).concat(addedLineOps, lineOps.slice(childEndLine)); + this.lineChildren = lineChildren = lineChildren + .slice(0, childStartLine) + .concat(addedLineChildren, lineChildren.slice(childEndLine)); + } + + for (let i = childEndIndex + childDiff + 1; i < this.children.length; i++) { + updateNodeLine(this.children[i], lineDiff); + } + } + + if (charDiff !== 0 || childDiff !== 0 || opDiff !== 0) { + const newLineLength = lineOffsets.length; + for (let i = childStartLine + 1 + addedLineLength; i < newLineLength; i++) { + if (charDiff !== 0) { + lineOffsets[i] += charDiff; + } + if (opDiff !== 0) { + lineOps[i] += opDiff; + } + if (childDiff !== 0) { + lineChildren[i] += childDiff; + } + } + } + + this.version = version; + } +} + +function processDelta( + delta: Delta, + lineOffsets: number[] = [], + lineChildren: number[] = [], + lineOps: number[] = [], + startPosition: Position = { line: 0, character: 0 }, + startOffset = 0, + startNodeIndex = 0, + startOpIndex = 0, +): ScriptureNode[] { + const content: ScriptureNode[] = []; + let curCharAttrs: Record[] = []; + const childNodes: ScriptureNode[][] = []; + childNodes.push([]); + let curTableAttrs: Record | undefined = undefined; + let curRowAttrs: Record | undefined = undefined; + let line = startPosition.line; + let character = startPosition.character; + let offset = startOffset; + let i = startOpIndex; + let paraStartPosition = startPosition; + for (const op of delta.ops) { + if (op.insert == null) { + throw new Error('The delta is not a document.'); + } + + const attrs = op.attributes; + if (curCharAttrs.length > 0 && attrs?.char == null) { + while (curCharAttrs.length > 0) { + charEnded(childNodes, curCharAttrs); + } + } else if (attrs?.char != null) { + const charAttrs = getCharAttributes(attrs.char); + while (curCharAttrs.length > 0 && !charAttributesMatch(curCharAttrs, charAttrs)) { + charEnded(childNodes, curCharAttrs); + } + for (let i = curCharAttrs.length; i < charAttrs.length; i++) { + childNodes.push([]); + } + curCharAttrs = charAttrs; + } + + let length: number; + if (typeof op.insert === 'string') { + const text = op.insert; + length = text.length; + if (curTableAttrs != null && attrs?.table == null && text === '\n') { + const nextBlockNodes = rowEnded(childNodes); + curRowAttrs = undefined; + tableEnded(content, childNodes); + curTableAttrs = undefined; + childNodes[childNodes.length - 1].push(...nextBlockNodes); + } else if (attrs?.table != null) { + const cellAttrs = attrs.cell as Record | undefined; + let cellNode: ScriptureCell; + if (cellAttrs != null) { + const children = childNodes[childNodes.length - 1]; + cellNode = new ScriptureCell( + cellAttrs.style as string, + cellAttrs.align as string, + cellAttrs.colspan as number, + children, + { start: paraStartPosition, end: { line: line + 1, character: 0 } }, + ); + } else { + cellNode = childNodes[childNodes.length - 1][0] as ScriptureCell; + } + childNodes.pop(); + + if (text === '\n') { + line += 1; + character = -1; + lineOffsets.push(offset + length); + lineChildren.push(startNodeIndex + content.length); + lineOps.push(i + 1); + paraStartPosition = { line, character: 0 }; + } + + const tableAttrs = attrs.table as Record; + const rowAttrs = attrs.row as Record; + if (curTableAttrs != null && curRowAttrs != null) { + if (rowAttrs.id !== curRowAttrs.id) { + rowEnded(childNodes); + curRowAttrs = undefined; + } + if (tableAttrs.id !== curTableAttrs.id) { + tableEnded(content, childNodes); + curTableAttrs = undefined; + } + } + + while (childNodes.length < 2) { + childNodes.push([]); + } + childNodes[childNodes.length - 1].push(cellNode); + childNodes.push([]); + + curTableAttrs = tableAttrs; + curRowAttrs = rowAttrs; + } + + if (attrs == null) { + let startIndex = 0; + let implicitParagraph = false; + let lineCount = 0; + while (startIndex < text.length) { + const endIndex = text.indexOf('\n', startIndex); + const inlineText = text.substring(startIndex, endIndex === -1 ? undefined : endIndex); + if (inlineText.length > 0) { + childNodes[childNodes.length - 1].push( + new ScriptureText(inlineText, { + start: { line, character }, + end: { line, character: character + inlineText.length }, + }), + ); + } + if (endIndex === -1) { + break; + } + implicitParagraph = true; + lineCount++; + line++; + character = 0; + lineOffsets.push(offset + endIndex + 1); + lineOps.push(endIndex + 1 === op.insert.length ? i + 1 : i); + startIndex = endIndex + 1; + } + + if (implicitParagraph) { + // implicit paragraph + const children = childNodes[childNodes.length - 1]; + if (children.length > 0) { + content.push( + new ScriptureParagraph('p', undefined, children, { + start: paraStartPosition, + end: { line: line, character: 0 }, + }), + ); + children.length = 0; + } + for (let j = 0; j < lineCount; j++) { + lineChildren.push(startNodeIndex + content.length); + } + paraStartPosition = { line, character: 0 }; + character = -1; + } + } else { + // text blots + for (const [key, value] of Object.entries(attrs)) { + const textBlotAttrs = value as Record; + switch (key) { + case 'para': + // end of a para block + for (let j = 0; j < text.length; j++) { + const children = childNodes[childNodes.length - 1]; + content.push( + new ScriptureParagraph(textBlotAttrs.style as string, getNodeAttributes(textBlotAttrs), children, { + start: paraStartPosition, + end: { line: line + 1, character: 0 }, + }), + ); + lineOffsets.push(offset + j + 1); + lineChildren.push(startNodeIndex + content.length); + lineOps.push(i + 1); + line += 1; + paraStartPosition = { line, character: 0 }; + } + childNodes[childNodes.length - 1].length = 0; + character = -1; + break; + + case 'ref': + childNodes[childNodes.length - 1].push( + new ScriptureRef(textBlotAttrs.display as string, textBlotAttrs.target as string, { + start: { line, character }, + end: { line, character: character + length }, + }), + ); + break; + + case 'char': + if (attrs.ref == null) { + childNodes[childNodes.length - 1].push( + new ScriptureText(text, { start: { line, character }, end: { line, character: character + length } }), + ); + } + break; + + case 'segment': + if (Object.keys(attrs).length === 1) { + childNodes[childNodes.length - 1].push( + new ScriptureText(text, { start: { line, character }, end: { line, character: character + length } }), + ); + } + break; + } + } + } + } else { + // embeds + const obj = op.insert; + length = 1; + for (const [key, value] of Object.entries(obj)) { + const embedAttrs = value as Record; + switch (key) { + case 'chapter': + content.push( + new ScriptureChapter(embedAttrs.number as string, undefined, undefined, undefined, undefined, { + start: { line, character }, + end: { line: line + 1, character: 0 }, + }), + ); + lineOffsets.push(offset + length); + lineChildren.push(startNodeIndex + content.length); + lineOps.push(i + 1); + line += 1; + character = -1; + paraStartPosition = { line, character: 0 }; + break; + + case 'verse': + childNodes[childNodes.length - 1].push( + new ScriptureVerse(embedAttrs.number as string, undefined, undefined, undefined, undefined, { + start: { line, character }, + end: { line, character: character + length }, + }), + ); + break; + + case 'figure': + childNodes[childNodes.length - 1].push( + new ScriptureCharacterStyle( + embedAttrs.style as string, + { file: embedAttrs.file as string, size: embedAttrs.size as string, ref: embedAttrs.ref as string }, + processDelta(new Delta(embedAttrs.content as any)), + { start: { line, character }, end: { line, character: character + length } }, + ), + ); + break; + + case 'note': + childNodes[childNodes.length - 1].push( + new ScriptureNote( + embedAttrs.style as string, + embedAttrs.caller as string, + embedAttrs.category as string | undefined, + processDelta(new Delta(embedAttrs.content as any)), + { start: { line, character }, end: { line, character: character + length } }, + ), + ); + break; + + case 'ms': { + const style = embedAttrs.style as string; + childNodes[childNodes.length - 1].push( + new ScriptureMilestone(style, !style.endsWith('-e'), undefined, undefined, undefined, { + start: { line, character }, + end: { line, character: character + length }, + }), + ); + break; + } + + case 'optbreak': + childNodes[childNodes.length - 1].push( + new ScriptureOptBreak({ start: { line, character }, end: { line, character: character + length } }), + ); + break; + } + } + } + offset += length; + i++; + if (character === -1) { + character = 0; + } else { + character += length; + } + } + while (curCharAttrs.length > 0) { + charEnded(childNodes, curCharAttrs); + } + if (curTableAttrs != null) { + rowEnded(childNodes); + tableEnded(content, childNodes); + } + content.push(...childNodes[childNodes.length - 1]); + return content; +} + +function charEnded(childNodes: ScriptureNode[][], curCharAttrs: Record[]): void { + const charAttrs = curCharAttrs.pop(); + if (charAttrs == null) { + return; + } + const attrs = getNodeAttributes(charAttrs); + const children = childNodes[childNodes.length - 1]; + const charStyleNode = new ScriptureCharacterStyle( + charAttrs.style as string, + attrs, + children, + getContainerRange(children), + ); + childNodes.pop(); + childNodes[childNodes.length - 1].push(charStyleNode); +} + +function getCharAttributes(charAttrs: any): Record[] { + if (Array.isArray(charAttrs)) { + return [...charAttrs]; + } else if (typeof charAttrs === 'object') { + return [charAttrs]; + } else { + return []; + } +} + +function charAttributesMatch(curCharAttrs: Record[], charAttrs: Record[]): boolean { + if (curCharAttrs.length > charAttrs.length) { + return false; + } + + for (let i = 0; i < curCharAttrs.length; i++) { + if (!isEqual(curCharAttrs[i], charAttrs[i])) { + return false; + } + } + return true; +} + +function rowEnded(childNodes: ScriptureNode[][]): ScriptureNode[] { + if (childNodes.length > 3) { + throw new Error('A table is not valid in the current location.'); + } + + let nextBlockNodes: ScriptureNode[] | undefined = undefined; + if (childNodes.length === 3) { + nextBlockNodes = childNodes.pop(); + } + const children = childNodes.pop() ?? []; + const rowNode = new ScriptureRow(children, getContainerRange(children)); + childNodes[childNodes.length - 1].push(rowNode); + return nextBlockNodes ?? []; +} + +function tableEnded(content: ScriptureNode[], childNodes: ScriptureNode[][]): void { + const children = childNodes[childNodes.length - 1]; + content.push(new ScriptureTable(children, getContainerRange(children))); + childNodes[childNodes.length - 1].length = 0; +} + +function getNodeAttributes(attrs: Record): Record { + const nodeAttrs: Record = {}; + for (const [key, value] of Object.entries(attrs)) { + if (typeof value === 'string' && key !== 'id' && key !== 'style' && key !== 'invalid' && key !== 'cid') { + nodeAttrs[key] = value; + } + } + return nodeAttrs; +} + +function getContainerRange(nodes: ScriptureNode[]): Range { + if (nodes.length === 0) { + return { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }; + } + return { + start: nodes[0].range.start, + end: nodes[nodes.length - 1].range.end, + }; +} + +function updateNodeLine(node: ScriptureNode, lineDiff: number): void { + node.range.start.line += lineDiff; + node.range.end.line += lineDiff; + for (const child of node.children) { + updateNodeLine(child, lineDiff); + } +} diff --git a/packages/delta/src/scripture-delta-edit-factory.test.ts b/packages/delta/src/scripture-delta-edit-factory.test.ts new file mode 100644 index 0000000..0ef3d1a --- /dev/null +++ b/packages/delta/src/scripture-delta-edit-factory.test.ts @@ -0,0 +1,88 @@ +import { ScriptureNote, ScriptureParagraph, ScriptureText, ScriptureVerse } from '@sillsdev/lynx'; +import Delta from 'quill-delta'; +import { describe, expect, it } from 'vitest'; + +import { ScriptureDeltaDocument } from './scripture-delta-document'; +import { ScriptureDeltaEditFactory } from './scripture-delta-edit-factory'; + +describe('ScriptureDeltaEditFactory', () => { + it('text', () => { + const factory = new ScriptureDeltaEditFactory(); + const document = new ScriptureDeltaDocument( + 'uri', + 1, + new Delta() + .insert({ chapter: { number: '1', style: 'c' } }) + .insert({ verse: { number: '1', style: 'v' } }) + .insert('This is a test.', { segment: 'verse_1_1' }) + .insert('\n', { para: { style: 'p' } }) + .insert({ verse: { number: '2', style: 'v' } }) + .insert('This is a test.', { segment: 'verse_1_2' }) + .insert('\n', { para: { style: 'p' } }), + ); + + const edit = factory.createScriptureEdit( + document, + { start: { line: 2, character: 1 }, end: { line: 2, character: 16 } }, + new ScriptureText('Hello, world!'), + ); + + expect(edit).toEqual([{ retain: 19 }, { insert: 'Hello, world!' }, { delete: 15 }]); + }); + + it('note', () => { + const factory = new ScriptureDeltaEditFactory(); + const document = new ScriptureDeltaDocument( + 'uri', + 1, + new Delta() + .insert({ chapter: { number: '1', style: 'c' } }) + .insert({ verse: { number: '1', style: 'v' } }) + .insert('This is a test.', { segment: 'verse_1_1' }) + .insert('\n', { para: { style: 'p' } }) + .insert({ verse: { number: '2', style: 'v' } }) + .insert('This is a test.', { segment: 'verse_1_2' }) + .insert('\n', { para: { style: 'p' } }), + ); + + const edit = factory.createScriptureEdit( + document, + { start: { line: 1, character: 16 }, end: { line: 1, character: 16 } }, + new ScriptureNote('f', '+', undefined, [new ScriptureText('Footnote.')]), + ); + + expect(edit).toEqual([ + { retain: 17 }, + { insert: { note: { style: 'f', caller: '+', contents: { ops: [{ insert: 'Footnote.' }] } } } }, + ]); + }); + + it('paragraph', () => { + const factory = new ScriptureDeltaEditFactory(); + const document = new ScriptureDeltaDocument( + 'uri', + 1, + new Delta() + .insert({ chapter: { number: '1', style: 'c' } }) + .insert({ verse: { number: '1', style: 'v' } }) + .insert('This is a test.', { segment: 'verse_1_1' }) + .insert('\n', { para: { style: 'p' } }) + .insert({ verse: { number: '2', style: 'v' } }) + .insert('This is a test.', { segment: 'verse_1_2' }) + .insert('\n', { para: { style: 'p' } }), + ); + + const edit = factory.createScriptureEdit( + document, + { start: { line: 3, character: 0 }, end: { line: 3, character: 0 } }, + new ScriptureParagraph('p', undefined, [new ScriptureVerse('3'), new ScriptureText('This is verse three.')]), + ); + + expect(edit).toEqual([ + { retain: 35 }, + { insert: { verse: { number: '3', style: 'v' } } }, + { insert: 'This is verse three.' }, + { insert: '\n', attributes: { para: { style: 'p' } } }, + ]); + }); +}); diff --git a/packages/delta/src/scripture-delta-edit-factory.ts b/packages/delta/src/scripture-delta-edit-factory.ts new file mode 100644 index 0000000..999face --- /dev/null +++ b/packages/delta/src/scripture-delta-edit-factory.ts @@ -0,0 +1,135 @@ +import { + Range, + ScriptureChapter, + ScriptureCharacterStyle, + ScriptureEditFactory, + ScriptureMilestone, + ScriptureNode, + ScriptureNodeType, + ScriptureNote, + ScriptureParagraph, + ScriptureRef, + ScriptureVerse, +} from '@sillsdev/lynx'; +import cloneDeep from 'lodash.clonedeep'; +import Delta, { Op } from 'quill-delta'; +import { v4 as uuidv4 } from 'uuid'; + +import { DeltaEditFactory } from './delta-edit-factory'; +import { ScriptureDeltaDocument } from './scripture-delta-document'; + +export class ScriptureDeltaEditFactory + extends DeltaEditFactory + implements ScriptureEditFactory +{ + createScriptureEdit(document: ScriptureDeltaDocument, range: Range, nodes: ScriptureNode[] | ScriptureNode): Op[] { + const startOffset = document.offsetAt(range.start); + const endOffset = document.offsetAt(range.end); + const delta = new Delta(); + if (startOffset > 0) { + delta.retain(startOffset); + } + if (endOffset - startOffset > 0) { + delta.delete(endOffset - startOffset); + } + if (Array.isArray(nodes)) { + for (const node of nodes) { + serializeNode(delta, node); + } + } else { + serializeNode(delta, nodes); + } + return delta.ops; + } +} + +function serializeNode(delta: Delta, node: ScriptureNode, attributes?: Record): void { + switch (node.type) { + case ScriptureNodeType.Text: { + delta.insert(node.getText(), attributes); + break; + } + + case ScriptureNodeType.Verse: { + const verse = node as ScriptureVerse; + delta.insert({ verse: { number: verse.number, style: 'v' } }); + break; + } + + case ScriptureNodeType.Chapter: { + const chapter = node as ScriptureChapter; + delta.insert({ chapter: { number: chapter.number, style: 'c' } }); + break; + } + + case ScriptureNodeType.Paragraph: { + const para = node as ScriptureParagraph; + serializeChildNodes(delta, para, attributes); + delta.insert('\n', { para: { style: para.style } }); + break; + } + + case ScriptureNodeType.CharacterStyle: { + const style = node as ScriptureCharacterStyle; + const newChildAttributes = attributes != null ? cloneDeep(attributes) : {}; + const existingCharAttributes = newChildAttributes.char; + const newCharAttributes = cloneDeep(style.attributes); + if (!('cid' in newCharAttributes)) { + newCharAttributes.cid = uuidv4(); + } + if (existingCharAttributes == null) { + newChildAttributes.char = newCharAttributes; + } else if (typeof existingCharAttributes === 'object') { + newChildAttributes.char = [existingCharAttributes, newCharAttributes]; + } else if (Array.isArray(existingCharAttributes)) { + existingCharAttributes.push(newCharAttributes); + } + serializeChildNodes(delta, style, newChildAttributes); + break; + } + + case ScriptureNodeType.Note: { + const note = node as ScriptureNote; + const noteAttributes: any = { style: note.style, caller: note.caller }; + if (note.category != null) { + noteAttributes.category = note.category; + } + const contents = new Delta(); + serializeChildNodes(contents, note); + if (contents.ops.length > 0) { + noteAttributes.contents = { ops: contents.ops }; + } + delta.insert({ note: noteAttributes }, attributes); + break; + } + + case ScriptureNodeType.Ref: { + const ref = node as ScriptureRef; + const newRefAttributes = attributes != null ? cloneDeep(attributes) : {}; + newRefAttributes.ref = { loc: ref.target }; + delta.insert(ref.display, newRefAttributes); + break; + } + + case ScriptureNodeType.Milestone: { + const milestone = node as ScriptureMilestone; + delta.insert({ ms: { style: milestone.style } }, attributes); + break; + } + + case ScriptureNodeType.OptBreak: { + delta.insert({ optbreak: {} }, attributes); + break; + } + + default: { + throw new Error(`Unsupported node type: ${node.type.toString()}.`); + } + } +} + +function serializeChildNodes(delta: Delta, node: ScriptureNode, attributes?: Record): void { + for (const child of node.children) { + serializeNode(delta, child, attributes); + } +} diff --git a/packages/delta/tsconfig.json b/packages/delta/tsconfig.json new file mode 100644 index 0000000..25c68df --- /dev/null +++ b/packages/delta/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@repo/typescript-config/base.json", + + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + } +} diff --git a/packages/delta/tsup.config.js b/packages/delta/tsup.config.js new file mode 100644 index 0000000..156f84e --- /dev/null +++ b/packages/delta/tsup.config.js @@ -0,0 +1,4 @@ +import library from '@repo/tsup-config/library.js'; +import { defineConfig } from 'tsup'; + +export default defineConfig({ ...library() }); diff --git a/packages/examples/src/simple-quote-formatting-provider.ts b/packages/examples/src/simple-quote-formatting-provider.ts index 437ed9f..93562a3 100644 --- a/packages/examples/src/simple-quote-formatting-provider.ts +++ b/packages/examples/src/simple-quote-formatting-provider.ts @@ -1,30 +1,43 @@ -import { DocumentManager, OnTypeFormattingProvider, Position, TextDocument, TextEdit } from '@sillsdev/lynx'; +import { Document, DocumentAccessor, EditFactory, OnTypeFormattingProvider, Position, TextEdit } from '@sillsdev/lynx'; -export class SimpleQuoteFormattingProvider implements OnTypeFormattingProvider { +export class SimpleQuoteFormattingProvider implements OnTypeFormattingProvider { readonly id = 'simple-quote'; readonly onTypeTriggerCharacters: ReadonlySet = new Set(['"', '“', '”']); - constructor(private readonly documentManager: DocumentManager) {} + constructor( + private readonly documents: DocumentAccessor, + private readonly editFactory: EditFactory, + ) {} init(): Promise { return Promise.resolve(); } - async getOnTypeEdits(uri: string, _position: Position, _ch: string): Promise { - const doc = await this.documentManager.get(uri); + async getOnTypeEdits(uri: string, _position: Position, _ch: string): Promise { + const doc = await this.documents.get(uri); if (doc == null) { return undefined; } - const edits: TextEdit[] = []; + const edits: T[] = []; const text = doc.getText(); for (const match of text.matchAll(/["“”]/g)) { if ((match.index === 0 || text[match.index - 1].trim() === '') && match[0] !== '“') { - const pos = doc.positionAt(match.index); - edits.push({ range: { start: pos, end: { line: pos.line, character: pos.character + 1 } }, newText: '“' }); + edits.push( + ...this.editFactory.createTextEdit( + doc, + { start: doc.positionAt(match.index), end: doc.positionAt(match.index + 1) }, + '“', + ), + ); } else if ((match.index === text.length - 1 || text[match.index + 1].trim() === '') && match[0] !== '”') { - const pos = doc.positionAt(match.index); - edits.push({ range: { start: pos, end: { line: pos.line, character: pos.character + 1 } }, newText: '”' }); + edits.push( + ...this.editFactory.createTextEdit( + doc, + { start: doc.positionAt(match.index), end: doc.positionAt(match.index + 1) }, + '”', + ), + ); } } diff --git a/packages/examples/src/verse-order-diagnostic-provider.test.ts b/packages/examples/src/verse-order-diagnostic-provider.test.ts index 175d686..a5c5125 100644 --- a/packages/examples/src/verse-order-diagnostic-provider.test.ts +++ b/packages/examples/src/verse-order-diagnostic-provider.test.ts @@ -4,9 +4,11 @@ import { Localizer, ScriptureChapter, ScriptureDocument, + ScriptureEditFactory, + ScriptureNode, ScriptureParagraph, - ScriptureSerializer, ScriptureText, + ScriptureTextDocument, ScriptureVerse, } from '@sillsdev/lynx'; import { describe, expect, it } from 'vitest'; @@ -18,28 +20,26 @@ describe('VerseOrderDiagnosticProvider', () => { it('out of order', async () => { const env = new TestEnvironment(); await env.init(); - env.docManager.add( - new ScriptureDocument('file1', 1, '', [ - new ScriptureChapter('1'), - new ScriptureParagraph('p', [ - new ScriptureVerse('1', { - start: { line: 2, character: 0 }, - end: { line: 2, character: 4 }, - }), - new ScriptureText('Chapter one, verse one.'), - new ScriptureVerse('3', { - start: { line: 3, character: 0 }, - end: { line: 3, character: 4 }, - }), - new ScriptureText('Chapter one, verse three.'), - new ScriptureVerse('2', { - start: { line: 4, character: 0 }, - end: { line: 4, character: 4 }, - }), - new ScriptureText('Chapter one, verse two.'), - ]), + env.addDocument('file1', [ + new ScriptureChapter('1'), + new ScriptureParagraph('p', undefined, [ + new ScriptureVerse('1', { + start: { line: 2, character: 0 }, + end: { line: 2, character: 4 }, + }), + new ScriptureText('Chapter one, verse one.'), + new ScriptureVerse('3', { + start: { line: 3, character: 0 }, + end: { line: 3, character: 4 }, + }), + new ScriptureText('Chapter one, verse three.'), + new ScriptureVerse('2', { + start: { line: 4, character: 0 }, + end: { line: 4, character: 4 }, + }), + new ScriptureText('Chapter one, verse two.'), ]), - ); + ]); const diagnostics = await env.provider.getDiagnostics('file1'); @@ -51,23 +51,21 @@ describe('VerseOrderDiagnosticProvider', () => { it('missing verse', async () => { const env = new TestEnvironment(); await env.init(); - env.docManager.add( - new ScriptureDocument('file1', 1, '', [ - new ScriptureChapter('1'), - new ScriptureParagraph('p', [ - new ScriptureVerse('1', { - start: { line: 2, character: 0 }, - end: { line: 2, character: 4 }, - }), - new ScriptureText('Chapter one, verse one.'), - new ScriptureVerse('3', { - start: { line: 3, character: 0 }, - end: { line: 3, character: 4 }, - }), - new ScriptureText('Chapter one, verse three.'), - ]), + env.addDocument('file1', [ + new ScriptureChapter('1'), + new ScriptureParagraph('p', undefined, [ + new ScriptureVerse('1', { + start: { line: 2, character: 0 }, + end: { line: 2, character: 4 }, + }), + new ScriptureText('Chapter one, verse one.'), + new ScriptureVerse('3', { + start: { line: 3, character: 0 }, + end: { line: 3, character: 4 }, + }), + new ScriptureText('Chapter one, verse three.'), ]), - ); + ]); const diagnostics = await env.provider.getDiagnostics('file1'); @@ -79,17 +77,23 @@ describe('VerseOrderDiagnosticProvider', () => { class TestEnvironment { private readonly localizer: Localizer; + private readonly editFactory: ScriptureEditFactory; readonly docManager: DocumentManager; readonly provider: VerseOrderDiagnosticProvider; constructor() { this.localizer = new Localizer(); + this.editFactory = mock(); this.docManager = new DocumentManager(mock>()); - this.provider = new VerseOrderDiagnosticProvider(this.localizer, this.docManager, mock()); + this.provider = new VerseOrderDiagnosticProvider(this.localizer, this.docManager, this.editFactory); } async init(): Promise { await this.provider.init(); await this.localizer.init(); } + + addDocument(uri: string, nodes: ScriptureNode[]): void { + this.docManager.add(new ScriptureTextDocument(uri, 'text', 1, '', nodes)); + } } diff --git a/packages/examples/src/verse-order-diagnostic-provider.ts b/packages/examples/src/verse-order-diagnostic-provider.ts index 28e518c..e047e73 100644 --- a/packages/examples/src/verse-order-diagnostic-provider.ts +++ b/packages/examples/src/verse-order-diagnostic-provider.ts @@ -4,43 +4,44 @@ import { DiagnosticProvider, DiagnosticsChanged, DiagnosticSeverity, - DocumentManager, + DocumentAccessor, Localizer, ScriptureChapter, ScriptureDocument, + ScriptureEditFactory, ScriptureNodeType, - ScriptureSerializer, ScriptureVerse, + TextEdit, } from '@sillsdev/lynx'; import { map, merge, Observable, switchMap } from 'rxjs'; -export class VerseOrderDiagnosticProvider implements DiagnosticProvider { +export class VerseOrderDiagnosticProvider implements DiagnosticProvider { public readonly id = 'verse-order'; public readonly diagnosticsChanged$: Observable; constructor( private readonly localizer: Localizer, - private readonly documentManager: DocumentManager, - private readonly serializer: ScriptureSerializer, + private readonly documents: DocumentAccessor, + private readonly editFactory: ScriptureEditFactory, ) { this.diagnosticsChanged$ = merge( - documentManager.opened$.pipe( + documents.opened$.pipe( map((e) => ({ uri: e.document.uri, version: e.document.version, diagnostics: this.validateDocument(e.document), })), ), - documentManager.changed$.pipe( + documents.changed$.pipe( map((e) => ({ uri: e.document.uri, version: e.document.version, diagnostics: this.validateDocument(e.document), })), ), - documentManager.closed$.pipe( + documents.closed$.pipe( switchMap(async (e) => { - const doc = await this.documentManager.get(e.uri); + const doc = await this.documents.get(e.uri); return { uri: e.uri, version: doc?.version, diagnostics: [] }; }), ), @@ -56,30 +57,33 @@ export class VerseOrderDiagnosticProvider implements DiagnosticProvider { } async getDiagnostics(uri: string): Promise { - const doc = await this.documentManager.get(uri); + const doc = await this.documents.get(uri); if (doc == null) { return []; } return this.validateDocument(doc); } - getDiagnosticFixes(_uri: string, diagnostic: Diagnostic): Promise { - const fixes: DiagnosticFix[] = []; + async getDiagnosticFixes(uri: string, diagnostic: Diagnostic): Promise[]> { + const doc = await this.documents.get(uri); + if (doc == null) { + return []; + } + const fixes: DiagnosticFix[] = []; if (diagnostic.code === 2) { const verseNumber = diagnostic.data as number; fixes.push({ title: this.localizer.t('missingVerse.fixTitle', { ns: 'verseOrder' }), isPreferred: true, diagnostic, - edits: [ - { - range: { start: diagnostic.range.start, end: diagnostic.range.start }, - newText: this.serializer.serialize(new ScriptureVerse(verseNumber.toString())), - }, - ], + edits: this.editFactory.createScriptureEdit( + doc, + { start: diagnostic.range.start, end: diagnostic.range.start }, + new ScriptureVerse(verseNumber.toString()), + ), }); } - return Promise.resolve(fixes); + return fixes; } private validateDocument(doc: ScriptureDocument): Diagnostic[] { diff --git a/packages/usfm/src/index.ts b/packages/usfm/src/index.ts index eb04c16..dc73997 100644 --- a/packages/usfm/src/index.ts +++ b/packages/usfm/src/index.ts @@ -1,3 +1,4 @@ export { UsfmDocument } from './usfm-document'; export { UsfmDocumentFactory } from './usfm-document-factory'; +export { UsfmEditFactory } from './usfm-edit-factory'; export { UsfmScriptureSerializer } from './usfm-scripture-serializer'; diff --git a/packages/usfm/src/usfm-document-factory.ts b/packages/usfm/src/usfm-document-factory.ts index 5526a00..c9fc073 100644 --- a/packages/usfm/src/usfm-document-factory.ts +++ b/packages/usfm/src/usfm-document-factory.ts @@ -1,23 +1,20 @@ -import { DocumentChange, DocumentFactory, ScriptureDocument } from '@sillsdev/lynx'; +import { DocumentFactory, TextDocumentChange } from '@sillsdev/lynx'; import { UsfmStylesheet } from '@sillsdev/machine/corpora'; import { UsfmDocument } from './usfm-document'; -export class UsfmDocumentFactory implements DocumentFactory { +export class UsfmDocumentFactory implements DocumentFactory { constructor(private readonly styleSheet: UsfmStylesheet) {} - create(uri: string, format: string, version: number, content: string): ScriptureDocument { + create(uri: string, format: string, version: number, content: string): UsfmDocument { if (format !== 'usfm') { throw new Error(`This factory does not support the format '${format}'.`); } return new UsfmDocument(uri, version, content, this.styleSheet); } - update(document: ScriptureDocument, changes: readonly DocumentChange[], version: number): ScriptureDocument { - if (document instanceof UsfmDocument) { - document.update(changes, version); - return document; - } - throw new Error('The document must be created by this factory.'); + update(document: UsfmDocument, changes: readonly TextDocumentChange[], version: number): UsfmDocument { + document.update(changes, version); + return document; } } diff --git a/packages/usfm/src/usfm-document.ts b/packages/usfm/src/usfm-document.ts index 2e1bf06..9484fda 100644 --- a/packages/usfm/src/usfm-document.ts +++ b/packages/usfm/src/usfm-document.ts @@ -1,12 +1,10 @@ import { - DocumentChange, Position, Range, ScriptureBook, ScriptureCell, ScriptureChapter, ScriptureCharacterStyle, - ScriptureDocument, ScriptureMilestone, ScriptureNode, ScriptureNote, @@ -17,7 +15,9 @@ import { ScriptureSidebar, ScriptureTable, ScriptureText, + ScriptureTextDocument, ScriptureVerse, + TextDocumentChange, } from '@sillsdev/lynx'; import { UsfmAttribute, @@ -29,7 +29,7 @@ import { UsfmTokenType, } from '@sillsdev/machine/corpora'; -export class UsfmDocument extends ScriptureDocument { +export class UsfmDocument extends ScriptureTextDocument { private lineChildren: number[] = []; constructor( @@ -39,11 +39,11 @@ export class UsfmDocument extends ScriptureDocument { private readonly stylesheet: UsfmStylesheet, start: Position = { line: 0, character: 0 }, ) { - super(uri, version, content); + super(uri, 'usfm', version, content); this.parseUsfm(content, start); } - update(changes: readonly DocumentChange[], version: number): void { + update(changes: readonly TextDocumentChange[], version: number): void { for (const change of changes) { if (change.range == null) { this.parseUsfm(change.text); diff --git a/packages/usfm/src/usfm-edit-factory.ts b/packages/usfm/src/usfm-edit-factory.ts new file mode 100644 index 0000000..d492668 --- /dev/null +++ b/packages/usfm/src/usfm-edit-factory.ts @@ -0,0 +1,18 @@ +import { Range, ScriptureNode, ScriptureTextEditFactory, TextEdit } from '@sillsdev/lynx'; +import { UsfmStylesheet } from '@sillsdev/machine'; + +import { UsfmDocument } from './usfm-document'; +import { UsfmScriptureSerializer } from './usfm-scripture-serializer'; + +export class UsfmEditFactory extends ScriptureTextEditFactory { + private readonly serializer: UsfmScriptureSerializer; + + constructor(stylesheet: UsfmStylesheet) { + super(); + this.serializer = new UsfmScriptureSerializer(stylesheet); + } + + createScriptureEdit(document: UsfmDocument, range: Range, nodes: ScriptureNode[] | ScriptureNode): TextEdit[] { + return this.createTextEdit(document, range, this.serializer.serialize(nodes)); + } +} diff --git a/packages/usfm/src/usfm-scripture-serializer.test.ts b/packages/usfm/src/usfm-scripture-serializer.test.ts index 92bf20e..539ac78 100644 --- a/packages/usfm/src/usfm-scripture-serializer.test.ts +++ b/packages/usfm/src/usfm-scripture-serializer.test.ts @@ -57,8 +57,8 @@ describe('UsfmScriptureSerializer', () => { function serialize(usfm: string): string { const stylesheet = new UsfmStylesheet('usfm.sty'); - const document = new UsfmDocument('uri', 1, usfm, stylesheet); const serializer = new UsfmScriptureSerializer(stylesheet); + const document = new UsfmDocument('uri', 1, usfm, stylesheet, serializer); return normalize(serializer.serialize(document)); } diff --git a/packages/usfm/src/usfm-scripture-serializer.ts b/packages/usfm/src/usfm-scripture-serializer.ts index 07d064e..107a530 100644 --- a/packages/usfm/src/usfm-scripture-serializer.ts +++ b/packages/usfm/src/usfm-scripture-serializer.ts @@ -3,23 +3,19 @@ import { ScriptureCell, ScriptureChapter, ScriptureCharacterStyle, - ScriptureDocument, ScriptureMilestone, ScriptureNode, + ScriptureNodeType, ScriptureNote, - ScriptureOptBreak, ScriptureParagraph, ScriptureRef, - ScriptureRow, - ScriptureSerializer, ScriptureSidebar, - ScriptureTable, ScriptureText, ScriptureVerse, } from '@sillsdev/lynx'; import { UsfmAttribute, UsfmStylesheet, UsfmToken, UsfmTokenizer, UsfmTokenType } from '@sillsdev/machine/corpora'; -export class UsfmScriptureSerializer implements ScriptureSerializer { +export class UsfmScriptureSerializer { private readonly tokenizer: UsfmTokenizer; constructor(private readonly stylesheet: UsfmStylesheet) { @@ -28,7 +24,7 @@ export class UsfmScriptureSerializer implements ScriptureSerializer { serialize(nodes: ScriptureNode[] | ScriptureNode): string { const tokens = this.toTokens(nodes, false, false); - return this.tokenizer.detokenize(tokens, false, nodes instanceof ScriptureDocument); + return this.tokenizer.detokenize(tokens, false, !Array.isArray(nodes) && nodes.type === ScriptureNodeType.Document); } private *toTokens( @@ -40,114 +36,168 @@ export class UsfmScriptureSerializer implements ScriptureSerializer { for (let i = 0; i < node.length; i++) { yield* this.toTokens(node[i] as ScriptureNode, nested, endOfParagraph && i === node.length - 1); } - } else if (node instanceof ScriptureDocument) { - yield* this.toTokens(node.children, nested, false); - } else if (node instanceof ScriptureText) { - let text = node.text; - if (endOfParagraph) { - text += ' '; - } - yield new UsfmToken(UsfmTokenType.Text, undefined, text); - } else if (node instanceof ScriptureBook) { - yield new UsfmToken(UsfmTokenType.Book, node.style, undefined, undefined, node.code); - yield* this.toTokens(node.children, nested, false); - } else if (node instanceof ScriptureChapter) { - yield new UsfmToken(UsfmTokenType.Chapter, node.style, undefined, undefined, node.number); - if (node.altNumber != null) { - yield new UsfmToken(UsfmTokenType.Character, 'ca', undefined, 'ca*'); - yield new UsfmToken(UsfmTokenType.Text, undefined, node.altNumber); - yield new UsfmToken(UsfmTokenType.End, 'ca*'); - } - if (node.pubNumber != null) { - yield new UsfmToken(UsfmTokenType.Paragraph, 'cp'); - yield new UsfmToken(UsfmTokenType.Text, undefined, node.pubNumber); - } - } else if (node instanceof ScriptureParagraph) { - yield new UsfmToken(UsfmTokenType.Paragraph, node.style, undefined, node.style + '*'); - yield* this.toTokens(node.children, nested, true); - } else if (node instanceof ScriptureVerse) { - yield new UsfmToken(UsfmTokenType.Verse, node.style, undefined, undefined, node.number); - if (node.altNumber != null) { - yield new UsfmToken(UsfmTokenType.Character, 'va', undefined, 'va*'); - yield new UsfmToken(UsfmTokenType.Text, undefined, node.altNumber); - yield new UsfmToken(UsfmTokenType.End, 'va*'); - } - if (node.pubNumber != null) { - yield new UsfmToken(UsfmTokenType.Character, 'vp', undefined, 'vp*'); - yield new UsfmToken(UsfmTokenType.Text, undefined, node.pubNumber); - yield new UsfmToken(UsfmTokenType.End, 'vp*'); - } - } else if (node instanceof ScriptureCharacterStyle) { - let marker = node.style; - if (nested) { - marker = '+' + marker; - } - const token = new UsfmToken(UsfmTokenType.Character, marker, undefined, marker + '*'); - const attributes: UsfmAttribute[] = []; - for (const key in node.attributes) { - const value = node.attributes[key]; - attributes.push(new UsfmAttribute(key, value)); - } - if (attributes.length > 0) { - const attrToken = new UsfmToken(UsfmTokenType.Attribute, marker); - const tag = this.stylesheet.getTag(node.style); - token.setAttributes(attributes, tag.defaultAttributeName); - yield attrToken; - } - yield token; - yield* this.toTokens(node.children, true, endOfParagraph); - if (attributes.length > 0) { - const attrToken = new UsfmToken(UsfmTokenType.Attribute, marker); - attrToken.copyAttributes(token); - yield attrToken; - } - yield new UsfmToken(UsfmTokenType.End, marker + '*'); - } else if (node instanceof ScriptureMilestone) { - let type: UsfmTokenType; - let endMarker: string | undefined; - if (node.isStart) { - type = UsfmTokenType.Milestone; - const tag = this.stylesheet.getTag(node.style); - endMarker = tag.endMarker; - } else { - type = UsfmTokenType.MilestoneEnd; - endMarker = undefined; - } - yield new UsfmToken(type, node.style, undefined, endMarker); - } else if (node instanceof ScriptureNote) { - yield new UsfmToken(UsfmTokenType.Note, node.style, undefined, node.style + '*', node.caller); - if (node.category != null) { - yield new UsfmToken(UsfmTokenType.Character, 'cat', undefined, 'cat*'); - yield new UsfmToken(UsfmTokenType.Text, undefined, node.category); - yield new UsfmToken(UsfmTokenType.End, 'cat*'); - } - yield* this.toTokens(node.children, nested, endOfParagraph); - } else if (node instanceof ScriptureRef) { - yield new UsfmToken(UsfmTokenType.Character, 'ref', undefined, 'ref*'); - yield new UsfmToken(UsfmTokenType.Text, undefined, `${node.display}|${node.target}`); - yield new UsfmToken(UsfmTokenType.End, 'ref*'); - } else if (node instanceof ScriptureOptBreak) { - yield new UsfmToken(UsfmTokenType.Text, undefined, '\\'); - } else if (node instanceof ScriptureSidebar) { - yield new UsfmToken(UsfmTokenType.Paragraph, node.style); - if (node.category != null) { - yield new UsfmToken(UsfmTokenType.Character, 'esbc', undefined, 'esbc*'); - yield new UsfmToken(UsfmTokenType.Text, undefined, node.category); - yield new UsfmToken(UsfmTokenType.End, 'esbc*'); - } - yield* this.toTokens(node.children, nested, true); - } else if (node instanceof ScriptureTable) { - yield* this.toTokens(node.children, nested, false); - } else if (node instanceof ScriptureRow) { - yield new UsfmToken(UsfmTokenType.Paragraph, 'tr'); - yield* this.toTokens(node.children, nested, true); - } else if (node instanceof ScriptureCell) { - let marker = node.style; - if (node.colSpan > 0) { - marker += '-' + node.colSpan.toString(); + } else { + node = node as ScriptureNode; + switch (node.type) { + case ScriptureNodeType.Document: + yield* this.toTokens(node.children, nested, false); + break; + + case ScriptureNodeType.Text: { + let text = (node as ScriptureText).text; + if (endOfParagraph) { + text += ' '; + } + yield new UsfmToken(UsfmTokenType.Text, undefined, text); + break; + } + + case ScriptureNodeType.Book: { + const book = node as ScriptureBook; + yield new UsfmToken(UsfmTokenType.Book, book.style, undefined, undefined, book.code); + yield* this.toTokens(node.children, nested, false); + break; + } + + case ScriptureNodeType.Chapter: { + const chapter = node as ScriptureChapter; + yield new UsfmToken(UsfmTokenType.Chapter, chapter.style, undefined, undefined, chapter.number); + if (chapter.altNumber != null) { + yield new UsfmToken(UsfmTokenType.Character, 'ca', undefined, 'ca*'); + yield new UsfmToken(UsfmTokenType.Text, undefined, chapter.altNumber); + yield new UsfmToken(UsfmTokenType.End, 'ca*'); + } + if (chapter.pubNumber != null) { + yield new UsfmToken(UsfmTokenType.Paragraph, 'cp'); + yield new UsfmToken(UsfmTokenType.Text, undefined, chapter.pubNumber); + } + break; + } + + case ScriptureNodeType.Paragraph: { + const paragraph = node as ScriptureParagraph; + yield new UsfmToken(UsfmTokenType.Paragraph, paragraph.style, undefined, paragraph.style + '*'); + yield* this.toTokens(node.children, nested, true); + break; + } + + case ScriptureNodeType.Verse: { + const verse = node as ScriptureVerse; + yield new UsfmToken(UsfmTokenType.Verse, verse.style, undefined, undefined, verse.number); + if (verse.altNumber != null) { + yield new UsfmToken(UsfmTokenType.Character, 'va', undefined, 'va*'); + yield new UsfmToken(UsfmTokenType.Text, undefined, verse.altNumber); + yield new UsfmToken(UsfmTokenType.End, 'va*'); + } + if (verse.pubNumber != null) { + yield new UsfmToken(UsfmTokenType.Character, 'vp', undefined, 'vp*'); + yield new UsfmToken(UsfmTokenType.Text, undefined, verse.pubNumber); + yield new UsfmToken(UsfmTokenType.End, 'vp*'); + } + break; + } + + case ScriptureNodeType.CharacterStyle: { + const charStyle = node as ScriptureCharacterStyle; + let marker = charStyle.style; + if (nested) { + marker = '+' + marker; + } + const token = new UsfmToken(UsfmTokenType.Character, marker, undefined, marker + '*'); + const attributes: UsfmAttribute[] = []; + for (const key in charStyle.attributes) { + const value = charStyle.attributes[key]; + attributes.push(new UsfmAttribute(key, value)); + } + if (attributes.length > 0) { + const attrToken = new UsfmToken(UsfmTokenType.Attribute, marker); + const tag = this.stylesheet.getTag(charStyle.style); + token.setAttributes(attributes, tag.defaultAttributeName); + yield attrToken; + } + yield token; + yield* this.toTokens(node.children, true, endOfParagraph); + if (attributes.length > 0) { + const attrToken = new UsfmToken(UsfmTokenType.Attribute, marker); + attrToken.copyAttributes(token); + yield attrToken; + } + yield new UsfmToken(UsfmTokenType.End, marker + '*'); + break; + } + + case ScriptureNodeType.Milestone: { + const milestone = node as ScriptureMilestone; + let type: UsfmTokenType; + let endMarker: string | undefined; + if (milestone.isStart) { + type = UsfmTokenType.Milestone; + const tag = this.stylesheet.getTag(milestone.style); + endMarker = tag.endMarker; + } else { + type = UsfmTokenType.MilestoneEnd; + endMarker = undefined; + } + yield new UsfmToken(type, milestone.style, undefined, endMarker); + break; + } + + case ScriptureNodeType.Note: { + const note = node as ScriptureNote; + yield new UsfmToken(UsfmTokenType.Note, note.style, undefined, note.style + '*', note.caller); + if (note.category != null) { + yield new UsfmToken(UsfmTokenType.Character, 'cat', undefined, 'cat*'); + yield new UsfmToken(UsfmTokenType.Text, undefined, note.category); + yield new UsfmToken(UsfmTokenType.End, 'cat*'); + } + yield* this.toTokens(node.children, nested, endOfParagraph); + break; + } + + case ScriptureNodeType.Ref: { + const ref = node as ScriptureRef; + yield new UsfmToken(UsfmTokenType.Character, 'ref', undefined, 'ref*'); + yield new UsfmToken(UsfmTokenType.Text, undefined, `${ref.display}|${ref.target}`); + yield new UsfmToken(UsfmTokenType.End, 'ref*'); + break; + } + + case ScriptureNodeType.OptBreak: + yield new UsfmToken(UsfmTokenType.Text, undefined, '\\'); + break; + + case ScriptureNodeType.Sidebar: { + const sidebar = node as ScriptureSidebar; + yield new UsfmToken(UsfmTokenType.Paragraph, sidebar.style); + if (sidebar.category != null) { + yield new UsfmToken(UsfmTokenType.Character, 'esbc', undefined, 'esbc*'); + yield new UsfmToken(UsfmTokenType.Text, undefined, sidebar.category); + yield new UsfmToken(UsfmTokenType.End, 'esbc*'); + } + yield* this.toTokens(node.children, nested, true); + break; + } + + case ScriptureNodeType.Table: + yield* this.toTokens(node.children, nested, false); + break; + + case ScriptureNodeType.Row: + yield new UsfmToken(UsfmTokenType.Paragraph, 'tr'); + yield* this.toTokens(node.children, nested, true); + break; + + case ScriptureNodeType.Cell: { + const cell = node as ScriptureCell; + let marker = cell.style; + if (cell.colSpan > 0) { + marker += '-' + cell.colSpan.toString(); + } + yield new UsfmToken(UsfmTokenType.Character, marker); + yield* this.toTokens(node.children, nested, endOfParagraph); + break; + } } - yield new UsfmToken(UsfmTokenType.Character, marker); - yield* this.toTokens(node.children, nested, endOfParagraph); } } } diff --git a/packages/vscode/src/server.ts b/packages/vscode/src/server.ts index d085c20..425cc8b 100644 --- a/packages/vscode/src/server.ts +++ b/packages/vscode/src/server.ts @@ -1,6 +1,6 @@ import { Diagnostic, DocumentManager, Localizer, ScriptureDocument, Workspace } from '@sillsdev/lynx'; import { SimpleQuoteFormattingProvider, VerseOrderDiagnosticProvider } from '@sillsdev/lynx-examples'; -import { UsfmDocumentFactory, UsfmScriptureSerializer } from '@sillsdev/lynx-usfm'; +import { UsfmDocumentFactory, UsfmEditFactory } from '@sillsdev/lynx-usfm'; import { UsfmStylesheet } from '@sillsdev/machine/corpora'; import { CodeAction, @@ -21,12 +21,12 @@ const connection = createConnection(ProposedFeatures.all); const localizer = new Localizer(); const stylesheet = new UsfmStylesheet('usfm.sty'); const documentFactory = new UsfmDocumentFactory(stylesheet); -const scriptureSerializer = new UsfmScriptureSerializer(stylesheet); +const editFactory = new UsfmEditFactory(stylesheet); const documentManager = new DocumentManager(documentFactory); const workspace = new Workspace({ localizer, - diagnosticProviders: [new VerseOrderDiagnosticProvider(localizer, documentManager, scriptureSerializer)], - onTypeFormattingProviders: [new SimpleQuoteFormattingProvider(documentManager)], + diagnosticProviders: [new VerseOrderDiagnosticProvider(localizer, documentManager, editFactory)], + onTypeFormattingProviders: [new SimpleQuoteFormattingProvider(documentManager, editFactory)], }); let hasWorkspaceFolderCapability = false;