-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
b77414d
commit 5c2d271
Showing
4 changed files
with
445 additions
and
404 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
); | ||
})(); |
Oops, something went wrong.