1
Fork 0

Proper editor

This commit is contained in:
Jan-Erik Rediger 2024-05-25 17:38:09 +02:00
parent dd26912eee
commit a837997f82
3 changed files with 649 additions and 71 deletions

View file

@ -1,71 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>code</title>
<link rel="stylesheet" href="" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="codapi.js"></script>
<script src="codapi-settings.js"></script>
<link rel="stylesheet" href="codapi.css" />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
}
pre {
margin-bottom: 0;
background: #f5f5f5;
line-height: 1.2;
}
code {
display: block;
padding: 1.5em;
}
codapi-snippet {
display: block;
margin-bottom: 0.8em;
background: #f5f5f5;
}
codapi-toolbar button {
padding: 0;
border: none;
background: none;
line-height: 1.35;
color: #008bf5;
font-size: 16px;
}
codapi-toolbar button::after {
content: " ▶";
}
codapi-toolbar a {
color: #008bf5;
border-bottom: 1px solid rgba(0, 139, 245, 0.2);
text-decoration: none;
font-size: 16px;
}
</style>
</head>
<body>
<codapi-settings url="https://hare-exec.fly.dev/v1"></codapi-settings>
<div id="playground">
<pre class="code"><code contenteditable="true">use fmt;
export fn main() void = {
const greetings = [
"Hello, world!",
"¡Hola Mundo!",
"Γειά σου Κόσμε!",
"Привіт, світ!",
"こんにちは世界!",
];
for (let i = 0z; i < len(greetings); i+= 1) {
fmt::println(greetings[i])!;
};
};
</code></pre>
</div>
<codapi-snippet sandbox="hare" editor="basic" selector="#playground .code">
</codapi-snippet>
</body>
</html>

511
frontend/codejar.js Normal file
View file

