@codeauradev/ngx-wind-editor 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.idea/workspace.xml +120 -0
- package/LICENSE +21 -0
- package/README.md +89 -2
- package/dist/LICENSE +21 -0
- package/dist/README.md +90 -0
- package/dist/fesm2022/codeauradev-ngx-wind-editor.mjs +2596 -0
- package/dist/fesm2022/codeauradev-ngx-wind-editor.mjs.map +1 -0
- package/dist/ngx-wind-editor.css +184 -0
- package/dist/package.json +48 -0
- package/dist/types/codeauradev-ngx-wind-editor.d.ts +270 -0
- package/ng-package.json +15 -0
- package/package.json +59 -4
- package/scripts/patch-dist.mjs +11 -0
- package/src/catalog.ts +27 -0
- package/src/delta.ts +115 -0
- package/src/html.ts +327 -0
- package/src/public-api.ts +8 -0
- package/src/session.ts +843 -0
- package/src/styles/ngx-wind-editor.css +184 -0
- package/src/surface.ts +499 -0
- package/src/toolbar.ts +53 -0
- package/src/types.ts +69 -0
- package/src/wind-editor.ts +357 -0
- package/src/wind-icon.ts +32 -0
- package/test/editor.test.ts +58 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import * as Y from "yjs";
|
|
2
|
+
import * as i0 from "@angular/core";
|
|
3
|
+
declare const BLOCK_TYPES: readonly ["paragraph", "heading", "blockquote", "code", "bullet", "ordered", "rule", "image"];
|
|
4
|
+
type BlockType = (typeof BLOCK_TYPES)[number];
|
|
5
|
+
declare const MARKS: readonly ["bold", "italic", "underline", "strike", "code"];
|
|
6
|
+
type MarkName = (typeof MARKS)[number];
|
|
7
|
+
interface TextAttrs {
|
|
8
|
+
bold?: boolean;
|
|
9
|
+
italic?: boolean;
|
|
10
|
+
underline?: boolean;
|
|
11
|
+
strike?: boolean;
|
|
12
|
+
code?: boolean;
|
|
13
|
+
href?: string;
|
|
14
|
+
}
|
|
15
|
+
interface DeltaOp {
|
|
16
|
+
insert: string;
|
|
17
|
+
attributes?: TextAttrs;
|
|
18
|
+
}
|
|
19
|
+
type Delta = DeltaOp[];
|
|
20
|
+
interface BlockData {
|
|
21
|
+
id: string;
|
|
22
|
+
type: BlockType;
|
|
23
|
+
level: number;
|
|
24
|
+
src: string;
|
|
25
|
+
alt: string;
|
|
26
|
+
delta: Delta;
|
|
27
|
+
}
|
|
28
|
+
interface Point {
|
|
29
|
+
blockId: string;
|
|
30
|
+
offset: number;
|
|
31
|
+
}
|
|
32
|
+
interface Selection {
|
|
33
|
+
anchor: Point;
|
|
34
|
+
head: Point;
|
|
35
|
+
}
|
|
36
|
+
interface EditorSnapshot {
|
|
37
|
+
html: string;
|
|
38
|
+
text: string;
|
|
39
|
+
words: number;
|
|
40
|
+
characters: number;
|
|
41
|
+
blockType: BlockType;
|
|
42
|
+
level: number;
|
|
43
|
+
marks: TextAttrs;
|
|
44
|
+
href: string | null;
|
|
45
|
+
canUndo: boolean;
|
|
46
|
+
canRedo: boolean;
|
|
47
|
+
slash: string | null;
|
|
48
|
+
}
|
|
49
|
+
declare function isBlockType(value: unknown): value is BlockType;
|
|
50
|
+
interface LocatedBlock {
|
|
51
|
+
map: Y.Map<unknown>;
|
|
52
|
+
index: number;
|
|
53
|
+
id: string;
|
|
54
|
+
type: BlockType;
|
|
55
|
+
level: number;
|
|
56
|
+
text: Y.Text | null;
|
|
57
|
+
}
|
|
58
|
+
type SlashChoice = 'paragraph' | 'h1' | 'h2' | 'h3' | 'bullet' | 'ordered' | 'quote' | 'code' | 'rule' | 'image';
|
|
59
|
+
declare function blocksOf(doc: Y.Doc): Y.Array<Y.Map<unknown>>;
|
|
60
|
+
declare function makeBlock(type: BlockType, fields?: {
|
|
61
|
+
level?: number;
|
|
62
|
+
src?: string;
|
|
63
|
+
alt?: string;
|
|
64
|
+
delta?: Delta;
|
|
65
|
+
}): Y.Map<unknown>;
|
|
66
|
+
declare function readDelta(text: Y.Text): Delta;
|
|
67
|
+
export declare function readBlocks(doc: Y.Doc): BlockData[];
|
|
68
|
+
interface SessionSnapshot {
|
|
69
|
+
blockType: BlockType;
|
|
70
|
+
level: number;
|
|
71
|
+
marks: TextAttrs;
|
|
72
|
+
href: string | null;
|
|
73
|
+
slash: string | null;
|
|
74
|
+
canUndo: boolean;
|
|
75
|
+
canRedo: boolean;
|
|
76
|
+
}
|
|
77
|
+
export declare class WindSession {
|
|
78
|
+
readonly doc: Y.Doc;
|
|
79
|
+
readonly origin: object;
|
|
80
|
+
selection: Selection;
|
|
81
|
+
private stored;
|
|
82
|
+
private pendingBefore;
|
|
83
|
+
private readonly undoManager;
|
|
84
|
+
private readonly listeners;
|
|
85
|
+
constructor(doc: Y.Doc);
|
|
86
|
+
onChange(listener: (structural: boolean) => void): () => void;
|
|
87
|
+
get html(): string;
|
|
88
|
+
get text(): string;
|
|
89
|
+
get words(): number;
|
|
90
|
+
get canUndo(): boolean;
|
|
91
|
+
get canRedo(): boolean;
|
|
92
|
+
undo(): void;
|
|
93
|
+
redo(): void;
|
|
94
|
+
select(selection: Selection): void;
|
|
95
|
+
focusPoint(blockId: string, offset: number): void;
|
|
96
|
+
insertText(value: string): void;
|
|
97
|
+
deleteBackward(): void;
|
|
98
|
+
deleteForward(): void;
|
|
99
|
+
split(mode: 'enter' | 'hard' | 'exit'): void;
|
|
100
|
+
toggleMark(mark: MarkName): void;
|
|
101
|
+
setLink(href: string | null): void;
|
|
102
|
+
clearMarks(): void;
|
|
103
|
+
setBlockType(type: BlockType, level?: number): void;
|
|
104
|
+
insertDivider(): void;
|
|
105
|
+
insertImage(src: string, alt?: string): void;
|
|
106
|
+
applySlash(choice: SlashChoice): 'image' | 'applied';
|
|
107
|
+
paste(html: string | null, plain: string | null): void;
|
|
108
|
+
replaceHtml(html: string): void;
|
|
109
|
+
reconcileText(blockId: string, next: string): void;
|
|
110
|
+
snapshot(): SessionSnapshot;
|
|
111
|
+
dismissSlash(): void;
|
|
112
|
+
private slashQuery;
|
|
113
|
+
private consumeSlash;
|
|
114
|
+
private typingAttrs;
|
|
115
|
+
private effectiveMarks;
|
|
116
|
+
private toolbarMarks;
|
|
117
|
+
private rangeHas;
|
|
118
|
+
private formatRange;
|
|
119
|
+
private iterRange;
|
|
120
|
+
private deleteRange;
|
|
121
|
+
private insertParsed;
|
|
122
|
+
private selectedBlocks;
|
|
123
|
+
private ensureText;
|
|
124
|
+
private ensureBlock;
|
|
125
|
+
private current;
|
|
126
|
+
private clampSelection;
|
|
127
|
+
private transact;
|
|
128
|
+
private emit;
|
|
129
|
+
}
|
|
130
|
+
export declare function loadHtml(doc: Y.Doc, html: string, origin?: unknown): void;
|
|
131
|
+
interface SlashItem {
|
|
132
|
+
id: SlashChoice;
|
|
133
|
+
label: string;
|
|
134
|
+
hint: string;
|
|
135
|
+
keys: string;
|
|
136
|
+
}
|
|
137
|
+
export declare const SLASH_ITEMS: readonly SlashItem[];
|
|
138
|
+
export declare function filterSlash(query: string): SlashItem[];
|
|
139
|
+
export declare const WIND_TOOLS: readonly ["paragraph", "h1", "h2", "bold", "italic", "underline", "strike", "code", "link", "bullet", "ordered", "quote", "codeblock", "rule", "image", "clear", "undo", "redo", "source"];
|
|
140
|
+
type WindTool = (typeof WIND_TOOLS)[number];
|
|
141
|
+
type WindToolbarPreset = 'full' | 'writing' | 'basic' | 'minimal';
|
|
142
|
+
export declare const WIND_TOOLBAR_PRESETS: Record<WindToolbarPreset, readonly WindTool[]>;
|
|
143
|
+
export declare function isWindTool(value: string): value is WindTool;
|
|
144
|
+
export declare function isToolbarPreset(value: string): value is WindToolbarPreset;
|
|
145
|
+
/** A preset name, or an explicit list. Unknown ids are dropped. List order is the button order. */
|
|
146
|
+
export declare function resolveToolbar(value?: WindToolbarPreset | readonly string[] | null): WindTool[];
|
|
147
|
+
interface SlashPoint {
|
|
148
|
+
top: number;
|
|
149
|
+
left: number;
|
|
150
|
+
}
|
|
151
|
+
interface SurfaceChange extends EditorSnapshot {
|
|
152
|
+
slashRect: SlashPoint | null;
|
|
153
|
+
}
|
|
154
|
+
type WindCommand = {
|
|
155
|
+
type: 'mark';
|
|
156
|
+
mark: MarkName;
|
|
157
|
+
} | {
|
|
158
|
+
type: 'link';
|
|
159
|
+
href: string | null;
|
|
160
|
+
} | {
|
|
161
|
+
type: 'clear';
|
|
162
|
+
} | {
|
|
163
|
+
type: 'block';
|
|
164
|
+
block: BlockType;
|
|
165
|
+
level?: number;
|
|
166
|
+
} | {
|
|
167
|
+
type: 'divider';
|
|
168
|
+
} | {
|
|
169
|
+
type: 'image';
|
|
170
|
+
src: string;
|
|
171
|
+
alt?: string;
|
|
172
|
+
} | {
|
|
173
|
+
type: 'undo';
|
|
174
|
+
} | {
|
|
175
|
+
type: 'redo';
|
|
176
|
+
} | {
|
|
177
|
+
type: 'slash';
|
|
178
|
+
choice: SlashChoice;
|
|
179
|
+
};
|
|
180
|
+
interface WindMount {
|
|
181
|
+
destroy(): void;
|
|
182
|
+
focus(): void;
|
|
183
|
+
exec(command: WindCommand): void;
|
|
184
|
+
setReadonly(readonly: boolean): void;
|
|
185
|
+
setPlaceholder(value: string): void;
|
|
186
|
+
setHtml(html: string): void;
|
|
187
|
+
getHtml(): string;
|
|
188
|
+
session: WindSession;
|
|
189
|
+
}
|
|
190
|
+
interface SurfaceOptions {
|
|
191
|
+
html?: string;
|
|
192
|
+
placeholder?: string;
|
|
193
|
+
readonly?: boolean;
|
|
194
|
+
onChange?: (change: SurfaceChange) => void;
|
|
195
|
+
/** Return true to swallow the key (slash menu, for example). */
|
|
196
|
+
interceptKey?: (event: KeyboardEvent) => boolean;
|
|
197
|
+
onLink?: () => void;
|
|
198
|
+
}
|
|
199
|
+
declare function mountWindSurface(host: HTMLElement, options: SurfaceOptions): WindMount;
|
|
200
|
+
interface Tool {
|
|
201
|
+
id: WindTool;
|
|
202
|
+
label: string;
|
|
203
|
+
icon: string;
|
|
204
|
+
}
|
|
205
|
+
export declare class WindEditor {
|
|
206
|
+
readonly html: import("@angular/core").ModelSignal<string>;
|
|
207
|
+
readonly placeholder: import("@angular/core").InputSignal<string>;
|
|
208
|
+
readonly readOnly: import("@angular/core").InputSignal<boolean>;
|
|
209
|
+
readonly showToolbar: import("@angular/core").InputSignal<boolean>;
|
|
210
|
+
/** Preset name, or the buttons to show, in order. */
|
|
211
|
+
readonly toolbar: import("@angular/core").InputSignal<readonly ("paragraph" | "code" | "bullet" | "ordered" | "rule" | "image" | "bold" | "italic" | "underline" | "strike" | "link" | "source" | "h1" | "h2" | "quote" | "undo" | "redo" | "codeblock" | "clear")[] | WindToolbarPreset>;
|
|
212
|
+
protected readonly tools: import("@angular/core").Signal<Tool[]>;
|
|
213
|
+
protected readonly change: import("@angular/core").WritableSignal<SurfaceChange | null>;
|
|
214
|
+
protected readonly linkOpen: import("@angular/core").WritableSignal<boolean>;
|
|
215
|
+
protected readonly linkDraft: import("@angular/core").WritableSignal<string>;
|
|
216
|
+
protected readonly imageOpen: import("@angular/core").WritableSignal<boolean>;
|
|
217
|
+
protected readonly imageSrc: import("@angular/core").WritableSignal<string>;
|
|
218
|
+
protected readonly imageAlt: import("@angular/core").WritableSignal<string>;
|
|
219
|
+
protected readonly sourceOpen: import("@angular/core").WritableSignal<boolean>;
|
|
220
|
+
protected readonly sourceDraft: import("@angular/core").WritableSignal<string>;
|
|
221
|
+
protected readonly slashIndex: import("@angular/core").WritableSignal<number>;
|
|
222
|
+
protected readonly slashItems: import("@angular/core").Signal<SlashItem[]>;
|
|
223
|
+
private readonly surfaceRef;
|
|
224
|
+
private readonly destroyRef;
|
|
225
|
+
private handle;
|
|
226
|
+
private echo;
|
|
227
|
+
constructor();
|
|
228
|
+
protected pressed(id: string): boolean;
|
|
229
|
+
protected disabled(id: string): boolean;
|
|
230
|
+
protected run(id: string): void;
|
|
231
|
+
protected pickSlash(item: SlashItem): void;
|
|
232
|
+
protected applyLink(event: Event): void;
|
|
233
|
+
protected clearLink(): void;
|
|
234
|
+
protected applyImage(event: Event): void;
|
|
235
|
+
protected applySource(): void;
|
|
236
|
+
protected onDraft(event: Event, field: 'link' | 'src' | 'alt' | 'source'): void;
|
|
237
|
+
private attach;
|
|
238
|
+
private onKey;
|
|
239
|
+
static ɵfac: i0.ɵɵFactoryDeclaration<WindEditor, never>;
|
|
240
|
+
static ɵcmp: i0.ɵɵComponentDeclaration<WindEditor, "wind-editor", never, {
|
|
241
|
+
"html": {
|
|
242
|
+
"alias": "html";
|
|
243
|
+
"required": false;
|
|
244
|
+
"isSignal": true;
|
|
245
|
+
};
|
|
246
|
+
"placeholder": {
|
|
247
|
+
"alias": "placeholder";
|
|
248
|
+
"required": false;
|
|
249
|
+
"isSignal": true;
|
|
250
|
+
};
|
|
251
|
+
"readOnly": {
|
|
252
|
+
"alias": "readonly";
|
|
253
|
+
"required": false;
|
|
254
|
+
"isSignal": true;
|
|
255
|
+
};
|
|
256
|
+
"showToolbar": {
|
|
257
|
+
"alias": "showToolbar";
|
|
258
|
+
"required": false;
|
|
259
|
+
"isSignal": true;
|
|
260
|
+
};
|
|
261
|
+
"toolbar": {
|
|
262
|
+
"alias": "toolbar";
|
|
263
|
+
"required": false;
|
|
264
|
+
"isSignal": true;
|
|
265
|
+
};
|
|
266
|
+
}, {
|
|
267
|
+
"html": "htmlChange";
|
|
268
|
+
}, never, never, true, never>;
|
|
269
|
+
}
|
|
270
|
+
export type { BlockData, BlockType, EditorSnapshot, MarkName, Selection, SlashChoice, SlashItem, TextAttrs, WindTool, WindToolbarPreset };
|
package/ng-package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./node_modules/ng-packagr/ng-package.schema.json",
|
|
3
|
+
"dest": "./dist",
|
|
4
|
+
"allowedNonPeerDependencies": ["yjs"],
|
|
5
|
+
"lib": {
|
|
6
|
+
"entryFile": "src/public-api.ts"
|
|
7
|
+
},
|
|
8
|
+
"assets": [
|
|
9
|
+
{
|
|
10
|
+
"input": "src/styles",
|
|
11
|
+
"glob": "**/*.css",
|
|
12
|
+
"output": "."
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codeauradev/ngx-wind-editor",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "WYSIWYG HTML editor for Angular 22, styled with Tailwind CSS v4.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"angular",
|
|
7
|
+
"angular22",
|
|
8
|
+
"wysiwyg",
|
|
9
|
+
"rich-text",
|
|
10
|
+
"editor",
|
|
11
|
+
"tailwindcss",
|
|
12
|
+
"ngx"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"sideEffects": [
|
|
19
|
+
"*.css",
|
|
20
|
+
"styles.css"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "./src/public-api.ts",
|
|
24
|
+
"module": "./src/public-api.ts",
|
|
25
|
+
"types": "./src/public-api.ts",
|
|
26
|
+
"typings": "./src/public-api.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./src/public-api.ts",
|
|
30
|
+
"default": "./src/public-api.ts"
|
|
31
|
+
},
|
|
32
|
+
"./styles.css": "./src/styles/ngx-wind-editor.css"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@angular/core": "^22.0.0",
|
|
36
|
+
"tailwindcss": "^4.0.0"
|
|
37
|
+
},
|
|
38
|
+
"peerDependenciesMeta": {
|
|
39
|
+
"tailwindcss": {
|
|
40
|
+
"optional": true
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"tslib": "^2.8.1",
|
|
45
|
+
"yjs": "^13.6.27"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@angular/compiler": "22.2.3",
|
|
49
|
+
"@angular/compiler-cli": "22.2.3",
|
|
50
|
+
"@angular/core": "22.2.3",
|
|
51
|
+
"ng-packagr": "22.2.3",
|
|
52
|
+
"rxjs": "7.8.2",
|
|
53
|
+
"tsx": "^4.20.5",
|
|
54
|
+
"typescript": "6.0.3",
|
|
55
|
+
"yjs": "13.6.27"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "ng-packagr -p ng-package.json -c tsconfig.json && node scripts/patch-dist.mjs",
|
|
59
|
+
"test": "tsx --test test/*.test.ts"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
const file = 'dist/package.json';
|
|
4
|
+
const pkg = JSON.parse(readFileSync(file, 'utf8'));
|
|
5
|
+
delete pkg.main;
|
|
6
|
+
delete pkg.types;
|
|
7
|
+
pkg.exports = {
|
|
8
|
+
...pkg.exports,
|
|
9
|
+
'./styles.css': './ngx-wind-editor.css',
|
|
10
|
+
};
|
|
11
|
+
writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n');
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { SlashChoice } from './session';
|
|
2
|
+
|
|
3
|
+
export interface SlashItem {
|
|
4
|
+
id: SlashChoice;
|
|
5
|
+
label: string;
|
|
6
|
+
hint: string;
|
|
7
|
+
keys: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const SLASH_ITEMS: readonly SlashItem[] = [
|
|
11
|
+
{ id: 'paragraph', label: 'Text', hint: 'Plain paragraph', keys: 'p text paragraph' },
|
|
12
|
+
{ id: 'h1', label: 'Heading 1', hint: 'Large title', keys: 'h1 heading title' },
|
|
13
|
+
{ id: 'h2', label: 'Heading 2', hint: 'Section', keys: 'h2 heading' },
|
|
14
|
+
{ id: 'h3', label: 'Heading 3', hint: 'Subsection', keys: 'h3 heading' },
|
|
15
|
+
{ id: 'bullet', label: 'Bullet list', hint: 'Unordered', keys: 'ul list bullet' },
|
|
16
|
+
{ id: 'ordered', label: 'Numbered list', hint: 'Ordered', keys: 'ol list number' },
|
|
17
|
+
{ id: 'quote', label: 'Quote', hint: 'Blockquote', keys: 'quote blockquote' },
|
|
18
|
+
{ id: 'code', label: 'Code', hint: 'Monospace block', keys: 'code pre' },
|
|
19
|
+
{ id: 'rule', label: 'Divider', hint: 'Horizontal rule', keys: 'hr rule divider line' },
|
|
20
|
+
{ id: 'image', label: 'Image', hint: 'From a URL', keys: 'img image picture' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export function filterSlash(query: string): SlashItem[] {
|
|
24
|
+
const needle = query.trim().toLowerCase();
|
|
25
|
+
if (!needle) return [...SLASH_ITEMS];
|
|
26
|
+
return SLASH_ITEMS.filter((item) => `${item.label} ${item.keys}`.toLowerCase().includes(needle));
|
|
27
|
+
}
|
package/src/delta.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { Delta, DeltaOp, TextAttrs } from './types';
|
|
2
|
+
|
|
3
|
+
const MARK_KEYS = ['bold', 'italic', 'underline', 'strike', 'code', 'href'] as const;
|
|
4
|
+
|
|
5
|
+
export function packAttrs(attrs: TextAttrs | null | undefined): TextAttrs | undefined {
|
|
6
|
+
if (!attrs) return undefined;
|
|
7
|
+
const out: TextAttrs = {};
|
|
8
|
+
if (attrs.bold) out.bold = true;
|
|
9
|
+
if (attrs.italic) out.italic = true;
|
|
10
|
+
if (attrs.underline) out.underline = true;
|
|
11
|
+
if (attrs.strike) out.strike = true;
|
|
12
|
+
if (attrs.code) out.code = true;
|
|
13
|
+
if (typeof attrs.href === 'string' && attrs.href.length > 0) out.href = attrs.href;
|
|
14
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function attrsEqual(a: TextAttrs | undefined, b: TextAttrs | undefined): boolean {
|
|
18
|
+
const left = packAttrs(a) ?? {};
|
|
19
|
+
const right = packAttrs(b) ?? {};
|
|
20
|
+
return MARK_KEYS.every((key) => left[key] === right[key]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function mergeDelta(delta: Delta): Delta {
|
|
24
|
+
const out: Delta = [];
|
|
25
|
+
for (const op of delta) {
|
|
26
|
+
if (!op.insert) continue;
|
|
27
|
+
const prev = out[out.length - 1];
|
|
28
|
+
if (prev && attrsEqual(prev.attributes, op.attributes)) prev.insert += op.insert;
|
|
29
|
+
else out.push({ insert: op.insert, attributes: packAttrs(op.attributes) });
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function deltaLength(delta: Delta): number {
|
|
35
|
+
return delta.reduce((sum, op) => sum + op.insert.length, 0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function deltaToText(delta: Delta): string {
|
|
39
|
+
return delta.map((op) => op.insert).join('');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function sliceDelta(delta: Delta, start: number, end: number): Delta {
|
|
43
|
+
const out: DeltaOp[] = [];
|
|
44
|
+
let pos = 0;
|
|
45
|
+
for (const op of delta) {
|
|
46
|
+
const len = op.insert.length;
|
|
47
|
+
const from = Math.max(start, pos);
|
|
48
|
+
const to = Math.min(end, pos + len);
|
|
49
|
+
if (to > from) {
|
|
50
|
+
out.push({ insert: op.insert.slice(from - pos, to - pos), attributes: packAttrs(op.attributes) });
|
|
51
|
+
}
|
|
52
|
+
pos += len;
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Attributes that apply to a caret sitting at `index` (the character before it). */
|
|
58
|
+
export function attrsAt(delta: Delta, index: number): TextAttrs {
|
|
59
|
+
if (index <= 0 || delta.length === 0) return {};
|
|
60
|
+
let pos = 0;
|
|
61
|
+
for (const op of delta) {
|
|
62
|
+
const len = op.insert.length;
|
|
63
|
+
if (index <= pos + len) return { ...(packAttrs(op.attributes) ?? {}) };
|
|
64
|
+
pos += len;
|
|
65
|
+
}
|
|
66
|
+
return { ...(packAttrs(delta[delta.length - 1]?.attributes) ?? {}) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function commonEdges(prev: string, next: string): { start: number; prevEnd: number; nextEnd: number } {
|
|
70
|
+
let start = 0;
|
|
71
|
+
const limit = Math.min(prev.length, next.length);
|
|
72
|
+
while (start < limit && prev[start] === next[start]) start += 1;
|
|
73
|
+
let prevEnd = prev.length;
|
|
74
|
+
let nextEnd = next.length;
|
|
75
|
+
while (prevEnd > start && nextEnd > start && prev[prevEnd - 1] === next[nextEnd - 1]) {
|
|
76
|
+
prevEnd -= 1;
|
|
77
|
+
nextEnd -= 1;
|
|
78
|
+
}
|
|
79
|
+
return { start, prevEnd, nextEnd };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface GraphemePart {
|
|
83
|
+
index: number;
|
|
84
|
+
segment: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface GraphemeSegmenter {
|
|
88
|
+
segment(input: string): Iterable<GraphemePart>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Local shape so the build does not depend on the ES2022 Intl lib. */
|
|
92
|
+
function graphemeSegmenter(): GraphemeSegmenter | null {
|
|
93
|
+
const intl = Intl as typeof Intl & {
|
|
94
|
+
Segmenter?: new (locales?: string, options?: { granularity?: 'grapheme' }) => GraphemeSegmenter;
|
|
95
|
+
};
|
|
96
|
+
if (typeof intl.Segmenter !== 'function') return null;
|
|
97
|
+
return new intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function graphemeBefore(text: string, offset: number): number {
|
|
101
|
+
if (offset <= 0) return 0;
|
|
102
|
+
const segmenter = graphemeSegmenter();
|
|
103
|
+
if (!segmenter) return offset - 1;
|
|
104
|
+
let last = 0;
|
|
105
|
+
for (const part of segmenter.segment(text.slice(0, offset))) last = part.index;
|
|
106
|
+
return last;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function graphemeAfter(text: string, offset: number): number {
|
|
110
|
+
if (offset >= text.length) return text.length;
|
|
111
|
+
const segmenter = graphemeSegmenter();
|
|
112
|
+
if (!segmenter) return offset + 1;
|
|
113
|
+
const part = segmenter.segment(text.slice(offset))[Symbol.iterator]().next().value;
|
|
114
|
+
return offset + (part?.segment.length ?? 1);
|
|
115
|
+
}
|