From 5c2d271c07c0317f0945e182640700f019fd629d Mon Sep 17 00:00:00 2001 From: Patrick Brosset Date: Mon, 18 Mar 2024 11:18:03 +0100 Subject: [PATCH] Split into multiple files --- edit-context/html-editor/converter.js | 96 ++++++ edit-context/html-editor/editor.js | 211 +++++++++++++ edit-context/html-editor/index.html | 406 +------------------------- edit-context/html-editor/styles.css | 136 +++++++++ 4 files changed, 445 insertions(+), 404 deletions(-) create mode 100644 edit-context/html-editor/converter.js create mode 100644 edit-context/html-editor/editor.js create mode 100644 edit-context/html-editor/styles.css diff --git a/edit-context/html-editor/converter.js b/edit-context/html-editor/converter.js new file mode 100644 index 0000000..31cbc08 --- /dev/null +++ b/edit-context/html-editor/converter.js @@ -0,0 +1,96 @@ +// The EditContext object only knows about character offsets. But out editor +// view renders HTML tokens as DOM nodes. This function finds DOM node tokens +// that are in the provided EditContext offset range. +export function fromOffsetsToRenderedTokenNodes(renderedTokens, start, end) { + const tokenNodes = []; + + for (let offset = start; offset < end; offset++) { + const token = renderedTokens.find( + (token) => token.pos <= offset && token.pos + token.value.length > offset + ); + if (token) { + tokenNodes.push({ + node: token.node, + nodeOffset: token.pos, + charOffset: offset, + }); + } + } + + return tokenNodes; +} + +// The EditContext object only knows about a plain text string and about +// character offsets. However, our editor view renders the text by using +// DOM nodes. So we sometimes need to convert between the two. +// This function converts from a DOM selection object to character offsets. +export function fromSelectionToOffsets(selection, editorEl) { + const treeWalker = document.createTreeWalker(editorEl, NodeFilter.SHOW_TEXT); + + let anchorNodeFound = false; + let extentNodeFound = false; + let anchorOffset = 0; + let extentOffset = 0; + + while (treeWalker.nextNode()) { + const node = treeWalker.currentNode; + if (node === selection.anchorNode) { + anchorNodeFound = true; + anchorOffset += selection.anchorOffset; + } + + if (node === selection.extentNode) { + extentNodeFound = true; + extentOffset += selection.extentOffset; + } + + if (!anchorNodeFound) { + anchorOffset += node.textContent.length; + } + if (!extentNodeFound) { + extentOffset += node.textContent.length; + } + } + + if (!anchorNodeFound || !extentNodeFound) { + return null; + } + + return { start: anchorOffset, end: extentOffset }; +} + +// The EditContext object only knows about a plain text string and about +// character offsets. However, our editor view renders the text by using +// DOM nodes. So we sometimes need to convert between the two. +// This function converts character offsets to a DOM selection object. +export function fromOffsetsToSelection(start, end, editorEl) { + const treeWalker = document.createTreeWalker(editorEl, NodeFilter.SHOW_TEXT); + + let offset = 0; + let anchorNode = null; + let anchorOffset = 0; + let extentNode = null; + let extentOffset = 0; + + while (treeWalker.nextNode()) { + const node = treeWalker.currentNode; + + if (!anchorNode && offset + node.textContent.length >= start) { + anchorNode = node; + anchorOffset = start - offset; + } + + if (!extentNode && offset + node.textContent.length >= end) { + extentNode = node; + extentOffset = end - offset; + } + + if (anchorNode && extentNode) { + break; + } + + offset += node.textContent.length; + } + + return { anchorNode, anchorOffset, extentNode, extentOffset }; +} diff --git a/edit-context/html-editor/editor.js b/edit-context/html-editor/editor.js new file mode 100644 index 0000000..61d56b7 --- /dev/null +++ b/edit-context/html-editor/editor.js @@ -0,0 +1,211 @@ +import { tokenizeHTML } from "./tokenizer.js"; +import { + fromOffsetsToRenderedTokenNodes, + fromSelectionToOffsets, + fromOffsetsToSelection, +} from "./converter.js"; + +const IS_EDIT_CONTEXT_SUPPORTED = "EditContext" in window; +const IS_CUSTOM_HIGHLIGHT_SUPPORTED = "Highlight" in window; + +// The editor element. +const editorEl = document.getElementById("html-editor"); + +// The current tokens from the html text. +let currentTokens = []; + +// Instances of CSS custom Highlight objects, used to render +// the IME composition text formats. +const imeHighlights = { + "solid-thin": null, + "solid-thick": null, + "dotted-thin": null, + "dotted-thick": null, + "dashed-thin": null, + "dashed-thick": null, + "wavy-thin": null, + "wavy-thick": null, + "squiggle-thin": null, + "squiggle-thick": null, +}; +if (IS_CUSTOM_HIGHLIGHT_SUPPORTED) { + for (const [key, value] of Object.entries(imeHighlights)) { + imeHighlights[key] = new Highlight(); + CSS.highlights.set(`ime-${key}`, imeHighlights[key]); + } +} else { + console.warn( + "Custom highlights are not supported in this browser. IME formats will not be rendered." + ); +} + +(function () { + if (!IS_EDIT_CONTEXT_SUPPORTED) { + editorEl.textContent = + "Sorry, your browser doesn't support the EditContext API. This demo will not work."; + return; + } + + // Instantiate the EditContext object. + const editContext = new EditContext({ + text: "\n \n

