@kubex/zinc 1.0.103 → 1.0.104
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/dist/custom-elements.json +520 -1
- package/dist/vscode.html-custom-data.json +67 -0
- package/dist/web-types.json +182 -1
- package/dist/zn.d.ts +101 -0
- package/dist/zn.min.js +476 -345
- package/docs/pages/components/markdown-editor.md +301 -0
- package/package.json +2 -1
- package/src/components/markdown-editor/index.ts +12 -0
- package/src/components/markdown-editor/markdown-editor.component.ts +332 -0
- package/src/components/markdown-editor/markdown-editor.scss +212 -0
- package/src/components/markdown-editor/markdown-editor.test.ts +22 -0
- package/src/zinc.ts +1 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
---
|
|
2
|
+
meta:
|
|
3
|
+
title: Markdown Editor
|
|
4
|
+
description: A markdown editor with live preview, split view, and fullscreen mode.
|
|
5
|
+
layout: component
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
`zn-markdown-editor` wraps the full markdown authoring workflow in a single
|
|
9
|
+
form control: a textarea for writing, a live preview rendered with
|
|
10
|
+
[`marked`](https://marked.js.org/) (bundled with the component), toggleable
|
|
11
|
+
editor / split / preview modes, a fullscreen toggle, and `localStorage`
|
|
12
|
+
persistence for the selected view.
|
|
13
|
+
|
|
14
|
+
```html:preview
|
|
15
|
+
<zn-markdown-editor
|
|
16
|
+
label="Content"
|
|
17
|
+
name="content"
|
|
18
|
+
help-text="Supports GitHub-flavored markdown"
|
|
19
|
+
value="# Hello
|
|
20
|
+
|
|
21
|
+
Write some **markdown** on the left — use the toolbar to switch to split or preview."
|
|
22
|
+
></zn-markdown-editor>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Examples
|
|
26
|
+
|
|
27
|
+
### Initial Value via Attribute
|
|
28
|
+
|
|
29
|
+
Set the initial markdown with the `value` attribute.
|
|
30
|
+
|
|
31
|
+
```html:preview
|
|
32
|
+
<zn-markdown-editor
|
|
33
|
+
label="Article"
|
|
34
|
+
name="article"
|
|
35
|
+
value="## Release notes
|
|
36
|
+
|
|
37
|
+
- New payments dashboard
|
|
38
|
+
- Faster settlement reporting"
|
|
39
|
+
></zn-markdown-editor>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Initial Value via Light DOM
|
|
43
|
+
|
|
44
|
+
If no `value` attribute is set, `zn-markdown-editor` reads its initial markdown
|
|
45
|
+
from its own text-node children. This is handy when rendering server-side
|
|
46
|
+
content into the tag without escaping quotes:
|
|
47
|
+
|
|
48
|
+
```html:preview
|
|
49
|
+
<zn-markdown-editor name="content" label="Terms">
|
|
50
|
+
# Terms of Service
|
|
51
|
+
|
|
52
|
+
This copy was rendered from a server-side template and picked up automatically.
|
|
53
|
+
</zn-markdown-editor>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Setting the Default View
|
|
57
|
+
|
|
58
|
+
Use the `view-mode` attribute to open directly in `editor` (default), `split`,
|
|
59
|
+
or `preview`.
|
|
60
|
+
|
|
61
|
+
```html:preview
|
|
62
|
+
<zn-markdown-editor
|
|
63
|
+
name="content"
|
|
64
|
+
label="Split by default"
|
|
65
|
+
view-mode="split"
|
|
66
|
+
value="## Split view
|
|
67
|
+
|
|
68
|
+
See both at once."
|
|
69
|
+
></zn-markdown-editor>
|
|
70
|
+
|
|
71
|
+
<br />
|
|
72
|
+
|
|
73
|
+
<zn-markdown-editor
|
|
74
|
+
name="content"
|
|
75
|
+
label="Preview by default"
|
|
76
|
+
view-mode="preview"
|
|
77
|
+
value="## Preview view
|
|
78
|
+
|
|
79
|
+
Toggle to the editor to edit."
|
|
80
|
+
></zn-markdown-editor>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Persisting the Selected View
|
|
84
|
+
|
|
85
|
+
The chosen view mode is persisted in `localStorage` under the key set by
|
|
86
|
+
`storage-key` (default `zn-markdown-editor-view-mode`). Use distinct keys
|
|
87
|
+
when multiple editors should remember their own preference.
|
|
88
|
+
|
|
89
|
+
```html:preview
|
|
90
|
+
<zn-markdown-editor
|
|
91
|
+
name="email_template"
|
|
92
|
+
label="Email template"
|
|
93
|
+
storage-key="email-template-view"
|
|
94
|
+
></zn-markdown-editor>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
To opt out of persistence, set `storage-key` to an empty string:
|
|
98
|
+
|
|
99
|
+
```html
|
|
100
|
+
|
|
101
|
+
<zn-markdown-editor storage-key=""></zn-markdown-editor>
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Help Text
|
|
105
|
+
|
|
106
|
+
Add descriptive help text with the `help-text` attribute, or use the slot for
|
|
107
|
+
HTML content.
|
|
108
|
+
|
|
109
|
+
```html:preview
|
|
110
|
+
<zn-markdown-editor
|
|
111
|
+
label="Release notes"
|
|
112
|
+
name="notes"
|
|
113
|
+
help-text="Use headings, lists, and code fences for emphasis."
|
|
114
|
+
></zn-markdown-editor>
|
|
115
|
+
|
|
116
|
+
<br />
|
|
117
|
+
|
|
118
|
+
<zn-markdown-editor label="Release notes" name="notes">
|
|
119
|
+
<div slot="help-text">
|
|
120
|
+
See the <a href="https://commonmark.org/help/">CommonMark cheatsheet</a>
|
|
121
|
+
for a full syntax reference.
|
|
122
|
+
</div>
|
|
123
|
+
</zn-markdown-editor>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Rows
|
|
127
|
+
|
|
128
|
+
Use `rows` to change the initial height of the editor before it overflows.
|
|
129
|
+
Default is `20`.
|
|
130
|
+
|
|
131
|
+
```html:preview
|
|
132
|
+
<zn-markdown-editor label="Short editor" rows="6"></zn-markdown-editor>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Required, Readonly, Disabled
|
|
136
|
+
|
|
137
|
+
`zn-markdown-editor` forwards standard form-control state onto its underlying
|
|
138
|
+
textarea.
|
|
139
|
+
|
|
140
|
+
```html:preview
|
|
141
|
+
<zn-markdown-editor label="Required" name="content" required></zn-markdown-editor>
|
|
142
|
+
|
|
143
|
+
<br />
|
|
144
|
+
|
|
145
|
+
<zn-markdown-editor
|
|
146
|
+
label="Readonly"
|
|
147
|
+
name="content"
|
|
148
|
+
readonly
|
|
149
|
+
value="## Read-only content
|
|
150
|
+
|
|
151
|
+
Visible but not editable."
|
|
152
|
+
></zn-markdown-editor>
|
|
153
|
+
|
|
154
|
+
<br />
|
|
155
|
+
|
|
156
|
+
<zn-markdown-editor
|
|
157
|
+
label="Disabled"
|
|
158
|
+
name="content"
|
|
159
|
+
disabled
|
|
160
|
+
value="Disabled editor."
|
|
161
|
+
></zn-markdown-editor>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Form Integration
|
|
165
|
+
|
|
166
|
+
`zn-markdown-editor` is a [form control](/getting-started/form-controls), so
|
|
167
|
+
its value is submitted with the surrounding form.
|
|
168
|
+
|
|
169
|
+
```html:preview
|
|
170
|
+
<form class="markdown-editor-form">
|
|
171
|
+
<zn-markdown-editor
|
|
172
|
+
name="content"
|
|
173
|
+
label="Document"
|
|
174
|
+
required
|
|
175
|
+
value="## Getting started
|
|
176
|
+
|
|
177
|
+
Write your content here."
|
|
178
|
+
></zn-markdown-editor>
|
|
179
|
+
<br />
|
|
180
|
+
<zn-button type="submit" color="success">Submit</zn-button>
|
|
181
|
+
<zn-button type="reset" color="secondary">Reset</zn-button>
|
|
182
|
+
</form>
|
|
183
|
+
|
|
184
|
+
<script type="module">
|
|
185
|
+
const form = document.querySelector('.markdown-editor-form');
|
|
186
|
+
|
|
187
|
+
await customElements.whenDefined('zn-button');
|
|
188
|
+
await customElements.whenDefined('zn-markdown-editor');
|
|
189
|
+
|
|
190
|
+
form.addEventListener('submit', (e) => {
|
|
191
|
+
e.preventDefault();
|
|
192
|
+
const data = Object.fromEntries(new FormData(form));
|
|
193
|
+
alert('Submitted!\n\n' + JSON.stringify(data, null, 2));
|
|
194
|
+
});
|
|
195
|
+
</script>
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Reacting to Events
|
|
199
|
+
|
|
200
|
+
Listen for content and view-mode changes.
|
|
201
|
+
|
|
202
|
+
```html:preview
|
|
203
|
+
<zn-markdown-editor
|
|
204
|
+
id="event-editor"
|
|
205
|
+
name="content"
|
|
206
|
+
label="Event demo"
|
|
207
|
+
value="Type here..."
|
|
208
|
+
></zn-markdown-editor>
|
|
209
|
+
|
|
210
|
+
<div id="event-log" style="margin-top: 1rem; padding: 1rem; background: var(--zn-color-neutral-100); border-radius: 4px; font-family: monospace; font-size: 0.875rem; max-height: 200px; overflow-y: auto;">
|
|
211
|
+
Events will appear here...
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<script type="module">
|
|
215
|
+
const editor = document.getElementById('event-editor');
|
|
216
|
+
const log = document.getElementById('event-log');
|
|
217
|
+
|
|
218
|
+
await customElements.whenDefined('zn-markdown-editor');
|
|
219
|
+
|
|
220
|
+
function push(name, detail = '') {
|
|
221
|
+
const t = new Date().toLocaleTimeString();
|
|
222
|
+
log.innerHTML = `[${t}] ${name}${detail ? ': ' + detail : ''}<br>` + log.innerHTML;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
editor.addEventListener('zn-input', () => push('zn-input', `length=${editor.value.length}`));
|
|
226
|
+
editor.addEventListener('zn-change', () => push('zn-change'));
|
|
227
|
+
editor.addEventListener('zn-view-mode-change', (e) => push('zn-view-mode-change', e.detail.mode));
|
|
228
|
+
</script>
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Programmatic Control
|
|
232
|
+
|
|
233
|
+
`focus()`, `blur()`, `checkValidity()`, and `reportValidity()` are all
|
|
234
|
+
available on the element.
|
|
235
|
+
|
|
236
|
+
```html:preview
|
|
237
|
+
<zn-markdown-editor
|
|
238
|
+
id="control-editor"
|
|
239
|
+
name="content"
|
|
240
|
+
label="Controlled editor"
|
|
241
|
+
value="## Controlled"
|
|
242
|
+
></zn-markdown-editor>
|
|
243
|
+
<br />
|
|
244
|
+
<zn-button id="editor-focus">Focus</zn-button>
|
|
245
|
+
<zn-button id="editor-blur">Blur</zn-button>
|
|
246
|
+
<zn-button id="editor-split">Switch to split</zn-button>
|
|
247
|
+
<zn-button id="editor-preview">Switch to preview</zn-button>
|
|
248
|
+
<zn-button id="editor-clear" color="error">Clear</zn-button>
|
|
249
|
+
|
|
250
|
+
<script type="module">
|
|
251
|
+
const editor = document.getElementById('control-editor');
|
|
252
|
+
|
|
253
|
+
await customElements.whenDefined('zn-button');
|
|
254
|
+
await customElements.whenDefined('zn-markdown-editor');
|
|
255
|
+
|
|
256
|
+
document.getElementById('editor-focus').addEventListener('click', () => editor.focus());
|
|
257
|
+
document.getElementById('editor-blur').addEventListener('click', () => editor.blur());
|
|
258
|
+
document.getElementById('editor-split').addEventListener('click', () => { editor.viewMode = 'split'; });
|
|
259
|
+
document.getElementById('editor-preview').addEventListener('click', () => { editor.viewMode = 'preview'; });
|
|
260
|
+
document.getElementById('editor-clear').addEventListener('click', () => { editor.value = ''; });
|
|
261
|
+
</script>
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### Fullscreen Mode
|
|
265
|
+
|
|
266
|
+
Click the fullscreen icon in the top-right to expand the editor to cover its
|
|
267
|
+
nearest positioned ancestor. Click again to collapse. This is driven by the
|
|
268
|
+
`expanded` state on the element, so you can also toggle it programmatically:
|
|
269
|
+
|
|
270
|
+
```html
|
|
271
|
+
|
|
272
|
+
<zn-markdown-editor id="fs-editor"></zn-markdown-editor>
|
|
273
|
+
|
|
274
|
+
<script>
|
|
275
|
+
document.getElementById('fs-editor').expanded = true;
|
|
276
|
+
</script>
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
## Events
|
|
280
|
+
|
|
281
|
+
| Event | Detail | Description |
|
|
282
|
+
|-----------------------|----------------------------------------------|---------------------------------------------|
|
|
283
|
+
| `zn-input` | – | Fired on every keystroke in the editor. |
|
|
284
|
+
| `zn-change` | – | Fired when the editor's value is committed. |
|
|
285
|
+
| `zn-view-mode-change` | `{ mode: 'editor' \| 'split' \| 'preview' }` | Fired when the user switches view modes. |
|
|
286
|
+
|
|
287
|
+
## Slots
|
|
288
|
+
|
|
289
|
+
| Slot | Description |
|
|
290
|
+
|-------------|---------------------------------------------------------------------|
|
|
291
|
+
| `label` | Custom HTML label. Alternatively use the `label` attribute. |
|
|
292
|
+
| `help-text` | Custom HTML help text. Alternatively use the `help-text` attribute. |
|
|
293
|
+
|
|
294
|
+
## CSS Parts
|
|
295
|
+
|
|
296
|
+
| Part | Description |
|
|
297
|
+
|-----------|-------------------------------------|
|
|
298
|
+
| `base` | The component's base wrapper. |
|
|
299
|
+
| `toolbar` | The view-mode / fullscreen toolbar. |
|
|
300
|
+
| `editor` | The textarea wrapper. |
|
|
301
|
+
| `preview` | The rendered markdown preview pane. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubex/zinc",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.104",
|
|
4
4
|
"description": "A collection of web components for building web applications based off of @shoelace-style/Shoelace",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"web components",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"composed-offset-position": "^0.0.6",
|
|
52
52
|
"emoji-mart": "^5.6.0",
|
|
53
53
|
"lit": "^2.7.6",
|
|
54
|
+
"marked": "^18.0.2",
|
|
54
55
|
"quill": "^2.0.3"
|
|
55
56
|
},
|
|
56
57
|
"devDependencies": {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import ZnMarkdownEditor from './markdown-editor.component';
|
|
2
|
+
|
|
3
|
+
export * from './markdown-editor.component';
|
|
4
|
+
export default ZnMarkdownEditor;
|
|
5
|
+
|
|
6
|
+
ZnMarkdownEditor.define('zn-markdown-editor');
|
|
7
|
+
|
|
8
|
+
declare global {
|
|
9
|
+
interface HTMLElementTagNameMap {
|
|
10
|
+
'zn-markdown-editor': ZnMarkdownEditor;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import {classMap} from "lit/directives/class-map.js";
|
|
2
|
+
import {type CSSResultGroup, html, type PropertyValues, unsafeCSS} from 'lit';
|
|
3
|
+
import {defaultValue} from "../../internal/default-value";
|
|
4
|
+
import {FormControlController} from "../../internal/form";
|
|
5
|
+
import {HasSlotController} from "../../internal/slot";
|
|
6
|
+
import {marked} from "marked";
|
|
7
|
+
import {property, query, state} from 'lit/decorators.js';
|
|
8
|
+
import {watch} from "../../internal/watch";
|
|
9
|
+
import ZincElement from '../../internal/zinc-element';
|
|
10
|
+
import type {ZincFormControl} from '../../internal/zinc-element';
|
|
11
|
+
import type ZnTextarea from "../textarea";
|
|
12
|
+
|
|
13
|
+
import styles from './markdown-editor.scss';
|
|
14
|
+
|
|
15
|
+
type ViewMode = 'editor' | 'split' | 'preview';
|
|
16
|
+
|
|
17
|
+
const VIEW_MODES: {mode: ViewMode; icon: string; label: string}[] = [
|
|
18
|
+
{mode: 'editor', icon: 'edit_note', label: 'Editor'},
|
|
19
|
+
{mode: 'split', icon: 'vertical_split', label: 'Split'},
|
|
20
|
+
{mode: 'preview', icon: 'visibility', label: 'Preview'},
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @summary A markdown editor with live preview, split view, and a fullscreen mode.
|
|
25
|
+
* @documentation https://zinc.style/components/markdown-editor
|
|
26
|
+
* @status experimental
|
|
27
|
+
* @since 1.0
|
|
28
|
+
*
|
|
29
|
+
* @dependency zn-textarea
|
|
30
|
+
* @dependency zn-button-group
|
|
31
|
+
* @dependency zn-icon
|
|
32
|
+
*
|
|
33
|
+
* @event zn-change - Emitted when the markdown content changes.
|
|
34
|
+
* @event zn-input - Emitted on each keystroke in the editor.
|
|
35
|
+
* @event zn-view-mode-change - Emitted when the user switches between editor / split / preview.
|
|
36
|
+
*
|
|
37
|
+
* @slot label - The editor label. Alternatively, use the `label` attribute.
|
|
38
|
+
* @slot help-text - Help text shown below the editor. Alternatively, use the `help-text` attribute.
|
|
39
|
+
*
|
|
40
|
+
* @csspart base - The component's base wrapper.
|
|
41
|
+
* @csspart toolbar - The toolbar containing the view-mode and fullscreen controls.
|
|
42
|
+
* @csspart editor - The textarea wrapper.
|
|
43
|
+
* @csspart preview - The rendered markdown preview.
|
|
44
|
+
*/
|
|
45
|
+
export default class ZnMarkdownEditor extends ZincElement implements ZincFormControl {
|
|
46
|
+
static styles: CSSResultGroup = unsafeCSS(styles);
|
|
47
|
+
|
|
48
|
+
private readonly formControlController = new FormControlController(this, {
|
|
49
|
+
assumeInteractionOn: ['zn-input', 'zn-change'],
|
|
50
|
+
});
|
|
51
|
+
private readonly hasSlotController = new HasSlotController(this, 'label', 'help-text');
|
|
52
|
+
|
|
53
|
+
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
54
|
+
|
|
55
|
+
@query('zn-textarea') textarea: ZnTextarea;
|
|
56
|
+
@query('.markdown-editor__preview') previewEl: HTMLDivElement;
|
|
57
|
+
|
|
58
|
+
/** The name of the control, submitted as part of form data. */
|
|
59
|
+
@property() name = '';
|
|
60
|
+
|
|
61
|
+
/** The current markdown content. */
|
|
62
|
+
@property() value = '';
|
|
63
|
+
|
|
64
|
+
/** The default value — used when resetting the form. */
|
|
65
|
+
@defaultValue() defaultValue = '';
|
|
66
|
+
|
|
67
|
+
/** The control's label. If you need HTML, use the `label` slot. */
|
|
68
|
+
@property() label = '';
|
|
69
|
+
|
|
70
|
+
/** Help text displayed below the editor. If you need HTML, use the `help-text` slot. */
|
|
71
|
+
@property({attribute: 'help-text'}) helpText = '';
|
|
72
|
+
|
|
73
|
+
/** Placeholder text shown when the editor is empty. */
|
|
74
|
+
@property() placeholder = 'Enter markdown content...';
|
|
75
|
+
|
|
76
|
+
/** Number of rows for the textarea. */
|
|
77
|
+
@property({type: Number}) rows = 20;
|
|
78
|
+
|
|
79
|
+
/** Which view to show. */
|
|
80
|
+
@property({reflect: true, attribute: 'view-mode'})
|
|
81
|
+
viewMode: ViewMode = 'editor';
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Key used to persist the selected view mode to `localStorage`. Set to an empty string to disable persistence.
|
|
85
|
+
*/
|
|
86
|
+
@property({attribute: 'storage-key'}) storageKey = 'zn-markdown-editor-view-mode';
|
|
87
|
+
|
|
88
|
+
/** Makes the editor required for form submission. */
|
|
89
|
+
@property({type: Boolean, reflect: true}) required = false;
|
|
90
|
+
|
|
91
|
+
/** Makes the editor read-only. */
|
|
92
|
+
@property({type: Boolean, reflect: true}) readonly = false;
|
|
93
|
+
|
|
94
|
+
/** Disables the editor. */
|
|
95
|
+
@property({type: Boolean, reflect: true}) disabled = false;
|
|
96
|
+
|
|
97
|
+
/** Whether the editor is currently expanded to cover its containing positioned ancestor. */
|
|
98
|
+
@state() expanded = false;
|
|
99
|
+
|
|
100
|
+
get validity(): ValidityState {
|
|
101
|
+
return this.textarea?.validity;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
get validationMessage(): string {
|
|
105
|
+
return this.textarea?.validationMessage ?? '';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
checkValidity(): boolean {
|
|
109
|
+
return this.textarea?.checkValidity() ?? true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
getForm(): HTMLFormElement | null {
|
|
113
|
+
return this.formControlController.getForm();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
reportValidity(): boolean {
|
|
117
|
+
return this.textarea?.reportValidity() ?? true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
setCustomValidity(message: string): void {
|
|
121
|
+
this.textarea?.setCustomValidity(message);
|
|
122
|
+
this.formControlController.updateValidity();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Sets focus on the editor. */
|
|
126
|
+
focus(options?: FocusOptions) {
|
|
127
|
+
this.textarea?.focus(options);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Removes focus from the editor. */
|
|
131
|
+
blur() {
|
|
132
|
+
this.textarea?.blur();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
connectedCallback() {
|
|
136
|
+
super.connectedCallback();
|
|
137
|
+
|
|
138
|
+
// Pull initial value from light-DOM text content if no value attr/property is set.
|
|
139
|
+
if (!this.hasAttribute('value') && !this.value) {
|
|
140
|
+
const textNodes = Array.from(this.childNodes).filter(n => n.nodeType === Node.TEXT_NODE);
|
|
141
|
+
const raw = textNodes.map(n => n.textContent ?? '').join('');
|
|
142
|
+
const content = raw.replace(/\r\n/g, '\n').trim();
|
|
143
|
+
if (content.length > 0) {
|
|
144
|
+
this.value = content;
|
|
145
|
+
this.defaultValue = content;
|
|
146
|
+
textNodes.forEach(n => {
|
|
147
|
+
if ((n.textContent ?? '').trim().length > 0) n.parentNode?.removeChild(n);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const stored = this.readStoredViewMode();
|
|
153
|
+
if (stored) this.viewMode = stored;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
disconnectedCallback() {
|
|
157
|
+
super.disconnectedCallback();
|
|
158
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
protected firstUpdated(_changedProperties: PropertyValues) {
|
|
162
|
+
void _changedProperties;
|
|
163
|
+
this.formControlController.updateValidity();
|
|
164
|
+
if (this.viewMode !== 'editor') this.renderPreview();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private readStoredViewMode(): ViewMode | null {
|
|
168
|
+
if (!this.storageKey) return null;
|
|
169
|
+
try {
|
|
170
|
+
const saved = localStorage.getItem(this.storageKey);
|
|
171
|
+
if (saved === 'editor' || saved === 'split' || saved === 'preview') return saved;
|
|
172
|
+
} catch {
|
|
173
|
+
// localStorage not available
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private writeStoredViewMode(mode: ViewMode) {
|
|
179
|
+
if (!this.storageKey) return;
|
|
180
|
+
try {
|
|
181
|
+
localStorage.setItem(this.storageKey, mode);
|
|
182
|
+
} catch {
|
|
183
|
+
// ignore
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private renderPreview() {
|
|
188
|
+
if (this.viewMode === 'editor') return;
|
|
189
|
+
if (!this.previewEl) return;
|
|
190
|
+
const parsed = marked.parse(this.value || '', {async: false});
|
|
191
|
+
this.previewEl.innerHTML = typeof parsed === 'string' ? parsed : '';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private handleInput = () => {
|
|
195
|
+
this.value = this.textarea.value;
|
|
196
|
+
this.emit('zn-input');
|
|
197
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
198
|
+
this.debounceTimer = setTimeout(() => this.renderPreview(), 150);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
private handleChange = () => {
|
|
202
|
+
this.value = this.textarea.value;
|
|
203
|
+
this.emit('zn-change');
|
|
204
|
+
this.renderPreview();
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
private handleViewToggle = (e: Event) => {
|
|
208
|
+
const host = (e.target as HTMLElement).closest<HTMLElement>('[data-mode]');
|
|
209
|
+
const mode = host?.dataset.mode as ViewMode | undefined;
|
|
210
|
+
if (!mode || mode === this.viewMode) return;
|
|
211
|
+
this.viewMode = mode;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
private handleExpandToggle = () => {
|
|
215
|
+
this.expanded = !this.expanded;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
@watch('viewMode', {waitUntilFirstUpdate: true})
|
|
219
|
+
async handleViewModeChange() {
|
|
220
|
+
await this.updateComplete;
|
|
221
|
+
this.writeStoredViewMode(this.viewMode);
|
|
222
|
+
this.renderPreview();
|
|
223
|
+
this.dispatchEvent(new CustomEvent('zn-view-mode-change', {
|
|
224
|
+
bubbles: true,
|
|
225
|
+
composed: true,
|
|
226
|
+
detail: {mode: this.viewMode},
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
@watch('value', {waitUntilFirstUpdate: true})
|
|
231
|
+
async handleValueChange() {
|
|
232
|
+
await this.updateComplete;
|
|
233
|
+
this.formControlController.updateValidity();
|
|
234
|
+
this.renderPreview();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
@watch('markedReady', {waitUntilFirstUpdate: true})
|
|
238
|
+
handleMarkedReady() {
|
|
239
|
+
this.renderPreview();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
render() {
|
|
243
|
+
const hasLabelSlot = this.hasSlotController.test('label');
|
|
244
|
+
const hasHelpSlot = this.hasSlotController.test('help-text');
|
|
245
|
+
const hasLabel = !!this.label || hasLabelSlot;
|
|
246
|
+
const hasHelpText = !!this.helpText || hasHelpSlot;
|
|
247
|
+
const showEditor = this.viewMode === 'editor' || this.viewMode === 'split';
|
|
248
|
+
const showPreview = this.viewMode === 'preview' || this.viewMode === 'split';
|
|
249
|
+
|
|
250
|
+
return html`
|
|
251
|
+
<div part="base"
|
|
252
|
+
class=${classMap({
|
|
253
|
+
'markdown-editor': true,
|
|
254
|
+
'markdown-editor--expanded': this.expanded,
|
|
255
|
+
'markdown-editor--split': this.viewMode === 'split',
|
|
256
|
+
'markdown-editor--preview-only': this.viewMode === 'preview',
|
|
257
|
+
})}>
|
|
258
|
+
${hasLabel ? html`
|
|
259
|
+
<label class="markdown-editor__label">
|
|
260
|
+
<slot name="label">${this.label}</slot>
|
|
261
|
+
</label>` : ''}
|
|
262
|
+
|
|
263
|
+
<div part="toolbar" class="markdown-editor__toolbar">
|
|
264
|
+
<zn-button-group class="markdown-editor__view-toggle" @click=${this.handleViewToggle}>
|
|
265
|
+
${VIEW_MODES.map(v => this.renderViewButton(v.mode, v.icon, v.label))}
|
|
266
|
+
</zn-button-group>
|
|
267
|
+
<zn-button
|
|
268
|
+
class="markdown-editor__expand-btn"
|
|
269
|
+
type="button"
|
|
270
|
+
size="medium"
|
|
271
|
+
color="secondary"
|
|
272
|
+
icon=${this.expanded ? 'close_fullscreen' : 'open_in_full'}
|
|
273
|
+
icon-size="18"
|
|
274
|
+
tooltip=${this.expanded ? 'Collapse' : 'Expand'}
|
|
275
|
+
@click=${this.handleExpandToggle}
|
|
276
|
+
></zn-button>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<div class="markdown-editor__body">
|
|
280
|
+
<div part="editor"
|
|
281
|
+
class="markdown-editor__editor"
|
|
282
|
+
?hidden=${!showEditor}>
|
|
283
|
+
<zn-textarea
|
|
284
|
+
.value=${this.value}
|
|
285
|
+
name=${this.name || ''}
|
|
286
|
+
placeholder=${this.placeholder}
|
|
287
|
+
rows=${this.rows}
|
|
288
|
+
resize="auto"
|
|
289
|
+
?required=${this.required}
|
|
290
|
+
?readonly=${this.readonly}
|
|
291
|
+
?disabled=${this.disabled}
|
|
292
|
+
@zn-input=${this.handleInput}
|
|
293
|
+
@zn-change=${this.handleChange}
|
|
294
|
+
></zn-textarea>
|
|
295
|
+
</div>
|
|
296
|
+
<div part="preview"
|
|
297
|
+
class="markdown-editor__preview-wrapper"
|
|
298
|
+
?hidden=${!showPreview}>
|
|
299
|
+
<div class="markdown-editor__preview"></div>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
|
|
303
|
+
${hasHelpText ? html`
|
|
304
|
+
<div class="markdown-editor__help-text">
|
|
305
|
+
<slot name="help-text">${this.helpText}</slot>
|
|
306
|
+
</div>` : ''}
|
|
307
|
+
</div>
|
|
308
|
+
`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private renderViewButton(mode: ViewMode, icon: string, label: string) {
|
|
312
|
+
const isActive = this.viewMode === mode;
|
|
313
|
+
return html`
|
|
314
|
+
<zn-button
|
|
315
|
+
class=${classMap({
|
|
316
|
+
'markdown-editor__view-btn': true,
|
|
317
|
+
'markdown-editor__view-btn--active': isActive,
|
|
318
|
+
})}
|
|
319
|
+
data-mode=${mode}
|
|
320
|
+
type="button"
|
|
321
|
+
size="medium"
|
|
322
|
+
color="default"
|
|
323
|
+
?outline=${!isActive}
|
|
324
|
+
square
|
|
325
|
+
icon=${icon}
|
|
326
|
+
icon-size="18"
|
|
327
|
+
tooltip=${label}
|
|
328
|
+
aria-pressed=${isActive ? 'true' : 'false'}
|
|
329
|
+
></zn-button>
|
|
330
|
+
`;
|
|
331
|
+
}
|
|
332
|
+
}
|