Skip to content

Commit

Permalink
In progress EditContext samples
Browse files Browse the repository at this point in the history
  • Loading branch information
captainbrosset committed Dec 19, 2023
1 parent 9786082 commit f9dee95
Show file tree
Hide file tree
Showing 2 changed files with 511 additions and 0 deletions.
331 changes: 331 additions & 0 deletions edit-context/canvas.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,331 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit Context API</title>
<link rel="icon" type="image/png" href="https://edgestatic.azureedge.net/welcome/static/favicon.png">

<style>
html,
body {
margin: 0;
height: 100%;
}

body {
display: grid;
place-content: center;
}

canvas {
background: white;
border: 2px solid black;
border-radius: .5rem;
width: 90vw;
}

canvas:focus {
outline: 2px solid rgb(105, 105, 255);
outline-offset: 2px;
}

canvas.is-composing {
background: lightblue;
}
</style>
</head>

<body>
<canvas width="1000" height="200"></canvas>

<script>
// Caret style.
const CARET_WIDTH = 2.5;
const CARET_COLOR = "black";
const BLINK_SPEED = 1000; // Smaller = faster.

// Selection style.
const SELECTION_COLOR = "black";
const SELECTION_TEXT_COLOR = "white";

// Padding above and below the text to draw the caret and selection.
const CARET_PADDING = 8;

// Color of the IME composition underline.
const COMPOSITION_STYLE_COLOR = "red";

// Font used for rendering the text.
const VERTICAL_MARGIN = 10;
const FONT_SIZE = 40;
const FONT = `${FONT_SIZE}px Arial`;

// Text start position.
const TEXT_ANCHOR_X = VERTICAL_MARGIN;
const TEXT_ANCHOR_Y = FONT_SIZE + VERTICAL_MARGIN;

let rafID = undefined;

// Get the canvas element and its context.
const canvas = document.querySelector("canvas");
canvas.height = FONT_SIZE + VERTICAL_MARGIN * 3;
const canvasCtx = canvas.getContext("2d");
canvasCtx.font = FONT;
const CHAR_HEIGHT = canvasCtx.measureText("M").emHeightAscent;

// Create the EditContext instance.
const editContext = new EditContext();
canvas.editContext = editContext;

let selectionStart = 0;
let selectionEnd = 0;
let textFormats = [];
let firstRenderTime = undefined;

function renderCaret(x) {
canvasCtx.save();
canvasCtx.fillStyle = CARET_COLOR;
canvasCtx.fillRect(x, TEXT_ANCHOR_Y - CHAR_HEIGHT - CARET_PADDING, CARET_WIDTH, CHAR_HEIGHT + CARET_PADDING * 2);
canvasCtx.restore();
}

function renderSelection(startX, endX) {
canvasCtx.save();
canvasCtx.fillStyle = SELECTION_COLOR;
canvasCtx.fillRect(startX, TEXT_ANCHOR_Y - CHAR_HEIGHT - CARET_PADDING, endX - startX, CHAR_HEIGHT + CARET_PADDING * 2);
canvasCtx.restore();
}

function renderUnderlineDecoration({rangeStart, rangeEnd, underlineStyle, underlineThickness}) {
const lineStartX = TEXT_ANCHOR_X + canvasCtx.measureText(editContext.text.substring(0, rangeStart)).width;
const lineEndX = TEXT_ANCHOR_X + canvasCtx.measureText(editContext.text.substring(0, rangeEnd)).width;
const lineY = TEXT_ANCHOR_Y + 7; // Some space between the text and the underline.
const thickWidth = 3;
const thinWidth = 2;

canvasCtx.save();
canvasCtx.strokeStyle = COMPOSITION_STYLE_COLOR;

// Japanese IME returns 'Squiggle' while Chinese returns 'Dotted' for active composition.
// For simplicity we draw dotted line for both.
if (underlineStyle == 'Squiggle' || underlineStyle == 'Dotted') {
canvasCtx.setLineDash([1, 1]); // dotted line pattern
canvasCtx.beginPath();
canvasCtx.moveTo(lineStartX, lineY);
canvasCtx.lineTo(lineEndX, lineY);
canvasCtx.stroke();
} else if (underlineStyle == 'Solid') { // Draw solid lines for "phrases" in Japenese IME
canvasCtx.lineWidth = (underlineThickness == 'Thick') ? thickWidth : thinWidth;
canvasCtx.beginPath();
canvasCtx.moveTo(lineStartX, lineY);
canvasCtx.lineTo(lineEndX, lineY);
canvasCtx.stroke();
}

canvasCtx.restore();
}

