Skip to content

Commit 9098959

Browse files
Make EditContext demo match the MDN tutorial (remove useless setInterval, debug mode, reorganize in files, clean up code) (mdn#266)
* Removing useless setInterval render * Remove the debug mode * Not needed debug mode visual transition * Split into multiple files * Final changes
1 parent 929e396 commit 9098959

File tree

4 files changed

+448
-469
lines changed

4 files changed

+448
-469
lines changed

edit-context/html-editor/converter.js

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// The EditContext object only knows about a plain text string and about
2+
// character offsets. However, our editor view renders the text by using
3+
// DOM nodes. So we sometimes need to convert between the two.
4+
// This function converts from a DOM selection object to character offsets.
5+
export function fromSelectionToOffsets(selection, editorEl) {
6+
const treeWalker = document.createTreeWalker(editorEl, NodeFilter.SHOW_TEXT);
7+
8+
let anchorNodeFound = false;
9+
let extentNodeFound = false;
10+
let anchorOffset = 0;
11+
let extentOffset = 0;
12+
13+
while (treeWalker.nextNode()) {
14+
const node = treeWalker.currentNode;
15+
if (node === selection.anchorNode) {
16+
anchorNodeFound = true;
17+
anchorOffset += selection.anchorOffset;
18+
}
19+
20+
if (node === selection.extentNode) {
21+
extentNodeFound = true;
22+
extentOffset += selection.extentOffset;
23+
}
24+
25+
if (!anchorNodeFound) {
26+
anchorOffset += node.textContent.length;
27+
}
28+
if (!extentNodeFound) {
29+
extentOffset += node.textContent.length;
30+
}
31+
}
32+
33+
if (!anchorNodeFound || !extentNodeFound) {
34+
return null;
35+
}
36+
37+
return { start: anchorOffset, end: extentOffset };
38+
}
39+
40+
// The EditContext object only knows about a plain text string and about
41+
// character offsets. However, our editor view renders the text by using
42+
// DOM nodes. So we sometimes need to convert between the two.
43+
// This function converts character offsets to a DOM selection object.
44+
export function fromOffsetsToSelection(start, end, editorEl) {
45+
const treeWalker = document.createTreeWalker(editorEl, NodeFilter.SHOW_TEXT);
46+
47+
let offset = 0;
48+
let anchorNode = null;
49+
let anchorOffset = 0;
50+
let extentNode = null;
51+
let extentOffset = 0;
52+
53+
while (treeWalker.nextNode()) {
54+
const node = treeWalker.currentNode;
55+
56+
if (!anchorNode && offset + node.textContent.length >= start) {
57+
anchorNode = node;
58+
anchorOffset = start - offset;
59+
}
60+
61+
if (!extentNode && offset + node.textContent.length >= end) {
62+
extentNode = node;
63+
extentOffset = end - offset;
64+
}
65+
66+
if (anchorNode && extentNode) {
67+
break;
68+
}
69+
70+
offset += node.textContent.length;
71+
}
72+
73+
return { anchorNode, anchorOffset, extentNode, extentOffset };
74+
}
75+
76+
// The EditContext object only knows about character offsets. But out editor
77+
// view renders HTML tokens as DOM nodes. This function finds DOM node tokens
78+
// that are in the provided EditContext offset range.
79+
export function fromOffsetsToRenderedTokenNodes(renderedTokens, start, end) {
80+
const tokenNodes = [];
81+
82+
for (let offset = start; offset < end; offset++) {
83+
const token = renderedTokens.find(
84+
(token) => token.pos <= offset && token.pos + token.value.length > offset
85+
);
86+
if (token) {
87+
tokenNodes.push({
88+
node: token.node,
89+
nodeOffset: token.pos,
90+
charOffset: offset,
91+
});
92+
}
93+
}
94+
95+
return tokenNodes;
96+
}

edit-context/html-editor/editor.js

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { tokenizeHTML } from "./tokenizer.js";
2+
import {
3+
fromOffsetsToRenderedTokenNodes,
4+
fromSelectionToOffsets,
5+
fromOffsetsToSelection,
6+
} from "./converter.js";
7+
8+
const IS_EDIT_CONTEXT_SUPPORTED = "EditContext" in window;
9+
const IS_CUSTOM_HIGHLIGHT_SUPPORTED = "Highlight" in window;
10+
11+
// The editor element.
12+
const editorEl = document.getElementById("html-editor");
13+
14+
// The current tokens from the html text.
15+
let currentTokens = [];
16+
17+
// Instances of CSS custom Highlight objects, used to render
18+
// the IME composition text formats.
19+
const imeHighlights = {
20+
"solid-thin": null,
21+
"solid-thick": null,
22+
"dotted-thin": null,
23+
"dotted-thick": null,
24+
"dashed-thin": null,
25+
"dashed-thick": null,
26+
"wavy-thin": null,
27+
"wavy-thick": null,
28+
"squiggle-thin": null,
29+
"squiggle-thick": null,
30+
};
31+
if (IS_CUSTOM_HIGHLIGHT_SUPPORTED) {
32+
for (const [key, value] of Object.entries(imeHighlights)) {
33+
imeHighlights[key] = new Highlight();
34+
CSS.highlights.set(`ime-${key}`, imeHighlights[key]);
35+
}
36+
} else {
37+
console.warn(
38+
"Custom highlights are not supported in this browser. IME formats will not be rendered."
39+
);
40+
}
41+
42+
(function () {
43+
if (!IS_EDIT_CONTEXT_SUPPORTED) {
44+
editorEl.textContent =
45+
"Sorry, your browser doesn't support the EditContext API. This demo will not work.";
46+
return;
47+
}
48+
49+
// Instantiate the EditContext object.
50+
const editContext = new EditContext({
51+
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>",
52+
});
53+
54+
// Attach the EditContext object to the editor element.
55+
// This makes the element focusable and able to receive text input.
56+
editorEl.editContext = editContext;
57+
58+
// Update the control bounds (i.e. where the editor is on the screen)
59+
// now, and when the window is resized.
60+
// This helps the OS position the IME composition window correctly.
61+
function updateControlBounds() {
62+
const editorBounds = editorEl.getBoundingClientRect();
63+
editContext.updateControlBounds(editorBounds);
64+
}
65+
updateControlBounds();
66+
window.addEventListener("resize", updateControlBounds);
67+
68+
// Update the selection and selection bounds in the EditContext object.
69+
// This helps the OS position the IME composition window correctly.
70+
function updateSelection(start, end) {
71+
editContext.updateSelection(start, end);
72+
// Get the bounds of the selection.
73+
editContext.updateSelectionBounds(
74+
document.getSelection().getRangeAt(0).getBoundingClientRect()
75+
);
76+
}
77+
78+
// The render function is used to update the view of the editor.
79+
// The EditContext object is our "model", and the editorEl is our "view".
80+
// The render function's job is to update the view when the model changes.
81+
function render(text, selectionStart, selectionEnd) {
82+
// Empty the editor. We're re-rendering everything.
83+
editorEl.innerHTML = "";
84+
85+
// Tokenize the text.
86+
currentTokens = tokenizeHTML(text);
87+
88+
// Render each token as a DOM node.
89+
for (const token of currentTokens) {
90+
const span = document.createElement("span");
91+
span.classList.add(`token-${token.type}`);
92+
span.textContent = token.value;
93+
editorEl.appendChild(span);
94+
95+
// Store the new DOM node as a property of the token
96+
// in the currentTokens array. We will need it again
97+
// later in fromOffsetsToRenderedTokenNodes.
98+
token.node = span;
99+
}
100+
101+
// Move the selection to the correct location.
102+
// It was lost when we updated the DOM.
103+
// The EditContext API gives us the selection as text offsets.
104+
// Convert it into a DOM selection.
105+
const { anchorNode, anchorOffset, extentNode, extentOffset } =
106+
fromOffsetsToSelection(selectionStart, selectionEnd, editorEl);
107+
document
108+
.getSelection()
109+
.setBaseAndExtent(anchorNode, anchorOffset, extentNode, extentOffset);
110+
}
111+
112+
// Listen to the EditContext's textupdate event.
113+
// This tells us when text input happens. We use it to re-render the view.
114+
editContext.addEventListener("textupdate", (e) => {
115+
render(editContext.text, e.selectionStart, e.selectionEnd);
116+
});
117+
118+
// Visually show when we're composing text, like when using an IME,
119+
// or voice dictation.
120+
editContext.addEventListener("compositionstart", (e) => {
121+
editorEl.classList.add("is-composing");
122+
});
123+
editContext.addEventListener("compositionend", (e) => {
124+
editorEl.classList.remove("is-composing");
125+
});
126+
127+
// Update the character bounds when the EditContext needs it.
128+
editContext.addEventListener("characterboundsupdate", (e) => {
129+
const tokenNodes = fromOffsetsToRenderedTokenNodes(
130+
currentTokens,
131+
e.rangeStart,
132+
e.rangeEnd
133+
);
134+
135+
const charBounds = tokenNodes.map(({ node, nodeOffset, charOffset }) => {
136+
const range = document.createRange();
137+
range.setStart(node.firstChild, charOffset - nodeOffset);
138+
range.setEnd(node.firstChild, charOffset - nodeOffset + 1);
139+
return range.getBoundingClientRect();
140+
});
141+
142+
editContext.updateCharacterBounds(e.rangeStart, charBounds);
143+
});
144+
145+
// Draw IME composition text formats if needed.
146+
editContext.addEventListener("textformatupdate", (e) => {
147+
const formats = e.getTextFormats();
148+
149+
for (const format of formats) {
150+
// Find the DOM selection that corresponds to the format's range.
151+
const selection = fromOffsetsToSelection(
152+
format.rangeStart,
153+
format.rangeEnd,
154+
editorEl
155+
);
156+
157+
// Highlight the selection with the right style and thickness.
158+
addHighlight(selection, format.underlineStyle, format.underlineThickness);
159+
}
160+
});
161+
162+
function addHighlight(selection, underlineStyle, underlineThickness) {
163+
// Get the right CSS custom Highlight object depending on the
164+
// underline style and thickness.
165+
const highlight =
166+
imeHighlights[
167+
`${underlineStyle.toLowerCase()}-${underlineThickness.toLowerCase()}`
168+
];
169+
170+
if (highlight) {
171+
// Add a range to the Highlight object.
172+
const range = document.createRange();
173+
range.setStart(selection.anchorNode, selection.anchorOffset);
174+
range.setEnd(selection.extentNode, selection.extentOffset);
175+
highlight.add(range);
176+
}
177+
}
178+
179+
// Handle key presses that are not already handled by the EditContext.
180+
editorEl.addEventListener("keydown", (e) => {
181+
const start = Math.min(
182+
editContext.selectionStart,
183+
editContext.selectionEnd
184+
);
185+
const end = Math.max(editContext.selectionStart, editContext.selectionEnd);
186+
187+
if (e.key === "Tab") {
188+
e.preventDefault();
189+
editContext.updateText(start, end, "\t");
190+
updateSelection(start + 1, start + 1);
191+
render(
192+
editContext.text,
193+
editContext.selectionStart,
194+
editContext.selectionEnd
195+
);
196+
} else if (e.key === "Enter") {
197+
editContext.updateText(start, end, "\n");
198+
updateSelection(start + 1, start + 1);
199+
render(
200+
editContext.text,
201+
editContext.selectionStart,
202+
editContext.selectionEnd
203+
);
204+
}
205+
});
206+
207+
// Listen to selectionchange events to let the EditContext know where it is.
208+
document.addEventListener("selectionchange", () => {
209+
const selection = document.getSelection();
210+
const offsets = fromSelectionToOffsets(selection, editorEl);
211+
if (offsets) {
212+
updateSelection(offsets.start, offsets.end);
213+
}
214+
});
215+
216+
// Render the initial view.
217+
render(
218+
editContext.text,
219+
editContext.selectionStart,
220+
editContext.selectionEnd
221+
);
222+
})();

0 commit comments

Comments
 (0)