prism-react-editor
Version:
Lightweight, extensible code editor component for React apps
403 lines (402 loc) • 15.9 kB
JavaScript
import { useLayoutEffect, useMemo, useEffect } from "react";
import { u as useStableRef, a as addListener, p as preventDefault, d as doc, n as numLines } from "./core-Dm5I6BkG.js";
import { u as useEditorSearch } from "./search-CT5tL2B1.js";
import { a as addTextareaListener, d as insertText, s as setSelection, m as scrollToEl, k as createTemplate, b as addListener2, i as isMac, q as addOverlay, u as updateNode, g as getModifierCode, h as getLineBefore, j as getLineStart, f as getLineEnd, w as isWebKit, r as regexEscape } from "./local-Cq-4Fajb.js";
import { g as getStyleValue } from "./other-EdiSn7BB.js";
const useEditorReplace = (editor, initClassName, initZIndex) => {
const search = useEditorSearch(editor, initClassName, initZIndex);
const getSelection = editor.getSelection;
const matches = search.matches;
const closest = () => {
const caretPos = getSelection()[0];
const l = matches.length;
for (let i = l; i; ) {
if (caretPos >= matches[--i][1]) return (i + (matches[i][0] < caretPos)) % l;
}
return l ? 0 : -1;
};
const toggleClasses = () => {
currentLine?.classList.toggle("match-highlight");
currentMatch?.classList.toggle("match");
};
const removeSelection = useStableRef(() => {
if (hasSelected) {
toggleClasses();
hasSelected = false;
}
});
let currentLine;
let currentMatch;
let hasSelected = false;
useLayoutEffect(() => {
return addTextareaListener(editor, "focus", removeSelection);
}, []);
useLayoutEffect(() => removeSelection, []);
return useMemo(
() => Object.assign(search, {
next() {
const cursor = getSelection()[1];
const l = matches.length;
for (let i = 0, match; i < l; i++) {
match = matches[i];
if (match[0] - (match[0] == match[1]) >= cursor) return i;
}
return l ? 0 : -1;
},
prev() {
const cursor = getSelection()[0];
const l = matches.length;
for (let i = l, match; i; ) {
match = matches[--i];
if (match[1] + (match[0] == match[1]) <= cursor) return i;
}
return l - 1;
},
closest,
selectMatch(index, scrollPadding) {
removeSelection();
if (matches[index]) {
setSelection(editor, ...matches[index]);
currentLine = editor.lines[editor.activeLine];
currentMatch = search.container.children[index];
hasSelected = true;
toggleClasses();
if (currentMatch) {
scrollToEl(editor, currentMatch, scrollPadding);
}
}
},
replace(str) {
if (matches[0]) {
let index = closest();
let [start, end] = matches[index];
let [caretStart, caretEnd] = getSelection();
let notSelected = start != caretStart || end != caretEnd;
if (notSelected) return index;
if (editor.value.slice(start, end) == str) return matches[++index] ? index : 0;
return insertText(editor, str);
}
},
replaceAll(str) {
if (!matches[0]) return;
let value = editor.value;
let [start, end] = getSelection();
let newLen = str.length;
let newStart = start;
let newEnd = end;
let newValue = "";
let l = matches.length;
for (let i = 0; i < l; i++) {
const [matchStart, matchEnd] = matches[i];
const lengthDiff = newLen - matchEnd + matchStart;
const move = (pos) => matchStart > pos ? 0 : pos >= matchEnd ? lengthDiff : lengthDiff < 0 && pos > matchStart + newLen ? newLen + matchStart - pos : 0;
newEnd += move(end);
newStart += move(start);
newValue += i ? value.slice(matches[i - 1][1], matchStart) + str : str;
}
insertText(editor, newValue, matches[0][0], matches[l - 1][1], newStart, newEnd);
}
}),
[]
);
};
const shortcut = ` (Alt+${isMac ? "Cmd+" : ""}`;
const template = /* @__PURE__ */ createTemplate(
`<div class=prism-search-container style=display:flex;align-items:flex-start;justify-content:flex-end><div dir=ltr class=prism-search><button type=button aria-expanded=false title="Toggle Replace" class=pce-expand></button><div spellcheck=false><div><div class="pce-input pce-find"><input autocorrect=off autocapitalize=off placeholder=Find aria-label=Find><button type=button class=prev-match title="Previous Match (Shift+Enter)"></button><button type=button class=next-match title="Next Match (Enter)"></button><div class=search-error></div></div><button type=button class=pce-close title="Close (Esc)"></button></div><div class="pce-input pce-replace"><input autocorrect=off autocapitalize=off placeholder=Replace aria-label=Replace><button type=button title=(Enter)>Replace</button><button type=button title=(${isMac ? "Cmd" : "Ctrl+Alt"}+Enter)>All</button></div><div class=pce-options><div class=pce-match-count>0<span> of </span>0</div><button type=button aria-pressed=false class=pce-regex title="RegExp Search${shortcut}R)"><span aria-hidden=true></span></button><button type=button aria-pressed=false title="Preserve Case${shortcut}P)"><span aria-hidden=true>Aa</span></button><button type=button aria-pressed=false class=pce-whole title="Match Whole Word${shortcut}W)"><span aria-hidden=true>ab</span></button><button type=button aria-pressed=false class=pce-in-selection title="Find in Selection${shortcut}L)">`
);
const toggleAttr = (el, name) => el.setAttribute(name, el.getAttribute(name) == "false");
const useSearchWidget = (editor) => {
const replaceAPI = useEditorReplace(editor, "pce-matches");
useEffect(() => {
let prevLength;
let useRegExp;
let matchCase;
let wholeWord;
let searchSelection;
let isOpen;
let currentSelection;
let prevUserSelection;
let prevMargin;
let selectNext = false;
let marginTop;
const searchContainer = template();
const search = searchContainer.firstChild;
const [toggle, div] = search.children;
const rows = div.children;
const [findContainer, closeEl] = rows[0].children;
const [findInput, prevEl, nextEl, errorEl] = findContainer.children;
const [replaceInput, replaceEl, replaceAllEl] = rows[1].children;
const [matchCount, useRegExpEl, matchCaseEl, wholeWordEl, inSelectionEl] = rows[2].children;
const [current, , total] = matchCount.childNodes;
const { textarea, container, getSelection, wrapper } = editor;
const startSearch = (selectMatch) => {
if (selectMatch && !isWebKit) textarea.setSelectionRange(...prevUserSelection);
const error = replaceAPI.search(
findInput.value,
matchCase,
wholeWord,
useRegExp,
searchSelection
);
const index = error ? -1 : selectNext ? replaceAPI.next() : replaceAPI.closest();
updateNode(current, index + 1);
updateNode(total, replaceAPI.matches.length);
findContainer.classList.toggle("pce-error", !!error);
if (error) errorEl.textContent = error;
else if (selectMatch || selectNext) replaceAPI.selectMatch(index, prevMargin);
};
const keydown = (e) => {
if (e.keyCode >> 1 == 35 && getModifierCode(e) == (isMac ? 4 : 2)) {
preventDefault(e);
open();
let [start, end] = getSelection();
let value = editor.value;
let word = value.slice(start, end) || /[_\p{N}\p{L}]*$/u.exec(getLineBefore(value, start))[0] + /^[_\p{N}\p{L}]*/u.exec(value.slice(start))[0];
if (/^$|\n/.test(word)) startSearch();
else {
if (useRegExp) word = regexEscape(word);
doc.execCommand("insertText", false, word);
findInput.select();
}
}
};
const cleanups = [
addListener2(textarea, "keydown", keydown),
addListener2(textarea, "beforeinput", () => {
if (isOpen && searchSelection) currentSelection = getSelection();
}),
editor.on("selectionChange", (selection) => {
if (isOpen && editor.focused) prevUserSelection = selection;
}),
editor.on("update", () => {
if (!isOpen) return;
if (searchSelection && currentSelection) {
const diff = prevLength - (prevLength = editor.value.length);
const end = currentSelection[1];
if (end <= searchSelection[1]) {
searchSelection[1] -= diff;
if (end <= searchSelection[0] - +(diff < 0)) searchSelection[0] -= diff;
}
}
startSearch();
})
];
const open = (focusInput = true) => {
if (!isOpen) {
isOpen = true;
if (marginTop == null) prevMargin = marginTop = getStyleValue(wrapper, "marginTop");
prevUserSelection = getSelection();
addOverlay(editor, searchContainer);
updateMargin();
resize();
observer?.observe(container);
}
if (focusInput) findInput.select();
};
const close = (focusTextarea = true) => {
if (isOpen) {
isOpen = false;
observer?.disconnect();
replaceAPI.stopSearch();
searchContainer.remove();
updateMargin();
if (focusTextarea) textarea.focus();
}
};
const move = (next) => {
if (replaceAPI.matches[0]) {
const index = replaceAPI[next ? "next" : "prev"]();
replaceAPI.selectMatch(index, prevMargin);
updateNode(current, index + 1);
}
};
const updateMargin = () => {
const newMargin = isOpen ? getStyleValue(search, "top") + getStyleValue(search, "height") : marginTop;
const newScroll = container.scrollTop + newMargin - prevMargin;
wrapper.style.marginTop = isOpen ? newMargin + "px" : "";
container.scrollTop = newScroll;
prevMargin = newMargin;
};
const resize = () => div.style.setProperty(
"--search-width",
`min(${container.clientWidth - 2}px - 2.4em - var(--padding-left),20em)`
);
const observer = window.ResizeObserver && new ResizeObserver(resize);
const replace = () => {
selectNext = true;
const index = replaceAPI.replace(replaceInput.value);
if (index != null) {
updateNode(current, index + 1);
replaceAPI.selectMatch(index, prevMargin);
}
selectNext = false;
};
const replaceAll = () => {
replaceAPI.replaceAll(replaceInput.value);
};
const keyCodeButtonMap = {
80: matchCaseEl,
87: wholeWordEl,
82: useRegExpEl,
76: inSelectionEl
};
const elementHandlerMap = /* @__PURE__ */ new Map([
[nextEl, () => move(true)],
[prevEl, move],
[closeEl, close],
[replaceEl, replace],
[replaceAllEl, replaceAll],
[
toggle,
() => {
toggleAttr(toggle, "aria-expanded");
updateMargin();
}
],
[matchCaseEl, () => matchCase = !matchCase],
[useRegExpEl, () => useRegExp = !useRegExp],
[wholeWordEl, () => wholeWord = !wholeWord],
[
inSelectionEl,
() => {
const value = editor.value;
if (searchSelection) searchSelection = void 0;
else {
searchSelection = getSelection().slice(0, 2);
if (numLines(value, ...searchSelection) > 1) {
searchSelection = [
getLineStart(value, searchSelection[0]),
getLineEnd(value, searchSelection[1])
];
}
}
prevLength = value.length;
}
]
]);
addListener(searchContainer, "click", (e) => {
const target = e.target;
const remove = editor.on("update", () => target.focus());
elementHandlerMap.get(target)?.();
if (target.matches(".pce-options>button")) {
toggleAttr(target, "aria-pressed");
startSearch(true);
}
remove();
});
addListener(findInput, "input", () => isOpen && startSearch(true));
addListener(searchContainer, "keydown", (e) => {
const shortcut2 = getModifierCode(e);
const target = e.target;
const keyCode = e.keyCode;
const isFind = target == findInput;
if (shortcut2 == (isMac ? 5 : 1)) {
if (keyCodeButtonMap[keyCode]) {
preventDefault(e);
keyCodeButtonMap[keyCode].click();
}
} else if (keyCode == 13 && target.tagName == "INPUT") {
preventDefault(e);
if (!shortcut2) isFind ? move(true) : replaceEl.click();
else if (shortcut2 == 8 && isFind) move();
else if (shortcut2 == (isMac ? 4 : 3) && !isFind) replaceAllEl.click();
target.focus();
} else if (!shortcut2 && keyCode == 27) close();
else keydown(e);
});
editor.extensions.searchWidget = {
open(focusInput) {
open(focusInput);
startSearch();
},
close
};
return () => {
delete editor.extensions.searchWidget;
cleanups.forEach((c) => c());
close(false);
};
}, []);
};
const useHighlightSelectionMatches = (editor, caseSensitive, minLength = 1, maxLength = 200) => {
const searchAPI = useEditorSearch(editor, "selection-matches", -1);
useLayoutEffect(() => {
return editor.on("selectionChange", ([start, end], value) => {
value = editor.focused ? value.slice(start, end) : "";
const pos = start + value.search(/\S/);
const l = (value = value.trim()).length;
searchAPI.search(
minLength > l || l > maxLength ? "" : value,
caseSensitive,
false,
false,
void 0,
(mStart, mEnd) => mStart > pos || mEnd <= pos
);
});
}, [caseSensitive, minLength, maxLength]);
};
const useHighlightCurrentWord = (editor, filter, includeHyphens) => {
const searchAPI = useEditorSearch(editor, "word-matches", -1);
useLayoutEffect(() => {
let noHighlight = false;
let cleanup1 = editor.on("update", () => noHighlight = true);
let cleanup2 = editor.on("selectionChange", ([start, end], value) => {
if (start < end || !editor.focused || noHighlight) searchAPI.search("");
else {
let group = `[_$\\p{L}\\d${includeHyphens && includeHyphens(start) ? "-" : ""}]`;
let before = value.slice(0, start).match(RegExp(group + "*$", "u"));
let index = before.index;
let word = before[0] + value.slice(start).match(RegExp("^" + group + "*", "u"))[0];
searchAPI.search(
/^-*(\d|$)/.test(word) || filter && !filter(index, index + word.length) ? "" : word,
true,
true,
false,
void 0,
filter,
RegExp(group + "{2}", "u")
);
}
noHighlight = false;
});
return () => {
cleanup1();
cleanup2();
};
}, [filter, includeHyphens]);
};
const useShowInvisibles = (editor, alwaysShow) => {
const show = useStableRef([alwaysShow]);
const searchAPI = useEditorSearch(editor, "pce-invisibles");
show[0] = alwaysShow;
useLayoutEffect(() => {
let prev;
const matches = searchAPI.matches;
const container = searchAPI.container;
const nodes = container.children;
const tabs = [];
const update = () => {
const value = editor.value;
const [start, end] = editor.getSelection();
if (!show[0] || prev != (prev = value)) {
searchAPI.search(" | ", true, false, true, show[0] ? void 0 : [start, end]);
for (let i = 0, l = matches.length; i < l; i++) {
if (value[matches[i][0]] == " " == !tabs[i]) {
nodes[i].className = (tabs[i] = !tabs[i]) ? "pce-tab" : "";
}
}
}
};
if (editor.value) update();
return editor.on("selectionChange", update);
}, []);
};
export {
useSearchWidget as a,
useHighlightSelectionMatches as b,
useHighlightCurrentWord as c,
useShowInvisibles as d,
useEditorReplace as u
};
//# sourceMappingURL=invisibles-C1HUPmS1.js.map