@ -0,0 +1,511 @@
const globalWindow = window;
export function CodeJar(editor, highlight, opt = {}) {
const options = {
tab: '\t',
indentOn: /[({\[]$/,
moveToNewLine: /^[)}\]]/,
spellcheck: false,
catchTab: true,
preserveIdent: true,
addClosing: true,
history: true,
window: globalWindow,
...opt,
};
const window = options.window;
const document = window.document;
const listeners = [];
const history = [];
let at = -1;
let focus = false;
let onUpdate = () => void 0;
let prev; // code content prior keydown event
editor.setAttribute('contenteditable', 'plaintext-only');
editor.setAttribute('spellcheck', options.spellcheck ? 'true' : 'false');
editor.style.outline = 'none';
editor.style.overflowWrap = 'break-word';
editor.style.overflowY = 'auto';
editor.style.whiteSpace = 'pre-wrap';
const doHighlight = (editor, pos) => {
highlight(editor, pos);
};
let isLegacy = false; // true if plaintext-only is not supported
if (editor.contentEditable !== 'plaintext-only')
isLegacy = true;
if (isLegacy)
editor.setAttribute('contenteditable', 'true');
const debounceHighlight = debounce(() => {
const pos = save();
doHighlight(editor, pos);
restore(pos);
}, 30);
let recording = false;
const shouldRecord = (event) => {
return !isUndo(event) && !isRedo(event)
&& event.key !== 'Meta'
&& event.key !== 'Control'
&& event.key !== 'Alt'
&& !event.key.startsWith('Arrow');
};
const debounceRecordHistory = debounce((event) => {
if (shouldRecord(event)) {
recordHistory();
recording = false;
}
}, 300);
const on = (type, fn) => {
listeners.push([type, fn]);
editor.addEventListener(type, fn);
};
on('keydown', event => {
if (event.defaultPrevented)
return;
prev = toString();
if (options.preserveIdent)
handleNewLine(event);
else
legacyNewLineFix(event);
if (options.catchTab)
handleTabCharacters(event);
if (options.addClosing)
handleSelfClosingCharacters(event);
if (options.history) {
handleUndoRedo(event);
if (shouldRecord(event) && !recording) {
recordHistory();
recording = true;
}
}
if (isLegacy && !isCopy(event))
restore(save());
});
on('keyup', event => {
if (event.defaultPrevented)
return;
if (event.isComposing)
return;
if (prev !== toString())
debounceHighlight();
debounceRecordHistory(event);
onUpdate(toString());
});
on('focus', _event => {
focus = true;
});
on('blur', _event => {
focus = false;
});
on('paste', event => {
recordHistory();
handlePaste(event);
recordHistory();
onUpdate(toString());
});
on('cut', event => {
recordHistory();
handleCut(event);
recordHistory();
onUpdate(toString());
});
function save() {
const s = getSelection();
const pos = { start: 0, end: 0, dir: undefined };
let { anchorNode, anchorOffset, focusNode, focusOffset } = s;
if (!anchorNode || !focusNode)
throw 'error1';
// If the anchor and focus are the editor element, return either a full
// highlight or a start/end cursor position depending on the selection
if (anchorNode === editor && focusNode === editor) {
pos.start = (anchorOffset > 0 && editor.textContent) ? editor.textContent.length : 0;
pos.end = (focusOffset > 0 && editor.textContent) ? editor.textContent.length : 0;
pos.dir = (focusOffset >= anchorOffset) ? '->' : '<-';
return pos;
}
// Selection anchor and focus are expected to be text nodes,
// so normalize them.
if (anchorNode.nodeType === Node.ELEMENT_NODE) {
const node = document.createTextNode('');
anchorNode.insertBefore(node, anchorNode.childNodes[anchorOffset]);
anchorNode = node;
anchorOffset = 0;
}
if (focusNode.nodeType === Node.ELEMENT_NODE) {
const node = document.createTextNode('');
focusNode.insertBefore(node, focusNode.childNodes[focusOffset]);
focusNode = node;
focusOffset = 0;
}
visit(editor, el => {
if (el === anchorNode && el === focusNode) {
pos.start += anchorOffset;
pos.end += focusOffset;
pos.dir = anchorOffset <= focusOffset ? '->' : '<-';
return 'stop';
}
if (el === anchorNode) {
pos.start += anchorOffset;
if (!pos.dir) {
pos.dir = '->';
}
else {
return 'stop';
}
}
else if (el === focusNode) {
pos.end += focusOffset;
if (!pos.dir) {
pos.dir = '<-';
}
else {
return 'stop';
}
}
if (el.nodeType === Node.TEXT_NODE) {
if (pos.dir != '->')
pos.start += el.nodeValue.length;
if (pos.dir != '<-')
pos.end += el.nodeValue.length;
}
});
editor.normalize(); // collapse empty text nodes
return pos;
}
function restore(pos) {
const s = getSelection();
let startNode, startOffset = 0;
let endNode, endOffset = 0;
if (!pos.dir)
pos.dir = '->';
if (pos.start < 0)
pos.start = 0;
if (pos.end < 0)
pos.end = 0;
// Flip start and end if the direction reversed
if (pos.dir == '<-') {
const { start, end } = pos;
pos.start = end;
pos.end = start;
}
let current = 0;
visit(editor, el => {
if (el.nodeType !== Node.TEXT_NODE)
return;
const len = (el.nodeValue || '').length;
if (current + len > pos.start) {
if (!startNode) {
startNode = el;
startOffset = pos.start - current;
}
if (current + len > pos.end) {
endNode = el;
endOffset = pos.end - current;
return 'stop';
}
}
current += len;
});
if (!startNode)
startNode = editor, startOffset = editor.childNodes.length;
if (!endNode)
endNode = editor, endOffset = editor.childNodes.length;
// Flip back the selection
if (pos.dir == '<-') {
[startNode, startOffset, endNode, endOffset] = [endNode, endOffset, startNode, startOffset];
}
{
// If nodes not editable, create a text node.
const startEl = uneditable(startNode);
if (startEl) {
const node = document.createTextNode('');
startEl.parentNode?.insertBefore(node, startEl);
startNode = node;
startOffset = 0;
}
const endEl = uneditable(endNode);
if (endEl) {
const node = document.createTextNode('');
endEl.parentNode?.insertBefore(node, endEl);
endNode = node;
endOffset = 0;
}
}
s.setBaseAndExtent(startNode, startOffset, endNode, endOffset);
editor.normalize(); // collapse empty text nodes
}
function uneditable(node) {
while (node && node !== editor) {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node;
if (el.getAttribute('contenteditable') == 'false') {
return el;
}
}
node = node.parentNode;
}
}
function beforeCursor() {
const s = getSelection();
const r0 = s.getRangeAt(0);
const r = document.createRange();
r.selectNodeContents(editor);
r.setEnd(r0.startContainer, r0.startOffset);
return r.toString();
}
function afterCursor() {
const s = getSelection();
const r0 = s.getRangeAt(0);
const r = document.createRange();
r.selectNodeContents(editor);
r.setStart(r0.endContainer, r0.endOffset);
return r.toString();
}
function handleNewLine(event) {
if (event.key === 'Enter') {
const before = beforeCursor();
const after = afterCursor();
let [padding] = findPadding(before);
let newLinePadding = padding;
// If last symbol is "{" ident new line
if (options.indentOn.test(before)) {
newLinePadding += options.tab;
}
// Preserve padding
if (newLinePadding.length > 0) {
preventDefault(event);
event.stopPropagation();
insert('\n' + newLinePadding);
}
else {
legacyNewLineFix(event);
}
// Place adjacent "}" on next line
if (newLinePadding !== padding && options.moveToNewLine.test(after)) {
const pos = save();
insert('\n' + padding);
restore(pos);
}
}
}
function legacyNewLineFix(event) {
// Firefox does not support plaintext-only mode
// and puts <div><br></div> on Enter. Let's help.
if (isLegacy && event.key === 'Enter') {
preventDefault(event);
event.stopPropagation();
if (afterCursor() == '') {
insert('\n ');
const pos = save();
pos.start = --pos.end;
restore(pos);
}
else {
insert('\n');
}
}
}
function handleSelfClosingCharacters(event) {
const open = `([{'"`;
const close = `)]}'"`;
if (open.includes(event.key)) {
preventDefault(event);
const pos = save();
const wrapText = pos.start == pos.end ? '' : getSelection().toString();
const text = event.key + wrapText + close[open.indexOf(event.key)];
insert(text);
pos.start++;
pos.end++;
restore(pos);
}
}
function handleTabCharacters(event) {
if (event.key === 'Tab') {
preventDefault(event);
if (event.shiftKey) {
const before = beforeCursor();
let [padding, start] = findPadding(before);
if (padding.length > 0) {
const pos = save();
// Remove full length tab or just remaining padding
const len = Math.min(options.tab.length, padding.length);
restore({ start, end: start + len });
document.execCommand('delete');
pos.start -= len;
pos.end -= len;
restore(pos);
}
}
else {
insert(options.tab);
}
}
}
function handleUndoRedo(event) {
if (isUndo(event)) {
preventDefault(event);
at--;
const record = history[at];
if (record) {
editor.innerHTML = record.html;
restore(record.pos);
}
if (at < 0)
at = 0;
}
if (isRedo(event)) {
preventDefault(event);
at++;
const record = history[at];
if (record) {
editor.innerHTML = record.html;
restore(record.pos);
}
if (at >= history.length)
at--;
}
}
function recordHistory() {
if (!focus)
return;
const html = editor.innerHTML;
const pos = save();
const lastRecord = history[at];
if (lastRecord) {
if (lastRecord.html === html
&& lastRecord.pos.start === pos.start
&& lastRecord.pos.end === pos.end)
return;
}
at++;
history[at] = { html, pos };
history.splice(at + 1);
const maxHistory = 300;
if (at > maxHistory) {
at = maxHistory;
history.splice(0, 1);
}
}
function handlePaste(event) {
if (event.defaultPrevented)
return;
preventDefault(event);
const originalEvent = event.originalEvent ?? event;
const text = originalEvent.clipboardData.getData('text/plain').replace(/\r\n?/g, '\n');
const pos = save();
insert(text);
doHighlight(editor);
restore({
start: Math.min(pos.start, pos.end) + text.length,
end: Math.min(pos.start, pos.end) + text.length,
dir: '<-',
});
}
function handleCut(event) {
const pos = save();
const selection = getSelection();
const originalEvent = event.originalEvent ?? event;
originalEvent.clipboardData.setData('text/plain', selection.toString());
document.execCommand('delete');
doHighlight(editor);
restore({
start: Math.min(pos.start, pos.end),
end: Math.min(pos.start, pos.end),
dir: '<-',
});
preventDefault(event);
}
function visit(editor, visitor) {
const queue = [];
if (editor.firstChild)
queue.push(editor.firstChild);
let el = queue.pop();
while (el) {
if (visitor(el) === 'stop')
break;
if (el.nextSibling)
queue.push(el.nextSibling);
if (el.firstChild)
queue.push(el.firstChild);
el = queue.pop();
}
}
function isCtrl(event) {
return event.metaKey || event.ctrlKey;
}
function isUndo(event) {
return isCtrl(event) && !event.shiftKey && getKeyCode(event) === 'Z';
}
function isRedo(event) {
return isCtrl(event) && event.shiftKey && getKeyCode(event) === 'Z';
}
function isCopy(event) {
return isCtrl(event) && getKeyCode(event) === 'C';
}
function getKeyCode(event) {
let key = event.key || event.keyCode || event.which;
if (!key)
return undefined;
return (typeof key === 'string' ? key : String.fromCharCode(key)).toUpperCase();
}
function insert(text) {
text = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
document.execCommand('insertHTML', false, text);
}
function debounce(cb, wait) {
let timeout = 0;
return (...args) => {
clearTimeout(timeout);
timeout = window.setTimeout(() => cb(...args), wait);
};
}
function findPadding(text) {
// Find beginning of previous line.
let i = text.length - 1;
while (i >= 0 && text[i] !== '\n')
i--;
i++;
// Find padding of the line.
let j = i;
while (j < text.length && /[ \t]/.test(text[j]))
j++;
return [text.substring(i, j) || '', i, j];
}
function toString() {
return editor.textContent || '';
}
function preventDefault(event) {
event.preventDefault();
}
function getSelection() {
if (editor.parentNode?.nodeType == Node.DOCUMENT_FRAGMENT_NODE) {
return editor.parentNode.getSelection();
}
return window.getSelection();
}
return {
updateOptions(newOptions) {
Object.assign(options, newOptions);
},
updateCode(code) {
editor.textContent = code;
doHighlight(editor);
onUpdate(code);
},
onUpdate(callback) {
onUpdate = callback;
},
toString,
save,
restore,
recordHistory,
destroy() {
for (let [type, fn] of listeners) {
editor.removeEventListener(type, fn);
}
},
};
}