Cool Title

\n

hello
How are you? test

\n \n", + }); + + // Attach the EditContext object to the editor element. + // This makes the element focusable and able to receive text input. + editorEl.editContext = editContext; + + // Update the control bounds (i.e. where the editor is on the screen) + // now, and when the window is resized. + // This helps the OS position the IME composition window correctly. + function updateControlBounds() { + const editorBounds = editorEl.getBoundingClientRect(); + editContext.updateControlBounds(editorBounds); + } + updateControlBounds(); + window.addEventListener("resize", updateControlBounds); + + // Update the selection and selection bounds in the EditContext object. + // This helps the OS position the IME composition window correctly. + function updateSelection(start, end) { + editContext.updateSelection(start, end); + // Get the bounds of the selection. + editContext.updateSelectionBounds( + document.getSelection().getRangeAt(0).getBoundingClientRect() + ); + } + + // The render function is used to update the view of the editor. + // The EditContext object is our "model", and the editorEl is our "view". + // The render function's job is to update the view when the model changes. + function render(text, selectionStart, selectionEnd) { + // Empty the editor. We're re-rendering everything. + editorEl.innerHTML = ""; + + // Tokenize the text. + currentTokens = tokenizeHTML(text); + + // Render each token. + for (const token of currentTokens) { + const span = document.createElement("span"); + span.classList.add(`token-${token.type}`); + span.textContent = token.value; + span.dataset.tokenPos = token.pos; + editorEl.appendChild(span); + + // Store the node in the token so we can find it later. + token.node = span; + } + + // Move the selection to the correct location. + // It was lost when we updated the DOM. + // The EditContext API gives us the selection as text offsets. + // Convert it into a DOM selection. + const { anchorNode, anchorOffset, extentNode, extentOffset } = + fromOffsetsToSelection(selectionStart, selectionEnd, editorEl); + document + .getSelection() + .setBaseAndExtent(anchorNode, anchorOffset, extentNode, extentOffset); + } + + // Listen to the EditContext's textupdate event. + // This tells us when text input happens. We use it to re-render the view. + editContext.addEventListener("textupdate", (e) => { + render(editContext.text, e.selectionStart, e.selectionEnd); + }); + + // Visually show when we're composing text, like when using an IME, + // or voice dictation. + editContext.addEventListener("compositionstart", (e) => { + editorEl.classList.add("is-composing"); + }); + editContext.addEventListener("compositionend", (e) => { + editorEl.classList.remove("is-composing"); + }); + + // Update the character bounds when the EditContext needs it. + editContext.addEventListener("characterboundsupdate", (e) => { + const tokenNodes = fromOffsetsToRenderedTokenNodes( + currentTokens, + e.rangeStart, + e.rangeEnd + ); + + const charBounds = tokenNodes.map(({ node, nodeOffset, charOffset }) => { + const range = document.createRange(); + range.setStart(node.firstChild, charOffset - nodeOffset); + range.setEnd(node.firstChild, charOffset - nodeOffset + 1); + return range.getBoundingClientRect(); + }); + + editContext.updateCharacterBounds(e.rangeStart, charBounds); + }); + + // Draw IME composition text formats if needed. + editContext.addEventListener("textformatupdate", (e) => { + const formats = e.getTextFormats(); + + for (const format of formats) { + const { rangeStart, rangeEnd, underlineStyle, underlineThickness } = + format; + + // Find the nodes in the view that are in the range. + const { anchorNode, anchorOffset, extentNode, extentOffset } = + fromOffsetsToSelection(rangeStart, rangeEnd, editorEl); + const highlight = + imeHighlights[ + `${format.underlineStyle.toLowerCase()}-${format.underlineThickness.toLowerCase()}` + ]; + if (highlight) { + const range = document.createRange(); + range.setStart(anchorNode, anchorOffset); + range.setEnd(extentNode, extentOffset); + highlight.add(range); + } + } + }); + + // Handle key presses that are not already handled by the EditContext. + editorEl.addEventListener("keydown", (e) => { + const start = Math.min( + editContext.selectionStart, + editContext.selectionEnd + ); + const end = Math.max(editContext.selectionStart, editContext.selectionEnd); + + if (e.key === "Tab") { + e.preventDefault(); + editContext.updateText(start, end, "\t"); + updateSelection(start + 1, start + 1); + render( + editContext.text, + editContext.selectionStart, + editContext.selectionEnd + ); + } else if (e.key === "Enter") { + editContext.updateText(start, end, "\n"); + updateSelection(start + 1, start + 1); + render( + editContext.text, + editContext.selectionStart, + editContext.selectionEnd + ); + } + }); + + // Listen to selectionchange events to let the EditContext know where it is. + document.addEventListener("selectionchange", () => { + const selection = document.getSelection(); + const offsets = fromSelectionToOffsets(selection, editorEl); + if (offsets) { + updateSelection(offsets.start, offsets.end); + } + }); + + // Render the initial view. + render( + editContext.text, + editContext.selectionStart, + editContext.selectionEnd + ); +})(); diff --git a/edit-context/html-editor/index.html b/edit-context/html-editor/index.html index 0a7a0f4..b04070a 100644 --- a/edit-context/html-editor/index.html +++ b/edit-context/html-editor/index.html @@ -5,416 +5,14 @@ Edit Context API: HTML editor demo - - + -
- + \ No newline at end of file diff --git a/edit-context/html-editor/styles.css b/edit-context/html-editor/styles.css new file mode 100644 index 0000000..823f64b --- /dev/null +++ b/edit-context/html-editor/styles.css @@ -0,0 +1,136 @@ +html, +body { + font-size: 0.9rem; + font-family: consolas, monospace; + margin: 0; + height: 100%; + box-sizing: border-box; +} + +body { + padding: 1rem; +} + +#html-editor { + box-sizing: border-box; + width: 100%; + height: 100%; + border-radius: 0.5rem; + padding: 1rem; + overflow: auto; + white-space: pre; + tab-size: 2; + caret-color: red; + background: #000; + line-height: 1.6; + color: red; +} + +#html-editor::selection { + color: white; + background: red; +} + +#html-editor.is-composing { + box-shadow: 0 0 0 0.25rem red; +} + +[data-token-pos] { + margin: 0; + transition: margin 0.2s; +} + +.token-openTagStart, +.token-openTagEnd, +.token-closeTagStart, +.token-closeTagEnd, +.token-selfClose { + background: rgb(7 53 92); + margin: 0 2px; + color: white; + border-radius: 0.25rem; +} + +.token-equal { + color: white; +} + +.token-tagName { + font-weight: bold; + color: rgb(117, 186, 242); +} + +.token-attributeName { + color: rgb(207, 81, 198); +} + +.token-attributeValue { + font-style: italic; + color: rgb(127 230 127); + border: 1px dashed #8c8c8c; + border-width: 1px 0 1px 0; +} + +.token-quoteStart, +.token-quoteEnd { + font-weight: bold; + color: rgb(127 230 127); + border: 1px solid #8c8c8c; + border-width: 1px 0 1px 1px; + border-radius: 0.25rem 0 0 0.25rem; +} + +.token-quoteEnd { + border-width: 1px 1px 1px 0; + border-radius: 0 0.25rem 0.25rem 0; +} + +.token-text { + color: #6a6a6a; + padding: 0 0.25rem; +} + +::highlight(ime-solid-thin) { + text-decoration: underline 1px; +} + +::highlight(ime-solid-thick) { + text-decoration: underline 2px; +} + +::highlight(ime-dotted-thin) { + text-decoration: underline dotted 1px; +} + +::highlight(ime-dotted-thick) { + text-decoration: underline dotted 2px; +} + +::highlight(ime-dashed-thin) { + text-decoration: underline dashed 1px; +} + +::highlight(ime-dashed-thick) { + text-decoration: underline dashed 2px; +} + +::highlight(ime-wavy-thin) { + text-decoration: underline wavy 1px; +} + +::highlight(ime-wavy-thick) { + text-decoration: underline wavy 2px; +} + +::highlight(ime-squiggle-thin) { + text-decoration: underline wavy 1px; +} + +::highlight(ime-squiggle-thick) { + text-decoration: underline wavy 2px; +} + +.controls label { + display: flex; + align-items: center; +}