@codeauradev/ngx-wind-editor 1.0.0 → 1.0.2
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/README.md +27 -9
- package/{dist/fesm2022 → fesm2022}/codeauradev-ngx-wind-editor.mjs +222 -76
- package/fesm2022/codeauradev-ngx-wind-editor.mjs.map +1 -0
- package/ngx-wind-editor.css +512 -0
- package/package.json +10 -22
- package/{dist/types → types}/codeauradev-ngx-wind-editor.d.ts +51 -5
- package/.idea/workspace.xml +0 -120
- package/dist/LICENSE +0 -21
- package/dist/README.md +0 -90
- package/dist/fesm2022/codeauradev-ngx-wind-editor.mjs.map +0 -1
- package/dist/ngx-wind-editor.css +0 -184
- package/dist/package.json +0 -48
- package/ng-package.json +0 -15
- package/scripts/patch-dist.mjs +0 -11
- package/src/catalog.ts +0 -27
- package/src/delta.ts +0 -115
- package/src/html.ts +0 -327
- package/src/public-api.ts +0 -8
- package/src/session.ts +0 -843
- package/src/styles/ngx-wind-editor.css +0 -184
- package/src/surface.ts +0 -499
- package/src/toolbar.ts +0 -53
- package/src/types.ts +0 -69
- package/src/wind-editor.ts +0 -357
- package/src/wind-icon.ts +0 -32
- package/test/editor.test.ts +0 -58
- package/tsconfig.json +0 -27
package/src/toolbar.ts
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
export const WIND_TOOLS = [
|
|
2
|
-
'paragraph',
|
|
3
|
-
'h1',
|
|
4
|
-
'h2',
|
|
5
|
-
'bold',
|
|
6
|
-
'italic',
|
|
7
|
-
'underline',
|
|
8
|
-
'strike',
|
|
9
|
-
'code',
|
|
10
|
-
'link',
|
|
11
|
-
'bullet',
|
|
12
|
-
'ordered',
|
|
13
|
-
'quote',
|
|
14
|
-
'codeblock',
|
|
15
|
-
'rule',
|
|
16
|
-
'image',
|
|
17
|
-
'clear',
|
|
18
|
-
'undo',
|
|
19
|
-
'redo',
|
|
20
|
-
'source',
|
|
21
|
-
] as const;
|
|
22
|
-
|
|
23
|
-
export type WindTool = (typeof WIND_TOOLS)[number];
|
|
24
|
-
|
|
25
|
-
export type WindToolbarPreset = 'full' | 'writing' | 'basic' | 'minimal';
|
|
26
|
-
|
|
27
|
-
export const WIND_TOOLBAR_PRESETS: Record<WindToolbarPreset, readonly WindTool[]> = {
|
|
28
|
-
full: WIND_TOOLS,
|
|
29
|
-
writing: ['paragraph', 'h1', 'h2', 'bold', 'italic', 'link', 'bullet', 'ordered', 'quote', 'undo', 'redo'],
|
|
30
|
-
basic: ['bold', 'italic', 'underline', 'strike', 'link', 'bullet', 'ordered', 'undo', 'redo'],
|
|
31
|
-
minimal: ['bold', 'italic', 'link'],
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
const KNOWN = new Set<string>(WIND_TOOLS);
|
|
35
|
-
|
|
36
|
-
export function isWindTool(value: string): value is WindTool {
|
|
37
|
-
return KNOWN.has(value);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function isToolbarPreset(value: string): value is WindToolbarPreset {
|
|
41
|
-
return value === 'full' || value === 'writing' || value === 'basic' || value === 'minimal';
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** A preset name, or an explicit list. Unknown ids are dropped. List order is the button order. */
|
|
45
|
-
export function resolveToolbar(value?: WindToolbarPreset | readonly string[] | null): WindTool[] {
|
|
46
|
-
if (value == null) return [...WIND_TOOLS];
|
|
47
|
-
if (typeof value === 'string') return [...(isToolbarPreset(value) ? WIND_TOOLBAR_PRESETS[value] : WIND_TOOLS)];
|
|
48
|
-
const picked: WindTool[] = [];
|
|
49
|
-
for (const id of value) {
|
|
50
|
-
if (isWindTool(id) && !picked.includes(id)) picked.push(id);
|
|
51
|
-
}
|
|
52
|
-
return picked;
|
|
53
|
-
}
|
package/src/types.ts
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
export const BLOCK_TYPES = [
|
|
2
|
-
'paragraph',
|
|
3
|
-
'heading',
|
|
4
|
-
'blockquote',
|
|
5
|
-
'code',
|
|
6
|
-
'bullet',
|
|
7
|
-
'ordered',
|
|
8
|
-
'rule',
|
|
9
|
-
'image',
|
|
10
|
-
] as const;
|
|
11
|
-
|
|
12
|
-
export type BlockType = (typeof BLOCK_TYPES)[number];
|
|
13
|
-
|
|
14
|
-
export const MARKS = ['bold', 'italic', 'underline', 'strike', 'code'] as const;
|
|
15
|
-
|
|
16
|
-
export type MarkName = (typeof MARKS)[number];
|
|
17
|
-
|
|
18
|
-
export interface TextAttrs {
|
|
19
|
-
bold?: boolean;
|
|
20
|
-
italic?: boolean;
|
|
21
|
-
underline?: boolean;
|
|
22
|
-
strike?: boolean;
|
|
23
|
-
code?: boolean;
|
|
24
|
-
href?: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface DeltaOp {
|
|
28
|
-
insert: string;
|
|
29
|
-
attributes?: TextAttrs;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export type Delta = DeltaOp[];
|
|
33
|
-
|
|
34
|
-
export interface BlockData {
|
|
35
|
-
id: string;
|
|
36
|
-
type: BlockType;
|
|
37
|
-
level: number;
|
|
38
|
-
src: string;
|
|
39
|
-
alt: string;
|
|
40
|
-
delta: Delta;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface Point {
|
|
44
|
-
blockId: string;
|
|
45
|
-
offset: number;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface Selection {
|
|
49
|
-
anchor: Point;
|
|
50
|
-
head: Point;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export interface EditorSnapshot {
|
|
54
|
-
html: string;
|
|
55
|
-
text: string;
|
|
56
|
-
words: number;
|
|
57
|
-
characters: number;
|
|
58
|
-
blockType: BlockType;
|
|
59
|
-
level: number;
|
|
60
|
-
marks: TextAttrs;
|
|
61
|
-
href: string | null;
|
|
62
|
-
canUndo: boolean;
|
|
63
|
-
canRedo: boolean;
|
|
64
|
-
slash: string | null;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function isBlockType(value: unknown): value is BlockType {
|
|
68
|
-
return typeof value === 'string' && (BLOCK_TYPES as readonly string[]).includes(value);
|
|
69
|
-
}
|
package/src/wind-editor.ts
DELETED
|
@@ -1,357 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
ChangeDetectionStrategy,
|
|
3
|
-
Component,
|
|
4
|
-
DestroyRef,
|
|
5
|
-
ElementRef,
|
|
6
|
-
ViewEncapsulation,
|
|
7
|
-
afterNextRender,
|
|
8
|
-
computed,
|
|
9
|
-
effect,
|
|
10
|
-
inject,
|
|
11
|
-
input,
|
|
12
|
-
model,
|
|
13
|
-
signal,
|
|
14
|
-
viewChild,
|
|
15
|
-
} from '@angular/core';
|
|
16
|
-
import { filterSlash } from './catalog';
|
|
17
|
-
import type { SlashItem } from './catalog';
|
|
18
|
-
import { mountWindSurface } from './surface';
|
|
19
|
-
import type { SurfaceChange, WindMount } from './surface';
|
|
20
|
-
import type { MarkName } from './types';
|
|
21
|
-
import { resolveToolbar } from './toolbar';
|
|
22
|
-
import type { WindTool, WindToolbarPreset } from './toolbar';
|
|
23
|
-
import { WindIcon } from './wind-icon';
|
|
24
|
-
|
|
25
|
-
interface Tool {
|
|
26
|
-
id: WindTool;
|
|
27
|
-
label: string;
|
|
28
|
-
icon: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const TOOLS: Tool[] = [
|
|
32
|
-
{ id: 'paragraph', label: 'Text', icon: 'block' },
|
|
33
|
-
{ id: 'h1', label: 'Heading 1', icon: 'block' },
|
|
34
|
-
{ id: 'h2', label: 'Heading 2', icon: 'block' },
|
|
35
|
-
{ id: 'bold', label: 'Bold', icon: 'bold' },
|
|
36
|
-
{ id: 'italic', label: 'Italic', icon: 'italic' },
|
|
37
|
-
{ id: 'underline', label: 'Underline', icon: 'underline' },
|
|
38
|
-
{ id: 'strike', label: 'Strikethrough', icon: 'strike' },
|
|
39
|
-
{ id: 'code', label: 'Inline code', icon: 'code' },
|
|
40
|
-
{ id: 'link', label: 'Link', icon: 'link' },
|
|
41
|
-
{ id: 'bullet', label: 'Bullet list', icon: 'list' },
|
|
42
|
-
{ id: 'ordered', label: 'Numbered list', icon: 'ordered' },
|
|
43
|
-
{ id: 'quote', label: 'Quote', icon: 'quote' },
|
|
44
|
-
{ id: 'codeblock', label: 'Code block', icon: 'block' },
|
|
45
|
-
{ id: 'rule', label: 'Divider', icon: 'rule' },
|
|
46
|
-
{ id: 'image', label: 'Image', icon: 'image' },
|
|
47
|
-
{ id: 'clear', label: 'Clear formatting', icon: 'clear' },
|
|
48
|
-
{ id: 'undo', label: 'Undo', icon: 'undo' },
|
|
49
|
-
{ id: 'redo', label: 'Redo', icon: 'redo' },
|
|
50
|
-
{ id: 'source', label: 'HTML', icon: 'source' },
|
|
51
|
-
];
|
|
52
|
-
|
|
53
|
-
@Component({
|
|
54
|
-
selector: 'wind-editor',
|
|
55
|
-
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
56
|
-
encapsulation: ViewEncapsulation.None,
|
|
57
|
-
imports: [WindIcon],
|
|
58
|
-
styleUrl: './styles/ngx-wind-editor.css',
|
|
59
|
-
host: { class: 'wind-editor block min-w-0' },
|
|
60
|
-
template: `
|
|
61
|
-
<div class="rounded-xl border border-stone-200 bg-white text-stone-900 shadow-sm dark:border-stone-800 dark:bg-stone-950 dark:text-stone-100">
|
|
62
|
-
@if (showToolbar() && tools().length > 0) {
|
|
63
|
-
<div class="flex gap-0.5 overflow-x-auto border-b border-stone-200 px-1.5 py-1.5 dark:border-stone-800" role="toolbar" aria-label="Formatting">
|
|
64
|
-
@for (tool of tools(); track tool.id) {
|
|
65
|
-
<button
|
|
66
|
-
type="button"
|
|
67
|
-
class="inline-flex h-9 shrink-0 items-center justify-center gap-1 rounded-md px-2 text-sm text-stone-600 hover:bg-stone-100 disabled:opacity-40 aria-pressed:bg-teal-700 aria-pressed:text-white dark:text-stone-300 dark:hover:bg-stone-900 dark:aria-pressed:bg-teal-600"
|
|
68
|
-
[attr.aria-label]="tool.label"
|
|
69
|
-
[attr.title]="tool.label"
|
|
70
|
-
[attr.aria-pressed]="pressed(tool.id)"
|
|
71
|
-
[disabled]="disabled(tool.id)"
|
|
72
|
-
(mousedown)="$event.preventDefault()"
|
|
73
|
-
(click)="run(tool.id)"
|
|
74
|
-
>
|
|
75
|
-
@if (tool.id === 'paragraph' || tool.id === 'h1' || tool.id === 'h2') {
|
|
76
|
-
<span class="px-0.5 text-xs font-semibold tracking-wide">{{ tool.id === 'paragraph' ? 'P' : tool.id.toUpperCase() }}</span>
|
|
77
|
-
} @else {
|
|
78
|
-
<wind-icon [name]="tool.icon" />
|
|
79
|
-
}
|
|
80
|
-
</button>
|
|
81
|
-
}
|
|
82
|
-
</div>
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
@if (linkOpen()) {
|
|
86
|
-
<form class="flex flex-wrap items-center gap-2 border-b border-stone-200 px-3 py-2 dark:border-stone-800" (submit)="applyLink($event)">
|
|
87
|
-
<label class="sr-only" for="wind-link">Link URL</label>
|
|
88
|
-
<input
|
|
89
|
-
id="wind-link"
|
|
90
|
-
class="h-9 min-w-0 flex-1 rounded-md border border-stone-300 bg-white px-2 text-sm outline-none focus:border-teal-700 dark:border-stone-700 dark:bg-stone-900"
|
|
91
|
-
[value]="linkDraft()"
|
|
92
|
-
placeholder="https://"
|
|
93
|
-
(input)="onDraft($event, 'link')"
|
|
94
|
-
/>
|
|
95
|
-
<button type="submit" class="h-9 rounded-md bg-teal-700 px-3 text-sm font-medium text-white">Apply</button>
|
|
96
|
-
<button type="button" class="h-9 rounded-md px-2 text-sm text-stone-600 dark:text-stone-300" (click)="clearLink()">Remove</button>
|
|
97
|
-
</form>
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
@if (imageOpen()) {
|
|
101
|
-
<form class="flex flex-wrap items-center gap-2 border-b border-stone-200 px-3 py-2 dark:border-stone-800" (submit)="applyImage($event)">
|
|
102
|
-
<input class="h-9 min-w-0 flex-1 rounded-md border border-stone-300 bg-white px-2 text-sm outline-none focus:border-teal-700 dark:border-stone-700 dark:bg-stone-900" [value]="imageSrc()" placeholder="Image URL" (input)="onDraft($event, 'src')" />
|
|
103
|
-
<input class="h-9 w-36 rounded-md border border-stone-300 bg-white px-2 text-sm outline-none focus:border-teal-700 dark:border-stone-700 dark:bg-stone-900" [value]="imageAlt()" placeholder="Alt text" (input)="onDraft($event, 'alt')" />
|
|
104
|
-
<button type="submit" class="h-9 rounded-md bg-teal-700 px-3 text-sm font-medium text-white">Insert</button>
|
|
105
|
-
</form>
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
<div class="relative min-h-48" #surface></div>
|
|
109
|
-
|
|
110
|
-
@if (slashItems().length > 0 && change()?.slashRect; as rect) {
|
|
111
|
-
<ul
|
|
112
|
-
class="fixed z-30 max-h-64 w-56 overflow-auto rounded-lg border border-stone-200 bg-white py-1 shadow-lg dark:border-stone-700 dark:bg-stone-900"
|
|
113
|
-
role="listbox"
|
|
114
|
-
[style.top.px]="rect.top"
|
|
115
|
-
[style.left.px]="rect.left"
|
|
116
|
-
>
|
|
117
|
-
@for (item of slashItems(); track item.id; let index = $index) {
|
|
118
|
-
<li>
|
|
119
|
-
<button
|
|
120
|
-
type="button"
|
|
121
|
-
class="flex w-full items-baseline justify-between gap-3 px-3 py-1.5 text-left text-sm hover:bg-stone-100 dark:hover:bg-stone-800"
|
|
122
|
-
[class.bg-teal-700]="index === slashIndex()"
|
|
123
|
-
[class.text-white]="index === slashIndex()"
|
|
124
|
-
role="option"
|
|
125
|
-
[attr.aria-selected]="index === slashIndex()"
|
|
126
|
-
(mousedown)="$event.preventDefault()"
|
|
127
|
-
(click)="pickSlash(item)"
|
|
128
|
-
>
|
|
129
|
-
<span>{{ item.label }}</span>
|
|
130
|
-
<span class="text-xs opacity-70">{{ item.hint }}</span>
|
|
131
|
-
</button>
|
|
132
|
-
</li>
|
|
133
|
-
}
|
|
134
|
-
</ul>
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
<footer class="flex items-center justify-between gap-3 border-t border-stone-200 px-3 py-1.5 text-xs text-stone-500 dark:border-stone-800 dark:text-stone-400">
|
|
138
|
-
<span>{{ change()?.words ?? 0 }} words</span>
|
|
139
|
-
<span class="text-stone-400">/ for blocks</span>
|
|
140
|
-
</footer>
|
|
141
|
-
|
|
142
|
-
@if (sourceOpen()) {
|
|
143
|
-
<div class="border-t border-stone-200 p-3 dark:border-stone-800">
|
|
144
|
-
<textarea
|
|
145
|
-
class="h-40 w-full resize-y rounded-md border border-stone-300 bg-stone-50 p-2 font-mono text-xs outline-none focus:border-teal-700 dark:border-stone-700 dark:bg-stone-900"
|
|
146
|
-
[value]="sourceDraft()"
|
|
147
|
-
spellcheck="false"
|
|
148
|
-
aria-label="HTML source"
|
|
149
|
-
(input)="onDraft($event, 'source')"
|
|
150
|
-
></textarea>
|
|
151
|
-
<button type="button" class="mt-2 h-9 rounded-md bg-stone-900 px-3 text-sm text-white dark:bg-stone-100 dark:text-stone-900" (click)="applySource()">Apply HTML</button>
|
|
152
|
-
</div>
|
|
153
|
-
}
|
|
154
|
-
</div>
|
|
155
|
-
`,
|
|
156
|
-
})
|
|
157
|
-
export class WindEditor {
|
|
158
|
-
readonly html = model('');
|
|
159
|
-
readonly placeholder = input('Write something…');
|
|
160
|
-
readonly readOnly = input(false, { alias: 'readonly' });
|
|
161
|
-
readonly showToolbar = input(true);
|
|
162
|
-
/** Preset name, or the buttons to show, in order. */
|
|
163
|
-
readonly toolbar = input<WindToolbarPreset | readonly WindTool[]>('full');
|
|
164
|
-
|
|
165
|
-
protected readonly tools = computed(() => {
|
|
166
|
-
const byId = new Map(TOOLS.map((tool) => [tool.id, tool]));
|
|
167
|
-
return resolveToolbar(this.toolbar()).flatMap((id) => {
|
|
168
|
-
const tool = byId.get(id);
|
|
169
|
-
return tool ? [tool] : [];
|
|
170
|
-
});
|
|
171
|
-
});
|
|
172
|
-
protected readonly change = signal<SurfaceChange | null>(null);
|
|
173
|
-
protected readonly linkOpen = signal(false);
|
|
174
|
-
protected readonly linkDraft = signal('');
|
|
175
|
-
protected readonly imageOpen = signal(false);
|
|
176
|
-
protected readonly imageSrc = signal('');
|
|
177
|
-
protected readonly imageAlt = signal('');
|
|
178
|
-
protected readonly sourceOpen = signal(false);
|
|
179
|
-
protected readonly sourceDraft = signal('');
|
|
180
|
-
protected readonly slashIndex = signal(0);
|
|
181
|
-
|
|
182
|
-
protected readonly slashItems = computed(() => {
|
|
183
|
-
const query = this.change()?.slash;
|
|
184
|
-
return query === null || query === undefined ? [] : filterSlash(query);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
private readonly surfaceRef = viewChild<ElementRef<HTMLElement>>('surface');
|
|
188
|
-
private readonly destroyRef = inject(DestroyRef);
|
|
189
|
-
private handle: WindMount | null = null;
|
|
190
|
-
private echo = '';
|
|
191
|
-
|
|
192
|
-
constructor() {
|
|
193
|
-
afterNextRender(() => this.attach());
|
|
194
|
-
effect(() => {
|
|
195
|
-
const value = this.placeholder();
|
|
196
|
-
this.handle?.setPlaceholder(value);
|
|
197
|
-
});
|
|
198
|
-
effect(() => {
|
|
199
|
-
this.handle?.setReadonly(this.readOnly());
|
|
200
|
-
});
|
|
201
|
-
effect(() => {
|
|
202
|
-
const html = this.html();
|
|
203
|
-
if (html !== this.echo) this.handle?.setHtml(html);
|
|
204
|
-
});
|
|
205
|
-
effect(() => {
|
|
206
|
-
this.slashItems();
|
|
207
|
-
this.slashIndex.set(0);
|
|
208
|
-
});
|
|
209
|
-
this.destroyRef.onDestroy(() => this.handle?.destroy());
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
protected pressed(id: string): boolean {
|
|
213
|
-
const snap = this.change();
|
|
214
|
-
if (!snap) return false;
|
|
215
|
-
if (id === 'bold' || id === 'italic' || id === 'underline' || id === 'strike' || id === 'code') return !!snap.marks[id];
|
|
216
|
-
if (id === 'link') return !!snap.href;
|
|
217
|
-
if (id === 'paragraph') return snap.blockType === 'paragraph';
|
|
218
|
-
if (id === 'h1') return snap.blockType === 'heading' && snap.level === 1;
|
|
219
|
-
if (id === 'h2') return snap.blockType === 'heading' && snap.level === 2;
|
|
220
|
-
if (id === 'bullet' || id === 'ordered' || id === 'quote') {
|
|
221
|
-
const type = id === 'quote' ? 'blockquote' : id;
|
|
222
|
-
return snap.blockType === type;
|
|
223
|
-
}
|
|
224
|
-
if (id === 'codeblock') return snap.blockType === 'code';
|
|
225
|
-
if (id === 'source') return this.sourceOpen();
|
|
226
|
-
return false;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
protected disabled(id: string): boolean {
|
|
230
|
-
if (this.readOnly() && id !== 'source') return true;
|
|
231
|
-
const snap = this.change();
|
|
232
|
-
if (id === 'undo') return !snap?.canUndo;
|
|
233
|
-
if (id === 'redo') return !snap?.canRedo;
|
|
234
|
-
return false;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
protected run(id: string): void {
|
|
238
|
-
const handle = this.handle;
|
|
239
|
-
if (!handle) return;
|
|
240
|
-
if (id === 'bold' || id === 'italic' || id === 'underline' || id === 'strike' || id === 'code') {
|
|
241
|
-
handle.exec({ type: 'mark', mark: id as MarkName });
|
|
242
|
-
} else if (id === 'paragraph') handle.exec({ type: 'block', block: 'paragraph' });
|
|
243
|
-
else if (id === 'h1') handle.exec({ type: 'block', block: 'heading', level: 1 });
|
|
244
|
-
else if (id === 'h2') handle.exec({ type: 'block', block: 'heading', level: 2 });
|
|
245
|
-
else if (id === 'bullet' || id === 'ordered') handle.exec({ type: 'block', block: id });
|
|
246
|
-
else if (id === 'quote') handle.exec({ type: 'block', block: 'blockquote' });
|
|
247
|
-
else if (id === 'codeblock') handle.exec({ type: 'block', block: 'code' });
|
|
248
|
-
else if (id === 'rule') handle.exec({ type: 'divider' });
|
|
249
|
-
else if (id === 'clear') handle.exec({ type: 'clear' });
|
|
250
|
-
else if (id === 'undo') handle.exec({ type: 'undo' });
|
|
251
|
-
else if (id === 'redo') handle.exec({ type: 'redo' });
|
|
252
|
-
else if (id === 'link') {
|
|
253
|
-
this.linkDraft.set(this.change()?.href ?? '');
|
|
254
|
-
this.linkOpen.update((open) => !open);
|
|
255
|
-
} else if (id === 'image') this.imageOpen.update((open) => !open);
|
|
256
|
-
else if (id === 'source') {
|
|
257
|
-
this.sourceDraft.set(this.change()?.html ?? this.html());
|
|
258
|
-
this.sourceOpen.update((open) => !open);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
protected pickSlash(item: SlashItem): void {
|
|
263
|
-
if (item.id === 'image') {
|
|
264
|
-
this.handle?.exec({ type: 'slash', choice: 'image' });
|
|
265
|
-
this.imageOpen.set(true);
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
this.handle?.exec({ type: 'slash', choice: item.id });
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
protected applyLink(event: Event): void {
|
|
272
|
-
event.preventDefault();
|
|
273
|
-
this.handle?.exec({ type: 'link', href: this.linkDraft().trim() || null });
|
|
274
|
-
this.linkOpen.set(false);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
protected clearLink(): void {
|
|
278
|
-
this.handle?.exec({ type: 'link', href: null });
|
|
279
|
-
this.linkOpen.set(false);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
protected applyImage(event: Event): void {
|
|
283
|
-
event.preventDefault();
|
|
284
|
-
const src = this.imageSrc().trim();
|
|
285
|
-
if (!src) return;
|
|
286
|
-
this.handle?.exec({ type: 'image', src, alt: this.imageAlt().trim() });
|
|
287
|
-
this.imageOpen.set(false);
|
|
288
|
-
this.imageSrc.set('');
|
|
289
|
-
this.imageAlt.set('');
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
protected applySource(): void {
|
|
293
|
-
this.echo = this.sourceDraft();
|
|
294
|
-
this.html.set(this.echo);
|
|
295
|
-
this.handle?.setHtml(this.echo);
|
|
296
|
-
this.sourceOpen.set(false);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
protected onDraft(event: Event, field: 'link' | 'src' | 'alt' | 'source'): void {
|
|
300
|
-
const value = event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement ? event.target.value : '';
|
|
301
|
-
if (field === 'link') this.linkDraft.set(value);
|
|
302
|
-
else if (field === 'src') this.imageSrc.set(value);
|
|
303
|
-
else if (field === 'alt') this.imageAlt.set(value);
|
|
304
|
-
else this.sourceDraft.set(value);
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
private attach(): void {
|
|
308
|
-
const element = this.surfaceRef()?.nativeElement;
|
|
309
|
-
if (!element || this.handle) return;
|
|
310
|
-
this.handle = mountWindSurface(element, {
|
|
311
|
-
html: this.html() || undefined,
|
|
312
|
-
placeholder: this.placeholder(),
|
|
313
|
-
readonly: this.readOnly(),
|
|
314
|
-
onChange: (change) => {
|
|
315
|
-
this.change.set(change);
|
|
316
|
-
if (change.html !== this.html()) {
|
|
317
|
-
this.echo = change.html;
|
|
318
|
-
this.html.set(change.html);
|
|
319
|
-
}
|
|
320
|
-
},
|
|
321
|
-
onLink: () => {
|
|
322
|
-
this.linkDraft.set(this.change()?.href ?? '');
|
|
323
|
-
this.linkOpen.set(true);
|
|
324
|
-
},
|
|
325
|
-
interceptKey: (event) => this.onKey(event),
|
|
326
|
-
});
|
|
327
|
-
this.echo = this.handle.getHtml();
|
|
328
|
-
if (this.echo !== this.html()) this.html.set(this.echo);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
private onKey(event: KeyboardEvent): boolean {
|
|
332
|
-
const items = this.slashItems();
|
|
333
|
-
if (items.length === 0) return false;
|
|
334
|
-
if (event.key === 'ArrowDown') {
|
|
335
|
-
event.preventDefault();
|
|
336
|
-
this.slashIndex.update((index) => (index + 1) % items.length);
|
|
337
|
-
return true;
|
|
338
|
-
}
|
|
339
|
-
if (event.key === 'ArrowUp') {
|
|
340
|
-
event.preventDefault();
|
|
341
|
-
this.slashIndex.update((index) => (index - 1 + items.length) % items.length);
|
|
342
|
-
return true;
|
|
343
|
-
}
|
|
344
|
-
if (event.key === 'Enter' || event.key === 'Tab') {
|
|
345
|
-
event.preventDefault();
|
|
346
|
-
const item = items[this.slashIndex()] ?? items[0];
|
|
347
|
-
if (item) this.pickSlash(item);
|
|
348
|
-
return true;
|
|
349
|
-
}
|
|
350
|
-
if (event.key === 'Escape') {
|
|
351
|
-
event.preventDefault();
|
|
352
|
-
this.handle?.session.dismissSlash();
|
|
353
|
-
return true;
|
|
354
|
-
}
|
|
355
|
-
return false;
|
|
356
|
-
}
|
|
357
|
-
}
|
package/src/wind-icon.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
|
2
|
-
|
|
3
|
-
@Component({
|
|
4
|
-
selector: 'wind-icon',
|
|
5
|
-
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
6
|
-
template: `
|
|
7
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
8
|
-
@switch (name()) {
|
|
9
|
-
@case ('bold') { <path d="M6 4h8a4 4 0 0 1 0 8H6z" /><path d="M6 12h9a4 4 0 0 1 0 8H6z" /> }
|
|
10
|
-
@case ('italic') { <line x1="19" y1="4" x2="10" y2="4" /><line x1="14" y1="20" x2="5" y2="20" /><line x1="15" y1="4" x2="9" y2="20" /> }
|
|
11
|
-
@case ('underline') { <path d="M6 4v6a6 6 0 0 0 12 0V4" /><line x1="4" y1="20" x2="20" y2="20" /> }
|
|
12
|
-
@case ('strike') { <path d="M16 5c-.5-1.5-2-2.5-4-2.5-2.5 0-4 1.5-4 3.5 0 4 8 2 8 6 0 2-1.8 3.5-4.2 3.5-2.2 0-3.8-1.2-4.3-3" /><line x1="4" y1="12" x2="20" y2="12" /> }
|
|
13
|
-
@case ('code') { <path d="m8 8-4 4 4 4" /><path d="m16 8 4 4-4 4" /> }
|
|
14
|
-
@case ('link') { <path d="M10 13a5 5 0 0 0 7.5.5l2-2a5 5 0 0 0-7.1-7.1l-1.1 1.1" /><path d="M14 11a5 5 0 0 0-7.5-.5l-2 2a5 5 0 0 0 7.1 7.1l1.1-1.1" /> }
|
|
15
|
-
@case ('list') { <line x1="9" y1="6" x2="20" y2="6" /><line x1="9" y1="12" x2="20" y2="12" /><line x1="9" y1="18" x2="20" y2="18" /><circle cx="5" cy="6" r="1" fill="currentColor" stroke="none" /><circle cx="5" cy="12" r="1" fill="currentColor" stroke="none" /><circle cx="5" cy="18" r="1" fill="currentColor" stroke="none" /> }
|
|
16
|
-
@case ('ordered') { <line x1="10" y1="6" x2="21" y2="6" /><line x1="10" y1="12" x2="21" y2="12" /><line x1="10" y1="18" x2="21" y2="18" /><path d="M4 6h1v4" /><path d="M4 10h2" /><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" /> }
|
|
17
|
-
@case ('quote') { <path d="M8 7H5a2 2 0 0 0-2 2v3h5V7z" /><path d="M17 7h-3a2 2 0 0 0-2 2v3h5V7z" /><path d="M8 12v2a3 3 0 0 1-3 3" /><path d="M17 12v2a3 3 0 0 1-3 3" /> }
|
|
18
|
-
@case ('block') { <rect x="4" y="4" width="16" height="16" rx="2" /><path d="m9 9-2 3 2 3" /><path d="m15 9 2 3-2 3" /> }
|
|
19
|
-
@case ('rule') { <line x1="4" y1="12" x2="20" y2="12" /> }
|
|
20
|
-
@case ('image') { <rect x="3" y="5" width="18" height="14" rx="2" /><circle cx="8.5" cy="10" r="1.5" /><path d="m21 16-5-4-9 8" /> }
|
|
21
|
-
@case ('undo') { <path d="M3 7v6h6" /><path d="M21 17a9 9 0 0 0-15-6.7L3 13" /> }
|
|
22
|
-
@case ('redo') { <path d="M21 7v6h-6" /><path d="M3 17a9 9 0 0 1 15-6.7L21 13" /> }
|
|
23
|
-
@case ('clear') { <path d="M4 7h16" /><path d="M9 7V5h6v2" /><path d="m8 7 1 12h6l1-12" /> }
|
|
24
|
-
@case ('source') { <path d="M8 6 3 12l5 6" /><path d="m16 6 5 6-5 6" /><path d="m14 4-4 16" /> }
|
|
25
|
-
}
|
|
26
|
-
</svg>
|
|
27
|
-
`,
|
|
28
|
-
styles: [':host{display:inline-flex} svg{width:1rem;height:1rem;display:block}'],
|
|
29
|
-
})
|
|
30
|
-
export class WindIcon {
|
|
31
|
-
readonly name = input.required<string>();
|
|
32
|
-
}
|
package/test/editor.test.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { describe, it } from 'node:test';
|
|
3
|
-
import * as Y from 'yjs';
|
|
4
|
-
import { parseHtml, safeUrl, serializeBlocks } from '../src/html.ts';
|
|
5
|
-
import { readBlocks, WindSession } from '../src/session.ts';
|
|
6
|
-
import { resolveToolbar } from '../src/toolbar.ts';
|
|
7
|
-
|
|
8
|
-
describe('html', () => {
|
|
9
|
-
it('round-trips marks, lists, and headings', () => {
|
|
10
|
-
const html = '<h1>Wind</h1><p>Hello <strong>bold</strong> and <em>italic</em> <a href="https://example.com">link</a></p><ul><li>One</li><li>Two</li></ul><pre><code>a < b</code></pre>';
|
|
11
|
-
const again = serializeBlocks(parseHtml(html));
|
|
12
|
-
assert.equal(again, html.replace('a < b', 'a < b'));
|
|
13
|
-
assert.match(serializeBlocks(parseHtml(again)), /a < b/);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it('rejects unsafe urls', () => {
|
|
17
|
-
assert.equal(safeUrl('javascript:alert(1)'), null);
|
|
18
|
-
assert.equal(safeUrl('https://example.com/a'), 'https://example.com/a');
|
|
19
|
-
const blocks = parseHtml('<p><a href="javascript:alert(1)">x</a></p>');
|
|
20
|
-
assert.equal(serializeBlocks(blocks), '<p>x</p>');
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('keeps newlines inside code', () => {
|
|
24
|
-
const html = '<pre><code>one\ntwo</code></pre>';
|
|
25
|
-
assert.equal(serializeBlocks(parseHtml(html)), html);
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
describe('session', () => {
|
|
30
|
-
it('types, formats, splits, and undoes', () => {
|
|
31
|
-
const doc = new Y.Doc();
|
|
32
|
-
const session = new WindSession(doc);
|
|
33
|
-
session.insertText('Hello');
|
|
34
|
-
const id = readBlocks(doc)[0]?.id ?? '';
|
|
35
|
-
session.select({ anchor: { blockId: id, offset: 0 }, head: { blockId: id, offset: 5 } });
|
|
36
|
-
session.toggleMark('bold');
|
|
37
|
-
assert.equal(session.html, '<p><strong>Hello</strong></p>');
|
|
38
|
-
session.select({ anchor: { blockId: id, offset: 5 }, head: { blockId: id, offset: 5 } });
|
|
39
|
-
session.split('enter');
|
|
40
|
-
session.insertText('Next');
|
|
41
|
-
assert.equal(readBlocks(doc).length, 2);
|
|
42
|
-
assert.match(session.html, /Next/);
|
|
43
|
-
session.undo();
|
|
44
|
-
assert.equal(session.html.includes('Next'), false);
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
describe('toolbar', () => {
|
|
49
|
-
it('resolves presets and custom lists', () => {
|
|
50
|
-
assert.deepEqual(resolveToolbar('minimal'), ['bold', 'italic', 'link']);
|
|
51
|
-
assert.equal(resolveToolbar('writing').includes('quote'), true);
|
|
52
|
-
assert.equal(resolveToolbar('writing').includes('image'), false);
|
|
53
|
-
assert.equal(resolveToolbar('basic').includes('h1'), false);
|
|
54
|
-
assert.deepEqual(resolveToolbar(['link', 'bold', 'nope', 'bold']), ['link', 'bold']);
|
|
55
|
-
assert.deepEqual(resolveToolbar(undefined), resolveToolbar('full'));
|
|
56
|
-
assert.equal(resolveToolbar('full').includes('source'), true);
|
|
57
|
-
});
|
|
58
|
-
});
|
package/tsconfig.json
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "ES2022",
|
|
5
|
-
"moduleResolution": "bundler",
|
|
6
|
-
"lib": ["ES2022", "DOM"],
|
|
7
|
-
"strict": true,
|
|
8
|
-
"declaration": true,
|
|
9
|
-
"skipLibCheck": true,
|
|
10
|
-
"experimentalDecorators": true,
|
|
11
|
-
"useDefineForClassFields": false,
|
|
12
|
-
"importHelpers": true,
|
|
13
|
-
"isolatedModules": true,
|
|
14
|
-
"sourceMap": true,
|
|
15
|
-
"inlineSources": true,
|
|
16
|
-
"types": []
|
|
17
|
-
},
|
|
18
|
-
"angularCompilerOptions": {
|
|
19
|
-
"compilationMode": "partial",
|
|
20
|
-
"strictInjectionParameters": true,
|
|
21
|
-
"strictInputAccessModifiers": true,
|
|
22
|
-
"strictTemplates": true
|
|
23
|
-
},
|
|
24
|
-
"files": ["src/public-api.ts"],
|
|
25
|
-
"include": ["src/**/*.ts"],
|
|
26
|
-
"exclude": ["test/**"]
|
|
27
|
-
}
|