diff options
| author | Shulhan <ms@kilabit.info> | 2023-11-05 00:39:45 +0700 |
|---|---|---|
| committer | Shulhan <ms@kilabit.info> | 2023-11-05 01:45:11 +0700 |
| commit | 65eb8e99d6dfdfdf419254cbb7e26b7afe426691 (patch) | |
| tree | e4585381101d9357e3b84f64e906d93111f10540 | |
| parent | c60be07a107a655b63dd7aa4a3bc740591f9ca54 (diff) | |
| download | pakakeh.ts-65eb8e99d6dfdfdf419254cbb7e26b7afe426691.tar.xz | |
editor: simplify using one contenteditable
Using multiple lines of content editable is hard, especially when
involving selection, deletion, and managing undo-redo history.
| -rw-r--r-- | editor/editor.ts | 863 | ||||
| -rw-r--r-- | editor/example.ts | 20 | ||||
| -rw-r--r-- | editor/index.html | 5 |
3 files changed, 138 insertions, 750 deletions
diff --git a/editor/editor.ts b/editor/editor.ts index 962ebb5..620d6b8 100644 --- a/editor/editor.ts +++ b/editor/editor.ts @@ -4,16 +4,12 @@ import { WuiVfsNodeInterface } from "../vfs/vfs"; const WUI_EDITOR_CLASS = "wui_editor"; -const WUI_EDITOR_CLASS_LINE = "wui_editor_line"; const WUI_EDITOR_CLASS_LINE_NUMBER = "wui_editor_line_number"; -const WUI_EDITOR_CLASS_LINE_TEXT = "wui_editor_line_text"; +const WUI_EDITOR_CLASS_CONTENT = "wui_editor_content"; export interface WuiEditorOptions { id: string; - is_editable: boolean; - - // Handler that will be called when user select lines. - onSelection(begin: number, end: number): void; + isEditable: boolean; // Handler that will be called when user press CTRL+S. onSave(content: string): void; @@ -21,21 +17,17 @@ export interface WuiEditorOptions { export class WuiEditor { id: string; - is_editable: boolean; - lines: WuiEditorLine[] = []; - sel!: Selection; - selLineStart: number = 0; + isEditable: boolean; + content: string = ""; + totalLine: number = 0; private el!: HTMLElement; - private range_begin: number = -1; - private range_end: number = -1; - private raw_lines: string[] = []; - private range!: Range; - private is_key_control: boolean = false; - private unre: WuiEditorUndoRedo = new WuiEditorUndoRedo(); + private elLineNumber: HTMLElement = document.createElement("div"); + private elContent: HTMLElement = document.createElement("div"); + private isKeyControl: boolean = false; constructor(public opts: WuiEditorOptions) { this.id = opts.id; - this.is_editable = opts.is_editable; + this.isEditable = opts.isEditable; const el = document.getElementById(opts.id); if (!el) { @@ -45,791 +37,174 @@ export class WuiEditor { this.el = el; this.initStyle(); + this.initLineNumber(); + this.initContent(); this.el.classList.add(WUI_EDITOR_CLASS); - - const sel = window.getSelection(); - if (!sel) { - console.error("WuiEditor: cannot get window selection", opts.id); - return; - } - this.sel = sel; - this.range = document.createRange(); - - document.onkeydown = (ev: KeyboardEvent) => { - this.onKeydownDocument(this, ev); - }; - document.onkeyup = (ev: KeyboardEvent) => { - this.onKeyupDocument(this, ev); - }; - - // Handle system COPY event. - // For each selection range, add line-feed to the end of it, unless the - // line is empty, so when we paste it back we can iterate the lines and - // insert it as new line. - this.el.addEventListener("copy", (ev: ClipboardEvent) => { - ev.preventDefault(); - let text = ""; - let line = ""; - for (let i = 0; i < this.sel.rangeCount; i++) { - line = this.sel.getRangeAt(i)?.toString(); - if (!line) { - continue; - } - text += line; - - if (line != "\n") { - text += "\n"; - } - } - ev.clipboardData?.setData("text/plain", text); - }); } // getContent return content of file. getContent(): string { - let content = ""; - for (let x = 0; x < this.lines.length; x++) { - if (x > 0) { - content += "\n"; - } - content += this.lines[x]!.elText.innerText; - } - return content; - } - - getSelectionRange(): RangeInterface { - return { - begin_at: this.range_begin, - end_at: this.range_end, - } as RangeInterface; - } - - // onKeyDelete handle key "DELETE" event on line. - // - // Case 1: DELETE on selection. - // We "cut" the selected text and push it to undo-redo registry. - // - // Case 2: DELETE on the end of line. - // If the next line exist, join them with current line. - // - // Case 3: DELETE on beginning or middle of text. - onKeyDelete(x: number, ev: KeyboardEvent) { - ev.preventDefault(); - - const elTextCurrent = this.lines[x]!.elText; - const textCurr = elTextCurrent.innerText; - - const selString = this.sel.toString(); - if (selString.length > 0) { - // Case 1: DELETE on selection. - let textAfter = textCurr.slice(0, this.sel.anchorOffset); - textAfter += textCurr.slice(this.sel.focusOffset, textCurr.length); - this.unre.doUpdate(x, textCurr, textAfter); - this.sel.deleteFromDocument(); - return; - } - - let textAfter = ""; - const off = this.sel.focusOffset; - - if (off === textCurr.length) { - // Case 2: DELETE on the end of line. - if (x + 1 >= this.lines.length) { - // Current line is the last line, nothing to do. - return; - } + let content: string = ""; + let el: HTMLElement; + let line: string; - const elTextAfter = this.lines[x + 1]!.elText; - textAfter = elTextAfter.innerText; - elTextAfter.innerText = ""; - - this.unre.doJoin(x, textCurr, textAfter); - this.deleteLine(x + 1); - - textAfter = textCurr + textAfter; - this.lines[x]!.elText.innerText = textAfter; - this.setCaret(elTextCurrent, off); - return; - } - - // Case 3: DELETE on beginning or middle of text. - textAfter = textCurr.slice(0, off); - textAfter += textCurr.slice(off + 1, textCurr.length); - - this.unre.doUpdate(x, textCurr, textAfter); - - this.lines[x]!.elText.innerText = textAfter; - this.setCaret(elTextCurrent, off); - } - - onKeyupOnLine(x: number, ev: KeyboardEvent) { - let elTextCurr: HTMLElement; - let elTextPrev: HTMLElement; - let textBefore: string; - let textAfter: string; - let off: number; - - switch (ev.key) { - case "Alt": - case "ArrowDown": - case "ArrowLeft": - case "ArrowRight": - case "ArrowUp": - case "CapsLock": - case "ContextMenu": - case "End": - case "Home": - case "Insert": - case "OS": - case "PageDown": - case "PageUp": - case "Pause": - case "PrintScreen": - case "ScrollLock": - case "Shift": - break; - - case "Backspace": - ev.preventDefault(); - - textBefore = this.raw_lines[x]!; - elTextCurr = this.lines[x]!.elText; - textAfter = elTextCurr.innerText; - - off = this.sel.focusOffset; - if (off > 0) { - this.unre.doUpdate(x, textBefore, textAfter); - - this.raw_lines[x] = textAfter; - this.setCaret(elTextCurr, off); - return false; - } - - // Join current line with previous. - elTextPrev = this.lines[x - 1]!.elText; - - this.unre.doJoin(x - 1, elTextPrev.innerText, elTextCurr.innerText); - - off = elTextPrev.innerText.length; - elTextPrev.innerText = elTextPrev.innerText + elTextCurr.innerText; - this.raw_lines[x - 1] = elTextPrev.innerText; - - // Remove the current line - this.deleteLine(x); - this.setCaret(elTextPrev, off); - return false; - - case "Enter": - ev.preventDefault(); - break; - - default: - if (this.is_key_control) { + this.elContent.childNodes.forEach((node: Node) => { + switch (node.nodeType) { + case Node.ELEMENT_NODE: + el = node as HTMLElement; + line = el.innerText; break; - } - this.unre.doUpdate( - x, - this.raw_lines[x]!, - this.lines[x]!.elText.innerText, - ); - this.raw_lines[x] = this.lines[x]!.elText.innerText; - } - return true; - } - - onKeydownOnLine(x: number, ev: KeyboardEvent) { - let textBefore: string; - let textAfter: string; - let off: number; - let elText: HTMLElement | undefined; - let text: string; - - switch (ev.key) { - case "ArrowUp": - if (x == 0) { - return false; - } - ev.preventDefault(); - - elText = this.lines[x - 1]!.elText; - off = this.sel.focusOffset; - if (off > elText.innerText.length) { - off = elText.innerText.length; - } - this.setCaret(elText, off); - - if (x == 1) { - this.el.scrollTop = 0; - } else if (x * 23 < this.el.scrollTop) { - this.el.scrollTop -= 25; - } - return false; - - case "ArrowDown": - if (x == this.lines.length - 1) { - return false; - } - ev.preventDefault(); - - elText = this.lines[x + 1]!.elText; - off = this.sel.focusOffset; - if (off > elText.innerText.length) { - off = elText.innerText.length; - } - this.setCaret(elText, off); - - x += 2; - if (x * 25 >= this.el.clientHeight + this.el.scrollTop) { - this.el.scrollTop += 25; - } - return false; - - case "Delete": - this.onKeyDelete(x, ev); - break; - - case "Enter": - ev.preventDefault(); - - elText = this.lines[x]!.elText; - off = this.sel.focusOffset; - text = elText.innerText; - textBefore = text.slice(0, off); - textAfter = text.slice(off, text.length); - - this.unre.doSplit(x, textBefore, textAfter); - - elText.innerText = textBefore; - this.raw_lines[x] = textBefore; - - this.insertNewline(x + 1, textAfter); - if (x + 3 >= this.raw_lines.length) { - this.el.scrollTop = this.el.scrollHeight; - } - break; - - case "Tab": - ev.preventDefault(); - - elText = this.lines[x]?.elText; - if (!elText) { + case Node.TEXT_NODE: + line = node.nodeValue || ""; break; - } + } - off = this.sel.focusOffset; - textBefore = elText.innerText; - textAfter = - textBefore.slice(0, off) + - "\t" + - textBefore.slice(off, textBefore.length); + if (line == "\n") { + content += line; + } else { + content += line + "\n"; + } + }); - this.unre.doUpdate(x, textBefore, textAfter); - elText.innerText = textAfter; - this.raw_lines[x] = textAfter; + content = content.trim(); - this.setCaret(elText, off + 1); - break; - } - return true; + return content; } - onMouseDownAtLine(x: number) { - this.range_begin = x; - } + // open the node for editing. + // The content MUST be encoded in base64. + open(node: WuiVfsNodeInterface): void { + this.content = atob(node.content); + this.content = this.content.replace("\r\n", "\n"); - onMouseUpAtLine(x: number) { - this.range_end = x; - if (this.range_end < this.range_begin) { - return; - } - let y = 0; - for (; y < this.range_begin; y++) { - this.el.children[y]?.setAttribute("style", ""); - } - for (; y <= this.range_end; y++) { - this.el.children[y]?.setAttribute( - "style", - "background-color:lightsalmon", - ); - } - for (; y < this.el.children.length; y++) { - this.el.children[y]?.setAttribute("style", ""); - } - if (this.opts.onSelection) { - this.opts.onSelection(this.range_begin, this.range_end); - } + this.render(this.content); } - // setEditOff make the content not editable. - setEditOff() { - this.lines.forEach((line) => { - line.setEditOff(); - }); + private addNewLine() { + this.totalLine++; + const elLine = document.createElement("div"); + elLine.innerText = `${this.totalLine}`; + this.elLineNumber.appendChild(elLine); } - // setEditOn make the content to be editable. - setEditOn() { - this.lines.forEach((line) => { - line.setEditOn(); - }); + private initLineNumber() { + this.elLineNumber.classList.add(WUI_EDITOR_CLASS_LINE_NUMBER); + this.el.appendChild(this.elLineNumber); } - // open the node for editing. - // The content MUST be encoded in base64. - open(node: WuiVfsNodeInterface): void { - let content = atob(node.content); - content = content.replace("\r\n", "\n"); - this.raw_lines = content.split("\n"); + private initContent() { + if (this.opts.isEditable) { + this.elContent.setAttribute("contenteditable", "true"); + this.elContent.setAttribute("spellcheck", "false"); - this.lines = []; - this.raw_lines.forEach((rawLine, x) => { - const line = new WuiEditorLine(x, rawLine, this); - this.lines.push(line); - }); - - this.render(); - } + this.elContent.addEventListener("paste", () => { + setTimeout(() => { + this.render(this.getContent()); + }, 100); + }); - // clearSelection clear selection range indicator. - clearSelection() { - if (this.range_begin < 0 || this.range_end == 0) { - return; - } - for (let x = this.range_begin; x <= this.range_end; x++) { - this.el.children[x]?.setAttribute("style", ""); + this.elContent.onkeydown = (ev: KeyboardEvent) => { + this.onKeydownDocument(this, ev); + }; + this.elContent.onkeyup = (ev: KeyboardEvent) => { + this.onKeyupDocument(this, ev); + }; } - this.range_begin = -1; - this.range_end = -1; + this.elContent.classList.add(WUI_EDITOR_CLASS_CONTENT); + this.el.appendChild(this.elContent); } private initStyle() { const style = document.createElement("style"); style.type = "text/css"; style.innerText = ` - [contenteditable] { - outline: 0px solid transparent; - } - .${WUI_EDITOR_CLASS} { - background-color: cornsilk; - font-family: monospace; - overflow-y: auto; - width: 100%; - } - .${WUI_EDITOR_CLASS_LINE} { - display: block; - width: 100%; - } - .${WUI_EDITOR_CLASS_LINE_NUMBER} { - color: dimgrey; - cursor: pointer; - display: inline-block; - padding: 4px 10px 4px 4px; - text-align: right; - user-select: none; - vertical-align: top; - width: 30px; - } - .${WUI_EDITOR_CLASS_LINE_NUMBER}:hover { - background-color: lightsalmon; - } - .${WUI_EDITOR_CLASS_LINE_TEXT} { - display: inline-block; - padding: 4px; - border-color: lightblue; - border-width: 0px; - border-style: solid; - white-space: pre-wrap; - width: calc(100% - 60px); - } - `; + [contenteditable] { + outline: 0px solid transparent; + } + .${WUI_EDITOR_CLASS} { + background-color: cornsilk; + border: 1px solid brown; + font-family: monospace; + line-height: 1.6em; + overflow-y: scroll; + width: 100%; + } + .${WUI_EDITOR_CLASS_LINE_NUMBER} { + background-color: bisque; + border-right: 1px dashed brown; + color: dimgrey; + font-family: monospace; + float: left; + left: 0px; + margin-right: 8px; + padding: 0px 8px; + position: sticky; + text-align: right; + width: 3em; + } + .${WUI_EDITOR_CLASS_CONTENT} { + // Do not use "float: left" to fix line break. + display: inline-block; + padding: 0px 8px; + white-space: pre; + width: calc(100% - 6em); + word-wrap: normal; + } + `; document.head.appendChild(style); } - private doJoin(changes: ChangesInterface) { - const line = this.lines[changes.currLine]; - if (!line) { - return; - } - line.elText.innerText = changes.currText; - this.deleteLine(changes.nextLine); - this.setCaret(line.elText, 0); - } - - private doSplit(changes: ChangesInterface) { - const line = this.lines[changes.currLine]; - if (!line) { - return; - } - line.elText.innerText = changes.currText; - this.insertNewline(changes.nextLine, changes.nextText); - } - - private doUpdate(changes: ChangesInterface) { - const line = this.lines[changes.currLine]; - if (!line) { - return; - } - line.elText.innerText = changes.currText; - this.setCaret(line.elText, 0); - } - - private doRedo() { - const act = this.unre.redo(); - if (!act) { - return; - } - switch (act.kind) { - case "join": - this.doJoin(act.after); - break; - case "split": - this.doSplit(act.after); - break; - case "update": - this.doUpdate(act.after); - break; - } - } - - private doUndo() { - const act = this.unre.undo(); - if (!act) { - return; - } - switch (act.kind) { - case "join": - this.doSplit(act.before); - break; - case "split": - this.doJoin(act.before); - break; - case "update": - this.doUpdate(act.before); - break; - } - } - - private deleteLine(x: number) { - this.lines.splice(x, 1); - this.raw_lines.splice(x, 1); - - // Reset the line numbers. - for (; x < this.lines.length; x++) { - this.lines[x]?.setNumber(x); - } - this.render(); - } - - insertNewline(x: number, text: string) { - const newline = new WuiEditorLine(x, text, this); - for (let y = x; y < this.lines.length; y++) { - this.lines[y]?.setNumber(y + 1); - } - - this.lines.splice(x, 0, newline); - this.raw_lines.splice(x, 0, text); - - this.render(); - this.setCaret(newline.elText, 0); - } - private onKeydownDocument(ed: WuiEditor, ev: KeyboardEvent) { switch (ev.key) { case "Control": - ed.is_key_control = true; - return; + ed.isKeyControl = true; + break; - case "r": - if (ed.is_key_control) { - ev.preventDefault(); - ed.doRedo(); - } - return; + case "Enter": + this.addNewLine(); + break; case "s": - if (ed.is_key_control) { + if (ed.isKeyControl) { ev.preventDefault(); ev.stopPropagation(); if (ed.opts.onSave) { ed.opts.onSave(ed.getContent()); } } - return; - - case "z": - if (ed.is_key_control) { - ev.preventDefault(); - ed.doUndo(); - } - return; + break; } + return true; } private onKeyupDocument(ed: WuiEditor, ev: KeyboardEvent) { switch (ev.key) { case "Control": - ed.is_key_control = false; - return; - - case "Escape": - ev.preventDefault(); - ed.clearSelection(); - return; - } - } - - private render() { - this.el.innerHTML = ""; - for (const line of this.lines) { - this.el.appendChild(line.el); + ed.isKeyControl = false; + return true; } + return true; } - private setCaret(elText: HTMLElement, off: number) { - if (elText.firstChild) { - this.range.setStart(elText.firstChild, off); - } else { - this.range.setStart(elText, off); - } - this.range.collapse(true); - this.sel.removeAllRanges(); - this.sel.addRange(this.range); - } -} - -class WuiEditorLine { - private lineNum: number = 0; - el!: HTMLElement; - el_number!: HTMLElement; - elText!: HTMLElement; - - constructor( - public x: number, - public text: string, - ed: WuiEditor, - ) { - this.lineNum = x; - if (text == "") { - // Add line feed to make all lines layout consistent. - text = "\n"; - } - this.el = document.createElement("div"); - this.el.classList.add(WUI_EDITOR_CLASS_LINE); - - this.el_number = document.createElement("span"); - this.el_number.classList.add(WUI_EDITOR_CLASS_LINE_NUMBER); - this.el_number.innerText = this.lineNum + 1 + ""; - - this.el_number.onmousedown = () => { - ed.onMouseDownAtLine(this.lineNum); - }; - this.el_number.onmouseup = () => { - ed.onMouseUpAtLine(this.lineNum); - }; - - this.elText = document.createElement("span"); - this.elText.classList.add(WUI_EDITOR_CLASS_LINE_TEXT); - this.elText.innerText = text; - this.elText.contentEditable = "true"; - - this.elText.onkeydown = (ev: KeyboardEvent) => { - return ed.onKeydownOnLine(this.lineNum, ev); - }; - this.elText.onkeyup = (ev: KeyboardEvent) => { - return ed.onKeyupOnLine(this.lineNum, ev); - }; + private render(content: string) { + const lines = content.split("\n"); - // onmousedown get and store the current selection and current line - // number so we can do multiple selection later when mouse move up or - // down. - this.elText.onmousedown = () => { - ed.sel.removeAllRanges(); + this.elContent.innerText = ""; + this.elLineNumber.innerText = ""; + lines.forEach((line: string, x: number) => { + const el = document.createElement("div"); + el.innerText = `${x + 1}`; + this.elLineNumber.appendChild(el); - const sel = window.getSelection(); - if (!sel) { - return; + const div = document.createElement("div"); + div.innerText = line; + if (line == "") { + div.appendChild(document.createElement("br")); } - - ed.sel = sel; - ed.selLineStart = this.x; - }; - - this.elText.onmouseup = () => { - ed.selLineStart = 0; - }; - - // onmousemove if the current line not same when the mousedown started, - // add it to selection range. - this.elText.onmousemove = () => { - if (!ed.selLineStart) { - return; - } - if (ed.selLineStart == this.x) { - return; - } - - const range = document.createRange(); - range.selectNode(this.elText); - ed.sel.addRange(range); - }; - - // paste event we split the clipboard text into lines and it to editor - // line by line. - this.elText.addEventListener("paste", (ev: ClipboardEvent) => { - if (!ev.clipboardData) { - return; - } - - ev.preventDefault(); - - const text = ev.clipboardData.getData("text/plain"); - const lines = text.split("\n"); - lines.forEach((l: string, x: number) => { - ed.insertNewline(this.x + x, l); - }); + this.elContent.appendChild(div); }); - this.el.appendChild(this.el_number); - this.el.appendChild(this.elText); - } - - setNumber(x: number) { - this.lineNum = x; - this.el_number.innerText = x + 1 + ""; - } - - setEditOn() { - this.elText.contentEditable = "true"; - } - - setEditOff() { - this.elText.contentEditable = "false"; - } -} - -// -// WuiEditorUndoRedo store the state of actions. -// -class WuiEditorUndoRedo { - private idx: number = 0; - private actions: ActionInterface[] = []; - - doJoin(prevLine: number, prevText: string, currText: string) { - const action: ActionInterface = { - kind: "join", - before: { - currLine: prevLine, - currText: prevText, - nextLine: prevLine + 1, - nextText: currText, - }, - after: { - currLine: prevLine, - currText: prevText + currText, - nextLine: prevLine + 1, - nextText: "", - }, - }; - if (this.actions.length > 0) { - this.actions = this.actions.slice(0, this.idx); - } - this.actions.push(action); - this.idx++; - } - - doSplit(currLine: number, currText: string, nextText: string) { - const action = { - kind: "split", - before: { - currLine: currLine, - currText: currText + nextText, - nextLine: currLine + 1, - nextText: "", - }, - after: { - currLine: currLine, - currText: currText, - nextLine: currLine + 1, - nextText: nextText, - }, - }; - if (this.actions.length > 0) { - this.actions = this.actions.slice(0, this.idx); - } - this.actions.push(action); - this.idx++; + this.totalLine = lines.length; } - - doUpdate(lineNum: number, textBefore: string, textAfter: string) { - const action: ActionInterface = { - kind: "update", - before: { - currLine: lineNum, - currText: textBefore, - nextLine: 0, - nextText: "", - }, - after: { - currLine: lineNum, - currText: textAfter, - nextLine: 0, - nextText: "", - }, - }; - - if (this.actions.length > 0) { - this.actions = this.actions.slice(0, this.idx); - } - this.actions.push(action); - this.idx++; - } - - undo(): ActionInterface | null { - if (this.idx == 0) { - return null; - } - this.idx--; - const action = this.actions[this.idx]; - if (!action) { - return null; - } - return action; - } - - redo(): ActionInterface | null { - if (this.idx == this.actions.length) { - return null; - } - const action = this.actions[this.idx]; - if (!action) { - return null; - } - this.idx++; - return action; - } -} - -// There are three kind of action -// -// * update: update a single line -// * split: split line using enter -// * join: join line using backspace. -// -interface ActionInterface { - kind: string; - before: ChangesInterface; - after: ChangesInterface; -} - -interface ChangesInterface { - currLine: number; - currText: string; - nextLine: number; - nextText: string; -} - -interface RangeInterface { - begin_at: number; - end_at: number; } diff --git a/editor/example.ts b/editor/example.ts index a967045..39efb4c 100644 --- a/editor/example.ts +++ b/editor/example.ts @@ -48,13 +48,23 @@ sudo systemctl status telegraf const opts = { id: "editor", - is_editable: true, - onSelection: (begin: number, end: number) => { - console.log("OnSelection: ", begin, end); - }, + isEditable: true, onSave: (content: string) => { - console.log("OnSave: ", content); + const lines = content.split("\n"); + lines.forEach((line: string, x: number) => { + console.log(`${x}: ${line}`); + }); }, }; const wuiEditor = new WuiEditor(opts); wuiEditor.open(nodeFile); + +const optsro = { + id: "editor-readonly", + isEditable: false, + onSave: (content: string) => { + console.log("OnSave: ", content); + }, +}; +const edro = new WuiEditor(optsro); +edro.open(nodeFile); diff --git a/editor/index.html b/editor/index.html index 04a64f5..9c008c7 100644 --- a/editor/index.html +++ b/editor/index.html @@ -9,7 +9,10 @@ </head> <body> - <div id="editor"></div> + <p> Writable editor: </p> + <div id="editor" style="height:20em;"></div> + <p> Read only editor: </p> + <div id="editor-readonly" style="height:20em;"></div> <script type="module" src="/editor/example.js"></script> </body> |