138
frontend/index.html Normal file
View file

@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>code</title>
<link rel="stylesheet" href="" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="codapi.js"></script>
<script src="codapi-settings.js"></script>
<link rel="stylesheet" href="codapi.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.18.1/styles/github.min.css" />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
}
codapi-snippet {
display: block;
margin: 10px 0;
}
codapi-snippet button {
padding: 0;
border: none;
background: none;
line-height: 1.35;
color: #008bf5;
font-size: 16px;
}
codapi-toolbar button::after {
content: " ▶";
}
codapi-toolbar a {
color: #008bf5;
border-bottom: 1px solid rgba(0, 139, 245, 0.2);
text-decoration: none;
font-size: 16px;
}
.editor {
border-radius: 6px;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12), 0 3px 1px -2px rgba(0, 0, 0, 0.2);
font-family: 'Source Code Pro', monospace;
font-size: 14px;
font-weight: 400;
height: 340px;
letter-spacing: normal;
line-height: 20px;
padding: 10px;
resize: none !important;
tab-size: 4;
}
codapi-output {
border-radius: 6px;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12), 0 3px 1px -2px rgba(0, 0, 0, 0.2);
font-family: 'Source Code Pro', monospace;
font-size: 14px;
font-weight: 400;
letter-spacing: normal;
line-height: 20px;
padding: 10px;
resize: none !important;
tab-size: 4;
background-color: #f8f8f8;
}
codapi-output pre {
margin: 0;
}
</style>
</head>
<body>
<codapi-settings url="https://hare-exec.fly.dev/v1"></codapi-settings>
<div class="editor language-c"></div>
<codapi-snippet sandbox="hare" editor="external" selector=".editor">
<button class="share">Share</button>
</codapi-snippet>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.18.1/highlight.min.js"></script>
<script type="module">
import {CodeJar} from "./codejar.js"
const highlight = editor => {
editor.textContent = editor.textContent
hljs.highlightBlock(editor)
}
function base64ToBytes(base64) {
const binString = atob(base64);
return Uint8Array.from(binString, (m) => m.codePointAt(0));
}
function base64Encode(str) {
return bytesToBase64(new TextEncoder().encode(str));
}
function bytesToBase64(bytes) {
const binString = Array.from(bytes, (byte) =>
String.fromCodePoint(byte),
).join("");
return btoa(binString);
}
function base64Decode(bytes) {
return new TextDecoder().decode(base64ToBytes(bytes));
}
let jar = CodeJar(document.querySelector('.editor'), highlight)
const defaultCode = `use fmt;
export fn main() void = {
const greetings = [
"Hello, world!",
"¡Hola Mundo!",
"Γειά σου Κόσμε!",
"Привіт, світ!",
"こんにちは世界!",
];
for (let i = 0z; i < len(greetings); i+= 1) {
fmt::println(greetings[i])!;
};
};`
if (location.hash) {
let code = base64Decode(location.hash.substr(1));
jar.updateCode(code)
} else {
location.hash = base64Encode(defaultCode);
jar.updateCode(defaultCode);
}
document.getElementsByClassName("share")[0].onclick = (e) => {
e.preventDefault();
let code = document.querySelector('.editor').innerText;
location.hash = base64Encode(code);
}
</script>
</body>
</html>