mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-14 00:34:23 +00:00
Assistant Builder improvements (#851)
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
1 parent
b1459523d9
commit
88aab302a2
37 files changed
+4098
-482
No files matched your search
@@ -0,0 +1,433 @@
|
||||
import { CodeJar } from "./codejar.js?v=20260707";
|
||||
|
||||
const editors = new Map();
|
||||
|
||||
const LUA_KEYWORDS = new Set([
|
||||
'and', 'break', 'do', 'else', 'elseif', 'end', 'for', 'function', 'goto',
|
||||
'if', 'in', 'local', 'not', 'or', 'repeat', 'return', 'then', 'until', 'while'
|
||||
]);
|
||||
const LUA_LITERALS = new Set(['false', 'nil', 'true']);
|
||||
const LUA_BUILT_INS = new Set([
|
||||
'_G', '_VERSION', 'assert', 'collectgarbage', 'dofile', 'error', 'getmetatable',
|
||||
'ipairs', 'load', 'loadfile', 'next', 'pairs', 'pcall', 'print', 'rawequal',
|
||||
'rawget', 'rawlen', 'rawset', 'require', 'select', 'setmetatable', 'tonumber',
|
||||
'tostring', 'type', 'xpcall', 'coroutine', 'debug', 'io', 'math', 'os',
|
||||
'package', 'string', 'table', 'utf8'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Creates a CodeJar editor for the Blazor component instance.
|
||||
*
|
||||
* CodeJar's public surface is intentionally small:
|
||||
* - CodeJar(element, highlighter, options) turns a 'contenteditable' element into an editor.
|
||||
* - updateCode(code) replaces the editor content and reruns highlighting.
|
||||
* - toString() reads the plain text content back out.
|
||||
* - destroy() removes listeners created by CodeJar.
|
||||
*
|
||||
* The highlighter callback receives the editor DOM node. It must write highlighted
|
||||
* HTML back into that node, so every token emitted by our highlighter is HTML-escaped.
|
||||
*/
|
||||
export function init(id, element, lineNumbersElement, code, language) {
|
||||
const codeJar = CodeJar(element, getHighlighter(language), {
|
||||
tab: ' ',
|
||||
spellcheck: false
|
||||
});
|
||||
// CodeJar enables soft wrapping by default, which cannot stay aligned with a newline-based gutter.
|
||||
element.style.whiteSpace = 'pre';
|
||||
element.style.overflowWrap = 'normal';
|
||||
const scrollHandler = () => syncLineNumbersScroll(element, lineNumbersElement);
|
||||
|
||||
codeJar.updateCode(code ?? '');
|
||||
updateLineNumbers(lineNumbersElement, codeJar.toString());
|
||||
codeJar.onUpdate(updatedCode => updateLineNumbers(lineNumbersElement, updatedCode));
|
||||
element.addEventListener('scroll', scrollHandler);
|
||||
editors.set(id, { codeJar, element, scrollHandler });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current plain text from a CodeJar instance.
|
||||
*/
|
||||
export function getCode(id) {
|
||||
return editors.get(id)?.codeJar.toString() ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the editor content through CodeJar so the cursor/history/highlighter
|
||||
* state stays consistent with CodeJar's internal model.
|
||||
*/
|
||||
export function setCode(id, code) {
|
||||
const editor = editors.get(id);
|
||||
if (!editor)
|
||||
return;
|
||||
|
||||
editor.codeJar.updateCode(code ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes one editor instance and removes it from the JS-side registry.
|
||||
*/
|
||||
export function destroy(id) {
|
||||
const editor = editors.get(id);
|
||||
if (!editor)
|
||||
return;
|
||||
|
||||
editor.element.removeEventListener('scroll', editor.scrollHandler);
|
||||
editor.codeJar.destroy();
|
||||
editors.delete(id);
|
||||
}
|
||||
|
||||
function updateLineNumbers(lineNumbersElement, code) {
|
||||
const lineCount = (code.match(/\n/g)?.length ?? 0) + 1;
|
||||
let lineNumbers = '';
|
||||
for (let lineNumber = 1; lineNumber <= lineCount; lineNumber++) {
|
||||
if (lineNumber > 1)
|
||||
lineNumbers += '\n';
|
||||
|
||||
lineNumbers += lineNumber;
|
||||
}
|
||||
|
||||
lineNumbersElement.textContent = lineNumbers;
|
||||
}
|
||||
|
||||
function syncLineNumbersScroll(editorElement, lineNumbersElement) {
|
||||
lineNumbersElement.scrollTop = editorElement.scrollTop;
|
||||
}
|
||||
|
||||
function highlightLua(editor) {
|
||||
editor.innerHTML = highlightLuaCode(editor.textContent ?? '');
|
||||
}
|
||||
|
||||
function highlightPlainText() {
|
||||
}
|
||||
|
||||
function getHighlighter(language) {
|
||||
switch ((language ?? '').toLowerCase()) {
|
||||
case 'lua':
|
||||
return highlightLua;
|
||||
|
||||
default:
|
||||
return highlightPlainText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight Lua highlighter.
|
||||
*
|
||||
* This intentionally does not use one large regex. Lua comments, long strings
|
||||
* (`[[...]]`, `[=[...]=]`), quoted strings and numbers can overlap with words
|
||||
* that would otherwise look like keywords or variables. A small scanner lets us
|
||||
* consume those regions first and only tokenize identifiers after that.
|
||||
*/
|
||||
function highlightLuaCode(code) {
|
||||
let html = '';
|
||||
let index = 0;
|
||||
const localVariables = collectLuaLocalVariables(code);
|
||||
|
||||
while (index < code.length) {
|
||||
const char = code[index];
|
||||
const next = code[index + 1];
|
||||
|
||||
if (char === '-' && next === '-') {
|
||||
const longCommentEnd = readLuaLongBracketEnd(code, index + 2);
|
||||
if (longCommentEnd) {
|
||||
html += wrapLuaToken(code.slice(index, longCommentEnd.end), 'comment');
|
||||
index = longCommentEnd.end;
|
||||
continue;
|
||||
}
|
||||
|
||||
const lineEnd = findLineEnd(code, index);
|
||||
html += wrapLuaToken(code.slice(index, lineEnd), 'comment');
|
||||
index = lineEnd;
|
||||
continue;
|
||||
}
|
||||
|
||||
const longStringEnd = readLuaLongBracketEnd(code, index);
|
||||
if (longStringEnd) {
|
||||
html += wrapLuaToken(code.slice(index, longStringEnd.end), 'string');
|
||||
index = longStringEnd.end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
const stringEnd = readQuotedStringEnd(code, index, char);
|
||||
html += wrapLuaToken(code.slice(index, stringEnd), 'string');
|
||||
index = stringEnd;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isNumberStart(code, index)) {
|
||||
const numberEnd = readNumberEnd(code, index);
|
||||
html += wrapLuaToken(code.slice(index, numberEnd), 'number');
|
||||
index = numberEnd;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isIdentifierStart(char)) {
|
||||
const functionCallEnd = readFunctionCallNameEnd(code, index);
|
||||
if (functionCallEnd > index) {
|
||||
html += wrapLuaToken(code.slice(index, functionCallEnd), 'function');
|
||||
index = functionCallEnd;
|
||||
continue;
|
||||
}
|
||||
|
||||
const identifierEnd = readIdentifierEnd(code, index);
|
||||
const identifier = code.slice(index, identifierEnd);
|
||||
const previousChar = findPreviousNonWhitespaceChar(code, index);
|
||||
html += highlightLuaIdentifier(identifier, localVariables, previousChar);
|
||||
index = identifierEnd;
|
||||
continue;
|
||||
}
|
||||
|
||||
html += escapeHtml(char);
|
||||
index++;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies one Lua identifier after the scanner has ruled out comments,
|
||||
* strings and numbers.
|
||||
*/
|
||||
function highlightLuaIdentifier(identifier, localVariables, previousChar) {
|
||||
if (LUA_KEYWORDS.has(identifier))
|
||||
return wrapLuaToken(identifier, 'keyword');
|
||||
|
||||
if (LUA_LITERALS.has(identifier))
|
||||
return wrapLuaToken(identifier, 'literal');
|
||||
|
||||
if (LUA_BUILT_INS.has(identifier))
|
||||
return wrapLuaToken(identifier, 'built-in');
|
||||
|
||||
if (isLuaConstant(identifier))
|
||||
return wrapLuaToken(identifier, 'constant');
|
||||
|
||||
if (previousChar === '.' || previousChar === ':')
|
||||
return wrapLuaToken(identifier, 'property');
|
||||
|
||||
if (localVariables.has(identifier))
|
||||
return wrapLuaToken(identifier, 'variable');
|
||||
|
||||
return escapeHtml(identifier);
|
||||
}
|
||||
|
||||
function wrapLuaToken(text, tokenClass) {
|
||||
return `<span class="lua-token lua-${tokenClass}">${escapeHtml(text)}</span>`;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function findLineEnd(code, start) {
|
||||
const lineEnd = code.indexOf('\n', start);
|
||||
return lineEnd < 0 ? code.length : lineEnd;
|
||||
}
|
||||
|
||||
function readQuotedStringEnd(code, start, quote) {
|
||||
let index = start + 1;
|
||||
while (index < code.length) {
|
||||
if (code[index] === '\\') {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (code[index] === quote)
|
||||
return index + 1;
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
return code.length;
|
||||
}
|
||||
|
||||
function readLuaLongBracketEnd(code, start) {
|
||||
if (code[start] !== '[')
|
||||
return null;
|
||||
|
||||
let equalsCount = 0;
|
||||
let index = start + 1;
|
||||
while (code[index] === '=') {
|
||||
equalsCount++;
|
||||
index++;
|
||||
}
|
||||
|
||||
if (code[index] !== '[')
|
||||
return null;
|
||||
|
||||
const close = `]${'='.repeat(equalsCount)}]`;
|
||||
const closeIndex = code.indexOf(close, index + 1);
|
||||
return {
|
||||
end: closeIndex < 0 ? code.length : closeIndex + close.length
|
||||
};
|
||||
}
|
||||
|
||||
function isNumberStart(code, index) {
|
||||
const char = code[index];
|
||||
const next = code[index + 1];
|
||||
return isDigit(char) || char === '.' && isDigit(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a permissive Lua number token.
|
||||
*
|
||||
* The character class covers decimal numbers, hex numbers (`0xff`), exponents
|
||||
* (`1e-3`, `0x1p+4`) and separators/dots used while the user is still typing.
|
||||
*/
|
||||
function readNumberEnd(code, start) {
|
||||
let index = start;
|
||||
while (index < code.length && /[0-9a-fA-FxXpPeE+\-_.]/.test(code[index]))
|
||||
index++;
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
function isIdentifierStart(char) {
|
||||
return /[A-Za-z_]/.test(char);
|
||||
}
|
||||
|
||||
function readIdentifierEnd(code, start) {
|
||||
let index = start + 1;
|
||||
while (index < code.length && /[A-Za-z0-9_]/.test(code[index]))
|
||||
index++;
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
function isDigit(char) {
|
||||
return /[0-9]/.test(char);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects function-call expressions such as `print(`, `table.insert(` or
|
||||
* `object:method(` and colors the whole call target as a function.
|
||||
*/
|
||||
function readFunctionCallNameEnd(code, start) {
|
||||
let index = readIdentifierEnd(code, start);
|
||||
let hasMember = false;
|
||||
|
||||
while (code[index] === '.' || code[index] === ':') {
|
||||
const memberStart = index + 1;
|
||||
if (!isIdentifierStart(code[memberStart]))
|
||||
break;
|
||||
|
||||
hasMember = true;
|
||||
index = readIdentifierEnd(code, memberStart);
|
||||
}
|
||||
|
||||
const nextIndex = skipWhitespace(code, index);
|
||||
if (code[nextIndex] === '(' && (hasMember || LUA_BUILT_INS.has(code.slice(start, index))))
|
||||
return index;
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects names declared after `local` so later identifier tokens can be styled
|
||||
* as local variables. The scanner skips comments and strings first to avoid
|
||||
* treating text inside them as declarations.
|
||||
*/
|
||||
function collectLuaLocalVariables(code) {
|
||||
const variables = new Set();
|
||||
let index = 0;
|
||||
|
||||
while (index < code.length) {
|
||||
const char = code[index];
|
||||
const next = code[index + 1];
|
||||
|
||||
if (char === '-' && next === '-') {
|
||||
const longCommentEnd = readLuaLongBracketEnd(code, index + 2);
|
||||
index = longCommentEnd?.end ?? findLineEnd(code, index);
|
||||
continue;
|
||||
}
|
||||
|
||||
const longStringEnd = readLuaLongBracketEnd(code, index);
|
||||
if (longStringEnd) {
|
||||
index = longStringEnd.end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
index = readQuotedStringEnd(code, index, char);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isIdentifierStart(char)) {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const identifierEnd = readIdentifierEnd(code, index);
|
||||
const identifier = code.slice(index, identifierEnd);
|
||||
if (identifier !== 'local') {
|
||||
index = identifierEnd;
|
||||
continue;
|
||||
}
|
||||
|
||||
index = readLocalDeclarationVariables(code, identifierEnd, variables);
|
||||
}
|
||||
|
||||
return variables;
|
||||
}
|
||||
|
||||
function readLocalDeclarationVariables(code, start, variables) {
|
||||
let index = skipWhitespace(code, start);
|
||||
|
||||
if (code.startsWith('function', index) && !isIdentifierPart(code[index + 'function'.length])) {
|
||||
index = skipWhitespace(code, index + 'function'.length);
|
||||
if (isIdentifierStart(code[index])) {
|
||||
const functionNameEnd = readIdentifierEnd(code, index);
|
||||
variables.add(code.slice(index, functionNameEnd));
|
||||
return functionNameEnd;
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
while (index < code.length) {
|
||||
index = skipWhitespace(code, index);
|
||||
if (!isIdentifierStart(code[index]))
|
||||
break;
|
||||
|
||||
const nameEnd = readIdentifierEnd(code, index);
|
||||
variables.add(code.slice(index, nameEnd));
|
||||
index = skipWhitespace(code, nameEnd);
|
||||
|
||||
if (code[index] !== ',')
|
||||
break;
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
function findPreviousNonWhitespaceChar(code, start) {
|
||||
let index = start - 1;
|
||||
while (index >= 0 && /\s/.test(code[index]))
|
||||
index--;
|
||||
|
||||
return index < 0 ? '' : code[index];
|
||||
}
|
||||
|
||||
function skipWhitespace(code, start) {
|
||||
let index = start;
|
||||
while (index < code.length && /\s/.test(code[index]))
|
||||
index++;
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
function isIdentifierPart(char) {
|
||||
return /[A-Za-z0-9_]/.test(char ?? '');
|
||||
}
|
||||
|
||||
function isLuaConstant(identifier) {
|
||||
// Constants are a convention here, not Lua syntax: `TRANSLATION_SYSTEM_PROMPT`.
|
||||
return identifier.length > 1 && /^[A-Z][A-Z0-9_]*$/.test(identifier);
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
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,
|
||||
autoclose: {
|
||||
open: `([{'"`,
|
||||
close: `)]}'"`
|
||||
},
|
||||
...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);
|
||||
};
|
||||
const matchFirefoxVersion = window.navigator.userAgent.match(/Firefox\/([0-9]+)\./);
|
||||
const firefoxVersion = matchFirefoxVersion
|
||||
? parseInt(matchFirefoxVersion[1])
|
||||
: 0;
|
||||
let isLegacy = false; // true if plaintext-only is not supported
|
||||
if (editor.contentEditable !== "plaintext-only" || firefoxVersion >= 136)
|
||||
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 = options.autoclose.open;
|
||||
const close = options.autoclose.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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
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() {
|
||||
// @ts-ignore
|
||||
return editor.getRootNode().getSelection();
|
||||
}
|
||||
return {
|
||||
updateOptions(newOptions) {
|
||||
Object.assign(options, newOptions);
|
||||
},
|
||||
updateCode(code, callOnUpdate = true) {
|
||||
editor.textContent = code;
|
||||
doHighlight(editor);
|
||||
callOnUpdate && onUpdate(code);
|
||||
},
|
||||
onUpdate(callback) {
|
||||
onUpdate = callback;
|
||||
},
|
||||
toString,
|
||||
save,
|
||||
restore,
|
||||
recordHistory,
|
||||
destroy() {
|
||||
for (let [type, fn] of listeners) {
|
||||
editor.removeEventListener(type, fn);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user