function render(timestamp) {
if (!firstRenderTime) {
firstRenderTime = timestamp;
}
const elapsedTime = timestamp - firstRenderTime;

// Clear the canvas.
canvasCtx.clearRect(0, 0, canvas.width, canvas.height);

// Render the text.
canvasCtx.fillText(editContext.text, TEXT_ANCHOR_X, TEXT_ANCHOR_Y);

if (selectionStart == selectionEnd) {
// Render the caret.
const caretIndex = selectionStart;
const caretX = TEXT_ANCHOR_X + canvasCtx.measureText(editContext.text.substring(0, caretIndex)).width;

if (elapsedTime % BLINK_SPEED < BLINK_SPEED / 2) {
renderCaret(caretX);
}

// Update the EditContext's selection bounds to match the position and size of the caret.
const selectionBound = computeCharacterBound(caretIndex);
selectionBound.width = CARET_WIDTH;
editContext.updateSelectionBounds(selectionBound);
} else {
// Render the text selection.
const startX = TEXT_ANCHOR_X + canvasCtx.measureText(editContext.text.substring(0, selectionStart)).width;
const endX = TEXT_ANCHOR_X + canvasCtx.measureText(editContext.text.substring(0, selectionEnd)).width;

renderSelection(startX, endX);

// Re-render the section of text that's selected.
canvasCtx.save();
canvasCtx.fillStyle = SELECTION_TEXT_COLOR;
canvasCtx.fillText(editContext.text.substring(selectionStart, selectionEnd), TEXT_ANCHOR_X + canvasCtx.measureText(editContext.text.substring(0, selectionStart)).width, TEXT_ANCHOR_Y);
canvasCtx.restore();

// Update the EditContext's selection bounds to match the position and size of the selection.
const selectionStartBound = computeCharacterBound(selectionStart);
const selectionEndBound = computeCharacterBound(selectionEnd);
editContext.updateSelectionBounds(DOMRect.fromRect({
x: selectionStartBound.x,
y: selectionStartBound.y,
width: selectionEndBound.x + selectionEndBound.width - selectionStartBound.x,
height: selectionStartBound.height
}));
}

// Update the EditContext's control bounds.
const controlBound = canvas.getBoundingClientRect();
editContext.updateControlBounds(controlBound);

// Render the text formats if any.
if (textFormats.length) {
for (const textFormat of textFormats) {
renderUnderlineDecoration(textFormat);
}
}

// Re-render on animation frame.
rafID = requestAnimationFrame(render);
}

// Handle textupdate events.
editContext.addEventListener("textupdate", e => {
const text = e.text;

selectionStart = e.selectionStart;
selectionEnd = e.selectionEnd;
});

function computeCharacterBound(offset) {
const widthBeforeChar = canvasCtx.measureText(editContext.text.substring(0, offset)).width;
const charSize = canvasCtx.measureText(editContext.text[offset]);

const charX = canvas.offsetLeft + TEXT_ANCHOR_X + widthBeforeChar;
const charY = canvas.offsetTop + TEXT_ANCHOR_Y;

return DOMRect.fromRect({
x: charX,
y: charY - charSize.height,
width: charSize.width,
height: charSize.emHeightAscent
});
}

editContext.addEventListener("characterboundsupdate", e => {
const rangeStart = e.rangeStart;
const rangeEnd = e.rangeEnd;

// Calculate bounds for each character in the range.
if (rangeEnd > rangeStart) {
const charBounds = [];
for (offset = rangeStart; offset < rangeEnd; offset++) {
const bound = computeCharacterBound(offset);
charBounds.push(bound);
}
editContext.updateCharacterBounds(rangeStart, charBounds);
}
});

// Visually show when we're composing text.
editContext.addEventListener("compositionstart", e => {
canvas.classList.add("is-composing");
});
editContext.addEventListener("compositionend", e => {
canvas.classList.remove("is-composing");

// Clear the composition text formats.
textFormats = [];
});

editContext.addEventListener("textformatupdate", e => {
// Set the composition text formats so the next render picks them up.
textFormats = e.getTextFormats();
});

canvas.addEventListener("keydown", e => {
if (e.key == "Backspace" && editContext.selectionStart > 0) {
if (editContext.selectionStart === editContext.selectionEnd) {
editContext.updateText(
editContext.selectionStart - 1,
editContext.selectionStart,
""
);
editContext.updateSelection(editContext.selectionStart - 1, editContext.selectionStart - 1);
} else {
editContext.updateText(
editContext.selectionStart,
editContext.selectionEnd,
""
);
editContext.updateSelection(editContext.selectionStart, editContext.selectionStart);
}
} else if (e.key === "Delete" && editContext.selectionEnd < editContext.text.length) {
if (editContext.selectionStart === editContext.selectionEnd) {
editContext.updateText(
editContext.selectionEnd,
editContext.selectionEnd + 1,
""
);
editContext.updateSelection(editContext.selectionEnd, editContext.selectionEnd);
} else {
editContext.updateText(
editContext.selectionStart,
editContext.selectionEnd,
""
);
editContext.updateSelection(editContext.selectionStart, editContext.selectionStart);
}
} else if (e.key == "ArrowLeft") {
let newPos = editContext.selectionStart - 1;
if (newPos < 0) {
newPos = 0;
}

if (e.shiftKey) {
editContext.updateSelection(newPos, editContext.selectionEnd);
} else {
editContext.updateSelection(newPos, newPos);
}
} else if (e.key == "ArrowRight") {
const newPos = editContext.selectionEnd + 1;
if (newPos > editContext.text.length) {
newPos = editContext.text.length;
}

if (e.shiftKey) {
editContext.updateSelection(editContext.selectionStart, newPos);
} else {
editContext.updateSelection(newPos, newPos);
}
} else if (e.key === "Home") {
if (e.shiftKey) {
editContext.updateSelection(0, editContext.selectionEnd);
} else {
editContext.updateSelection(0, 0);
}
} else if (e.key === "End") {
if (e.shiftKey) {
editContext.updateSelection(editContext.selectionStart, editContext.text.length);
} else {
editContext.updateSelection(editContext.text.length, editContext.text.length);
}
}

selectionStart = editContext.selectionStart;
selectionEnd = editContext.selectionEnd;
});

canvas.addEventListener("focus", () => {
rafID = requestAnimationFrame(render);
});

canvas.addEventListener("blur", () => {
cancelAnimationFrame(rafID);
});
</script>
</body>

</html>
Loading

0 comments on commit f9dee95

Please sign in to comment.