Skip to content

Commit

Permalink
Split into multiple files
Browse files Browse the repository at this point in the history
  • Loading branch information
captainbrosset committed Mar 18, 2024
1 parent b77414d commit 5c2d271
Show file tree
Hide file tree
Showing 4 changed files with 445 additions and 404 deletions.
96 changes: 96 additions & 0 deletions edit-context/html-editor/converter.js
Original file line number Diff line number Diff line change
@@ -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 };
}
211 changes: 211 additions & 0 deletions edit-context/html-editor/editor.js
Original file line number Diff line number Diff line change
@@ -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: "<html>\n <body id=foo>\n <h1 id='header'>Cool Title</h1>\n <p class=\"wow\">hello<br/>How are you? test</p>\n </body>\n</html>",
});

// 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
);
})();
Loading

0 comments on commit 5c2d271

Please sign in to comment.