@aegisjsproject/md-editor
Version:
A custom Markdown editor component with preview as a form input element
402 lines (337 loc) • 10.4 kB
JavaScript
import { parse, createStyleSheet } from '@aegisjsproject/markdown';
import { registerComponent } from '@aegisjsproject/core/componentRegistry.js';
import { html } from '@aegisjsproject/core/parsers/html.js';
import { css } from '@aegisjsproject/core/parsers/css.js';
import { componentBase, componentDarkTheme, componentLightTheme } from '@aegisjsproject/styles/theme.js';
import { btn, btnSecondary, btnDanger } from '@aegisjsproject/styles/button.js';
const styles = css`:host(:not([hidden]):not([popover])) {
display: inline-block;
}
:host {
resize: both;
overflow: scroll;
font-size: 1rem;
}
#container, pre {
max-width: 100%;
overflow: auto;
}
#toolbar {
position: sticky;
top: 0;
width: 100%;
margin: 0;
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
}
#toolbar > li {
list-style: none;
display: inline-block;
}
#editor, #viewer {
min-height: 2em;
padding: 0.8em;
}
#editor:empty::before {
display: inline;
content: "Enter markdown content here";
}
.icon {
width: 1em;
height: 1em;
vertical-align: baseline;
}
(max-width: 800px) {
.mobile-hidden {
display: none;
}
}`;
const template = html`<div id="container" part="container">
<menu id="toolbar" role="toolbar" part="menubar" aria-label="Markdown Editor Views">
<li>
<button type="button" role="menuitem" id="editor-btn" class="btn btn-secondary" part="btn view-editor" data-mode="editor" title="Switch to Editor View" aria-label="Switch to Editor View" disabled="">
<slot name="editor-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="16" viewBox="0 0 12 16" part="btn-icon" class="icon" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M8.5 1H1c-.55 0-1 .45-1 1v12c0 .55.45 1 1 1h10c.55 0 1-.45 1-1V4.5L8.5 1zM11 14H1V2h7l3 3v9zM5 6.98L3.5 8.5 5 10l-.5 1L2 8.5 4.5 6l.5.98zM7.5 6L10 8.5 7.5 11l-.5-.98L8.5 8.5 7 7l.5-1z"/>
</svg>
</slot>
<span part="btn-label" class="mobile-hidden">
<slot name="editor-label">Editor</slot>
</span>
</button>
</li>
<li>
<button type="button" role="menuitem" id="viewer-btn" class="btn btn-secondary" part="btn view-viewer" data-mode="viewer" title="Switch to Viewer Mode" aria-label="Switch to Viewer Mode">
<slot name="viewer-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" part="btn-icon" class="icon" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M8.06 2C3 2 0 8 0 8s3 6 8.06 6C13 14 16 8 16 8s-3-6-7.94-6zM8 12c-2.2 0-4-1.78-4-4 0-2.2 1.8-4 4-4 2.22 0 4 1.8 4 4 0 2.22-1.78 4-4 4zm2-4c0 1.11-.89 2-2 2-1.11 0-2-.89-2-2 0-1.11.89-2 2-2 1.11 0 2 .89 2 2z"/>
</svg>
<span part="btn-label" class="mobile-hidden">
<slot name="viewer-label">Viewer</slot>
</span>
</button>
</li>
</menu>
<pre id="editor" part="editor" contenteditable="true" role="textarea"></pre>
<div id="viewer" part="viewer" hidden=""></div>
</div>`;
class HTMLMarkdownEditorElement extends HTMLElement {
#shadow = this.attachShadow({ mode: 'closed', delegatesFocus: true });
#internals = this.attachInternals();
#disabled = false;
#hasUpdated = false;
constructor() {
super();
this.#shadow.adoptedStyleSheets = [componentBase, componentLightTheme, componentDarkTheme, btn, btnSecondary, btnDanger, styles];
this.#shadow.append(
createStyleSheet('github', { media: '(prefers-color-scheme: light)' }),
createStyleSheet('github-dark', { media: '(prefers-color-scheme: dark)' }),
template.cloneNode(true)
);
this.#shadow.getElementById('toolbar').addEventListener('click', ({ target }) => {
if (target.matches('button, button *')) {
const btn = target.closest('button');
this.mode = btn.dataset.mode;
}
});
this.#editor.addEventListener('input', event => {
const target = event.target;
queueMicrotask(() => this.#setValue(target.innerText.trim()));
this.#hasUpdated = true;
}, { passive: true });
this.addEventListener('blur', () => {
if (this.#hasUpdated) {
this.dispatchEvent(new Event('change'));
this.#hasUpdated = false;
}
}, { passive: true });
this.#internals.role = 'textbox';
this.#internals.ariaMultiLine = 'true';
this.addEventListener('focus', () => {
if (this.mode !== 'editor') {
this.mode = 'editor';
}
this.#editor.focus();
}, { passive: true });
}
async attributeChangedCallback(attr, oldVal, newVal) {
if (oldVal !== newVal) {
const editor = this.#editor;
const viewer = this.#viewer;
switch(attr) {
case 'mode':
if (newVal === 'viewer') {
await this.#render();
editor.hidden = true;
viewer.hidden = false;
this.#internals.role = 'document';
this.#internals.ariaMultiLine = 'false';
this.#shadow.getElementById('viewer-btn').disabled = true;
this.#shadow.getElementById('editor-btn').disabled = false;
viewer.ariaLive = 'polite';
} else {
editor.hidden = false;
viewer.hidden = true;
this.#internals.role = 'textbox';
this.#internals.ariaMultiLine = 'true';
this.#shadow.getElementById('viewer-btn').disabled = false;
this.#shadow.getElementById('editor-btn').disabled = true;
viewer.ariaLive = null;
editor.focus();
}
break;
case 'required':
if (typeof newVal === 'string' && this.value.length === 0) {
this.#internals.setValidity({ valueMissing: true }, 'This is a required field.', this.#editor);
}
break;
case 'readonly':
this.#editor.contentEditable = (this.#disabled || typeof newVal === 'string') ? 'false' : 'true';
break;
}
}
}
connectedCallback() {
if (this.#editor.textContent.length === 0) {
this.#getInitialValue();
}
}
formDisabledCallback(disabled) {
this.#disabled = disabled;
this.#editor.contentEditable = (disabled || this.readOnly) ? 'false' : 'true';
}
formResetCallback() {
if (! this.#getInitialValue()) {
this.value = '';
}
}
formStateRestoreCallback(value) {
this.value = value;
}
get disabled() {
return this.hasAttribute('disabled');
}
set disabled(val) {
this.toggleAttribute('disabled', val);
}
get form() {
return this.#internals.form;
}
get labels() {
return this.#internals.labels;
}
get maxLength() {
if (this.hasAttribute('maxlength')) {
return Math.max(parseInt(this.getAttribute('maxlength'), -1)) || -1;
} else {
return -1;
}
}
set maxLength(val) {
if (typeof val === 'string') {
this.maxLength = parseInt(val);
} else if (Number.isSafeInteger(val) && val > -1) {
this.setAttribute('maxlength', val);
} else {
this.removeAttribute('maxlength');
}
}
get minLength() {
if (this.hasAttribute('minlength')) {
return Math.max(parseInt(this.getAttribute('minlength'), 0)) || -1;
} else {
return -1;
}
}
set minLength(val) {
if (typeof val === 'string') {
this.minLength = parseInt(val);
} else if (Number.isSafeInteger(val) && val > -1) {
this.setAttribute('minlength', val);
} else {
this.removeAttribute('minlength');
}
}
get mode() {
return this.getAttribute('mode') ?? 'editor';
}
set mode(val) {
if (typeof val !== 'string') {
this.removeAttribute('mode');
} else if (! ['editor', 'viewer'].includes(val)) {
throw new TypeError(`Invalid view option: "${val}".`);
} else {
this.setAttribute('mode', val);
}
}
get name() {
return this.getAttribute('name');
}
set name(val) {
if (typeof val === 'string') {
this.setAttribute('name', val);
} else {
this.removeAttribute('name');
}
}
get readOnly() {
return this.hasAttribute('readonly');
}
set readOnly(val) {
this.toggleAttribute('readonly', val);
}
get required() {
return this.hasAttribute('required');
}
set required(val) {
this.toggleAttribute('required', val);
}
get validationMessage() {
return this.#internals.validationMessage;
}
get validity() {
return this.#internals.validity;
}
get value() {
return this.#editor.innerText;
}
set value(val) {
if (typeof val !== 'string') {
throw new TypeError('Cannot set markdown content to non-string.');
} else {
this.#editor.innerText = val;
this.#setValue(val);
if (this.mode === 'viewer') {
this.#render();
}
}
}
get willValidate() {
return this.#internals.willValidate;
}
get html() {
return parse(this.value);
}
get #editor() {
return this.#shadow.getElementById('editor');
}
get #viewer() {
return this.#shadow.getElementById('viewer');
}
checkValidity() {
return this.#internals.checkValidity();
}
reportValidity() {
return this.#internals.reportValidity();
}
toBlob({ lastModified } = {}) {
return new Blob([this.value], { type: 'text/markdown', lastModified });
}
toFile(name, { lastModified } = {}) {
return new File([this.value], name, { type: 'text/markdown', lastModified });
}
async importBlob(blob) {
if (! (blob instanceof Blob)) {
throw new TypeError('Cannot import from non-blob object.');
} else {
const md = await blob.text();
this.value = md;
return md;
}
}
async #render() {
await scheduler.yield();
this.#viewer.replaceChildren(this.html);
}
#setValue(val) {
this.#internals.setFormValue(val);
const { required, minLength, maxLength } = this;
const anchor = this.#editor;
if (required && val.trim().length === 0) {
this.#internals.setValidity({ valueMissing: true }, 'This is a required field.', anchor);
} else if (val.length < minLength) {
this.#internals.setValidity({ tooShort: true }, `This requires a length of at least ${minLength} characters`, anchor);
} else if (maxLength > -1 && val.length > maxLength) {
this.#internals.setValidity({ tooLong: true }, `This requires a length of at most ${maxLength} characters`, anchor);
} else {
this.#internals.setValidity({});
}
}
#getInitialValue() {
if (this.textContent.length !== 0) {
const content = this.textContent.startsWith('\n') ? String.dedent(this.textContent).trim() : this.textContent.trim();
this.#editor.textContent = content;
this.#setValue(content);
return true;
} else {
return false;
}
}
static get formAssociated() {
return true;
}
static get observedAttributes() {
return ['mode', 'readonly', 'required'];
}
}
registerComponent('md-editor', HTMLMarkdownEditorElement);