fix(grid): preserve caret when condition editor collapses
This commit is contained in:
parent
7b95f0ca17
commit
bdb941b606
|
|
@ -94,11 +94,13 @@ const previewStyle = computed<CSSProperties>(() => {
|
|||
});
|
||||
const previewArrowStyle = computed<CSSProperties>(() => ({ top: `${historyPreview.value?.arrowTop ?? 0}px` }));
|
||||
|
||||
function createTextProbe(input: HTMLTextAreaElement, wrap: boolean) {
|
||||
function createTextProbe(input: HTMLTextAreaElement, wrap: boolean, options: { width?: number; textIndent?: number } = {}) {
|
||||
const probe = document.createElement(wrap ? "div" : "span");
|
||||
const style = window.getComputedStyle(input);
|
||||
const width = options.width ?? input.clientWidth;
|
||||
const textIndent = options.textIndent !== undefined ? `${options.textIndent}px` : style.textIndent;
|
||||
probe.textContent = input.value || input.placeholder || "";
|
||||
probe.style.cssText = `position:fixed;left:-9999px;top:-9999px;visibility:hidden;box-sizing:border-box;${wrap ? `width:${input.clientWidth}px;white-space:pre-wrap;overflow-wrap:normal;padding:${style.paddingTop} ${style.paddingRight} ${style.paddingBottom} ${style.paddingLeft};` : "white-space:pre;"}font:${style.font};font-size:${style.fontSize};font-family:${style.fontFamily};font-weight:${style.fontWeight};line-height:${style.lineHeight};letter-spacing:${style.letterSpacing};`;
|
||||
probe.style.cssText = `position:fixed;left:-9999px;top:-9999px;visibility:hidden;box-sizing:border-box;${wrap ? `width:${width}px;white-space:pre-wrap;overflow-wrap:anywhere;padding:${style.paddingTop} ${style.paddingRight} ${style.paddingBottom} ${style.paddingLeft};text-indent:${textIndent};` : "white-space:pre;"}font:${style.font};font-size:${style.fontSize};font-family:${style.fontFamily};font-weight:${style.fontWeight};line-height:${style.lineHeight};letter-spacing:${style.letterSpacing};`;
|
||||
document.body.appendChild(probe);
|
||||
return probe;
|
||||
}
|
||||
|
|
@ -111,8 +113,8 @@ function shouldExpand(input: HTMLTextAreaElement) {
|
|||
return should;
|
||||
}
|
||||
|
||||
function measureExpandedHeight(input: HTMLTextAreaElement) {
|
||||
const probe = createTextProbe(input, true);
|
||||
function measureExpandedHeight(input: HTMLTextAreaElement, rect: typeof expandedRect.value) {
|
||||
const probe = createTextProbe(input, true, { width: Math.max(1, rect.width - 8), textIndent: rect.prefix });
|
||||
const style = window.getComputedStyle(input);
|
||||
const lineHeight = Number.parseFloat(style.lineHeight) || 24;
|
||||
const contentHeight = probe.scrollHeight;
|
||||
|
|
@ -121,11 +123,30 @@ function measureExpandedHeight(input: HTMLTextAreaElement) {
|
|||
return Math.min(Math.max(56, lineHeight * 2.5, contentHeight), Math.min(260, availableHeight));
|
||||
}
|
||||
|
||||
function maxExpandedHeight() {
|
||||
const input = inputRef.value;
|
||||
const top = input?.getBoundingClientRect().top ?? expandedRect.value.top;
|
||||
return Math.min(260, Math.max(56, window.innerHeight - top - 12));
|
||||
}
|
||||
|
||||
function fitExpandedHeightToOverlay() {
|
||||
const overlay = overlayRef.value;
|
||||
if (!overlay) return;
|
||||
const overflow = overlay.scrollHeight - overlay.clientHeight;
|
||||
if (overflow > 4) {
|
||||
expandedHeight.value = Math.min(maxExpandedHeight(), expandedHeight.value + overflow);
|
||||
} else if (overflow <= 0) {
|
||||
overlay.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function measureExpandedRect(input: HTMLTextAreaElement) {
|
||||
const inputRect = input.getBoundingClientRect();
|
||||
const control = controlRef.value;
|
||||
const controlRect = control?.getBoundingClientRect() ?? inputRect;
|
||||
const controlsRect = control?.firstElementChild?.getBoundingClientRect() ?? controlRect;
|
||||
const label = control?.querySelector<HTMLElement>(".data-grid-topbar-condition-label");
|
||||
const labelPrefix = label ? label.scrollWidth + 4 : 0;
|
||||
const horizontalInset = 8;
|
||||
return {
|
||||
left: controlRect.left - horizontalInset,
|
||||
|
|
@ -133,7 +154,7 @@ function measureExpandedRect(input: HTMLTextAreaElement) {
|
|||
width: controlRect.width + horizontalInset * 2,
|
||||
controlsTop: Math.max(0, controlsRect.top - controlRect.top),
|
||||
inputTop: Math.max(0, inputRect.top - controlRect.top),
|
||||
prefix: Math.max(0, inputRect.left - controlRect.left),
|
||||
prefix: Math.max(0, inputRect.left - controlRect.left, labelPrefix),
|
||||
suffix: Math.max(28, controlRect.right - inputRect.right),
|
||||
};
|
||||
}
|
||||
|
|
@ -172,8 +193,10 @@ function resizeEditor(forceExpand = false) {
|
|||
const focused = document.activeElement === input || overlayFocused;
|
||||
const nextExpanded = focused && shouldExpand(input) && (forceExpand || expanded.value);
|
||||
if (nextExpanded) {
|
||||
expandedRect.value = measureExpandedRect(input);
|
||||
expandedHeight.value = measureExpandedHeight(input);
|
||||
const nextRect = measureExpandedRect(input);
|
||||
expandedRect.value = nextRect;
|
||||
expandedHeight.value = measureExpandedHeight(input, nextRect);
|
||||
void nextTick(fitExpandedHeightToOverlay);
|
||||
}
|
||||
expanded.value = nextExpanded;
|
||||
updateSuggestionPosition();
|
||||
|
|
@ -181,15 +204,25 @@ function resizeEditor(forceExpand = false) {
|
|||
void nextTick(() => {
|
||||
const overlay = overlayRef.value;
|
||||
if (!overlay || composing.value) return;
|
||||
syncSelection(input);
|
||||
overlay.focus();
|
||||
overlay.setSelectionRange(selectionStart.value, selectionEnd.value);
|
||||
const start = selectionStart.value;
|
||||
const end = selectionEnd.value;
|
||||
overlay.setSelectionRange(start, end);
|
||||
overlay.focus({ preventScroll: true });
|
||||
overlay.setSelectionRange(start, end);
|
||||
selectionStart.value = start;
|
||||
selectionEnd.value = end;
|
||||
scheduleCaretIntoView();
|
||||
});
|
||||
}
|
||||
if (!nextExpanded && overlayFocused && !composing.value) {
|
||||
void nextTick(() => {
|
||||
input.focus();
|
||||
input.setSelectionRange(selectionStart.value, selectionEnd.value);
|
||||
const start = selectionStart.value;
|
||||
const end = selectionEnd.value;
|
||||
input.focus({ preventScroll: true });
|
||||
input.setSelectionRange(start, end);
|
||||
selectionStart.value = start;
|
||||
selectionEnd.value = end;
|
||||
scheduleCaretIntoView();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -220,7 +253,14 @@ function focus(select = false) {
|
|||
|
||||
function scrollCaretIntoView() {
|
||||
const target = activeEditor.value;
|
||||
if (!target || target.scrollHeight <= target.clientHeight) return;
|
||||
if (!target) return;
|
||||
const hasVerticalOverflow = target.scrollHeight > target.clientHeight + 4;
|
||||
const hasHorizontalOverflow = target.scrollWidth > target.clientWidth + 1;
|
||||
if (!hasVerticalOverflow && !hasHorizontalOverflow) {
|
||||
target.scrollTop = 0;
|
||||
target.scrollLeft = 0;
|
||||
return;
|
||||
}
|
||||
const style = window.getComputedStyle(target);
|
||||
const probe = document.createElement("div");
|
||||
const caretMarker = document.createElement("span");
|
||||
|
|
@ -232,19 +272,40 @@ function scrollCaretIntoView() {
|
|||
document.body.appendChild(probe);
|
||||
const lineHeight = Number.parseFloat(style.lineHeight) || 24;
|
||||
const caretTop = caretMarker.offsetTop;
|
||||
const caretLeft = caretMarker.offsetLeft;
|
||||
const topPadding = Number.parseFloat(style.paddingTop) || 0;
|
||||
const bottomPadding = Number.parseFloat(style.paddingBottom) || 0;
|
||||
const leftPadding = Number.parseFloat(style.paddingLeft) || 0;
|
||||
const rightPadding = Number.parseFloat(style.paddingRight) || 0;
|
||||
const visibleTop = target.scrollTop + topPadding;
|
||||
const visibleBottom = target.scrollTop + target.clientHeight - bottomPadding;
|
||||
if (caretTop < visibleTop) target.scrollTop = Math.max(0, caretTop - topPadding);
|
||||
else if (caretTop + lineHeight > visibleBottom) target.scrollTop = caretTop + lineHeight + bottomPadding - target.clientHeight;
|
||||
const visibleLeft = target.scrollLeft + leftPadding;
|
||||
const visibleRight = target.scrollLeft + target.clientWidth - rightPadding;
|
||||
if (hasVerticalOverflow) {
|
||||
if (caretTop < visibleTop) target.scrollTop = Math.max(0, caretTop - topPadding);
|
||||
else if (caretTop + lineHeight > visibleBottom) target.scrollTop = caretTop + lineHeight + bottomPadding - target.clientHeight;
|
||||
} else {
|
||||
target.scrollTop = 0;
|
||||
}
|
||||
if (hasHorizontalOverflow) {
|
||||
if (caretLeft < visibleLeft) target.scrollLeft = Math.max(0, caretLeft - leftPadding);
|
||||
else if (caretLeft > visibleRight) target.scrollLeft = caretLeft + rightPadding - target.clientWidth;
|
||||
} else {
|
||||
target.scrollLeft = 0;
|
||||
}
|
||||
probe.remove();
|
||||
}
|
||||
|
||||
function scheduleCaretIntoView() {
|
||||
void nextTick(() => {
|
||||
requestAnimationFrame(() => scrollCaretIntoView());
|
||||
});
|
||||
}
|
||||
|
||||
function focusAfterAccept() {
|
||||
void nextTick(() => {
|
||||
focus();
|
||||
void nextTick(scrollCaretIntoView);
|
||||
scheduleCaretIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -280,6 +341,7 @@ function onInput(event: Event) {
|
|||
syncSelection(event.currentTarget as HTMLTextAreaElement);
|
||||
resizeEditor(true);
|
||||
updateSuggestionPosition();
|
||||
scheduleCaretIntoView();
|
||||
}
|
||||
|
||||
async function applyCondition() {
|
||||
|
|
@ -592,6 +654,12 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH
|
|||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
.data-grid-topbar-condition-label--floating.data-grid-topbar-condition-label--compact {
|
||||
max-width: 5rem;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.data-grid-topbar-condition-input,
|
||||
.data-grid-condition-suggestion-field {
|
||||
font-family: var(--data-grid-condition-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
|
||||
|
|
@ -681,7 +749,7 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH
|
|||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: normal;
|
||||
overflow-wrap: anywhere;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,27 @@ function mountEditor(kind: DataGridConditionHistoryKind, initialValue: string, o
|
|||
return { value, input: host.querySelector("textarea") as HTMLTextAreaElement };
|
||||
}
|
||||
|
||||
function mockTextareaMetrics(input: HTMLTextAreaElement, options: { clientWidth: number; scrollWidth?: number; clientHeight?: number; scrollHeight?: number }) {
|
||||
Object.defineProperties(input, {
|
||||
clientWidth: { configurable: true, value: options.clientWidth },
|
||||
scrollWidth: { configurable: true, value: options.scrollWidth ?? options.clientWidth },
|
||||
clientHeight: { configurable: true, value: options.clientHeight ?? 24 },
|
||||
scrollHeight: { configurable: true, value: options.scrollHeight ?? 24 },
|
||||
});
|
||||
input.getBoundingClientRect = () =>
|
||||
({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: options.clientWidth,
|
||||
bottom: options.clientHeight ?? 24,
|
||||
width: options.clientWidth,
|
||||
height: options.clientHeight ?? 24,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, host } of mountedApps.splice(0)) {
|
||||
app.unmount();
|
||||
|
|
@ -102,26 +123,36 @@ describe("DataGridConditionEditor quote completion", () => {
|
|||
expect(input.selectionEnd).toBe(20);
|
||||
});
|
||||
|
||||
it("keeps expanded input first-line indent without forced word breaks", () => {
|
||||
it("keeps expanded input first-line indent and wraps long tokens", () => {
|
||||
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
|
||||
const expandedInputCss = source.match(/\.data-grid-topbar-condition-input--expanded\s*\{(?<body>[\s\S]*?)\n\}/)?.groups?.body;
|
||||
|
||||
expect(expandedInputCss).toContain("padding:");
|
||||
expect(expandedInputCss).toContain("0.0625rem 0.125rem");
|
||||
expect(expandedInputCss).toContain("text-indent: var(--data-grid-condition-prefix-indent)");
|
||||
expect(expandedInputCss).toContain("overflow-wrap: normal");
|
||||
expect(source).toContain("white-space:pre-wrap;overflow-wrap:normal;");
|
||||
expect(expandedInputCss).toContain("overflow-wrap: anywhere");
|
||||
expect(source).toContain("white-space:pre-wrap;overflow-wrap:anywhere;");
|
||||
expect(source).toContain("text-indent:${style.textIndent};");
|
||||
expect(source).toContain("textIndent: rect.prefix");
|
||||
expect(source).toContain("width: Math.max(1, rect.width - 8)");
|
||||
expect(source).toContain("function fitExpandedHeightToOverlay()");
|
||||
expect(source).toContain("expandedHeight.value + overflow");
|
||||
});
|
||||
|
||||
it("keeps the expanded condition label readable over wrapped content", () => {
|
||||
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
|
||||
const floatingControls = source.match(/data-grid-topbar-condition-floating-controls[^"]*/)?.[0];
|
||||
const floatingLabelCss = source.match(/\.data-grid-topbar-condition-label--floating\s*\{(?<body>[\s\S]*?)\n\}/)?.groups?.body;
|
||||
const compactFloatingLabelCss = source.match(/\.data-grid-topbar-condition-label--floating\.data-grid-topbar-condition-label--compact\s*\{(?<body>[\s\S]*?)\n\}/)?.groups?.body;
|
||||
|
||||
expect(floatingControls).toContain("z-[2]");
|
||||
expect(source).toContain("data-grid-topbar-condition-label--floating");
|
||||
expect(source).toContain("label.scrollWidth + 4");
|
||||
expect(floatingLabelCss).toContain("text-shadow:");
|
||||
expect(floatingLabelCss).not.toContain("padding-right:");
|
||||
expect(floatingLabelCss).not.toContain("box-shadow:");
|
||||
expect(compactFloatingLabelCss).toContain("max-width: 5rem");
|
||||
expect(compactFloatingLabelCss).toContain("opacity: 1");
|
||||
});
|
||||
|
||||
it("keeps dark condition styles scoped to their target elements", () => {
|
||||
|
|
@ -136,11 +167,117 @@ describe("DataGridConditionEditor quote completion", () => {
|
|||
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
|
||||
|
||||
expect(source).toContain("function scrollCaretIntoView()");
|
||||
expect(source).toContain("const hasVerticalOverflow = target.scrollHeight > target.clientHeight + 4");
|
||||
expect(source).toContain("const hasHorizontalOverflow = target.scrollWidth > target.clientWidth + 1");
|
||||
expect(source).toContain("target.scrollTop = 0");
|
||||
expect(source).toContain("const caretLeft = caretMarker.offsetLeft");
|
||||
expect(source).toContain("target.scrollLeft");
|
||||
expect(source).toContain("function scheduleCaretIntoView()");
|
||||
expect(source).toContain("function focusAfterAccept()");
|
||||
expect(source).toContain("void nextTick(scrollCaretIntoView)");
|
||||
expect(source).toContain("requestAnimationFrame(() => scrollCaretIntoView())");
|
||||
expect(source).toContain("scheduleCaretIntoView()");
|
||||
expect(source).toContain('if (action === "accept") focusAfterAccept()');
|
||||
});
|
||||
|
||||
it("also keeps the caret visible after regular input wraps", () => {
|
||||
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
|
||||
const onInputBody = source.match(/function onInput\(event: Event\) \{(?<body>[\s\S]*?)\n\}/)?.groups?.body;
|
||||
|
||||
expect(onInputBody).toContain("resizeEditor(true)");
|
||||
expect(onInputBody).toContain("updateSuggestionPosition()");
|
||||
expect(onInputBody).toContain("scheduleCaretIntoView()");
|
||||
});
|
||||
|
||||
it("keeps the caret visible after switching focus into the expanded editor", () => {
|
||||
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
|
||||
const focusTransferBody = source.match(/if \(nextExpanded && document\.activeElement === input && !composing\.value\) \{(?<body>[\s\S]*?)\n \}/)?.groups?.body;
|
||||
|
||||
expect(focusTransferBody).toContain("const start = selectionStart.value");
|
||||
expect(focusTransferBody).toContain("overlay.setSelectionRange(start, end)");
|
||||
expect(focusTransferBody).toContain("overlay.focus({ preventScroll: true })");
|
||||
expect(focusTransferBody).toContain("scheduleCaretIntoView()");
|
||||
});
|
||||
|
||||
it("keeps the caret visible after collapsing the expanded editor back to one line", () => {
|
||||
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
|
||||
const collapseTransferBody = source.match(/if \(!nextExpanded && overlayFocused && !composing\.value\) \{(?<body>[\s\S]*?)\n \}/)?.groups?.body;
|
||||
|
||||
expect(collapseTransferBody).toContain("const start = selectionStart.value");
|
||||
expect(collapseTransferBody).toContain("input.setSelectionRange(start, end)");
|
||||
expect(collapseTransferBody).toContain("input.focus({ preventScroll: true })");
|
||||
expect(collapseTransferBody!.indexOf("input.focus({ preventScroll: true })")).toBeLessThan(collapseTransferBody!.indexOf("input.setSelectionRange(start, end)"));
|
||||
expect(collapseTransferBody).toContain("scheduleCaretIntoView()");
|
||||
});
|
||||
|
||||
it("preserves continuous input when the expanded editor collapses", async () => {
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
|
||||
const textWidth = (this.textContent?.length ?? 0) * 8;
|
||||
const width = this.classList.contains("data-grid-topbar-condition-pane--expanded") ? 160 : textWidth;
|
||||
return { x: 0, y: 0, left: 0, top: 0, right: width, bottom: 24, width, height: 24, toJSON: () => ({}) } as DOMRect;
|
||||
});
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
const { value, input } = mountEditor("where", "abcdefghijklmnopqrstuvwxyz0123456789");
|
||||
mockTextareaMetrics(input, { clientWidth: 80, scrollWidth: 320 });
|
||||
input.focus();
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
input.dispatchEvent(new Event("select", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("focus", { bubbles: true }));
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
const overlay = document.body.querySelector(".data-grid-topbar-condition-input--expanded") as HTMLTextAreaElement | null;
|
||||
expect(overlay).toBeTruthy();
|
||||
|
||||
overlay!.value = "i";
|
||||
overlay!.setSelectionRange(1, 1);
|
||||
overlay!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(input.selectionStart).toBe(1);
|
||||
input.value = "id";
|
||||
input.setSelectionRange(2, 2);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await nextTick();
|
||||
|
||||
expect(value.value).toBe("id");
|
||||
expect(input.selectionStart).toBe(2);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("preserves the caret offset when focus moves into the expanded textarea", async () => {
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
|
||||
const width = this.classList.contains("data-grid-topbar-condition-pane--expanded") ? 160 : 140;
|
||||
return { x: 0, y: 0, left: 0, top: 0, right: width, bottom: 24, width, height: 24, toJSON: () => ({}) } as DOMRect;
|
||||
});
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
const { input } = mountEditor("where", "abcdefghijklmnopqrstuvwxyz0123456789");
|
||||
mockTextareaMetrics(input, { clientWidth: 80, scrollWidth: 320 });
|
||||
input.focus();
|
||||
input.setSelectionRange(30, 30);
|
||||
input.dispatchEvent(new Event("select", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("focus", { bubbles: true }));
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
const overlay = document.body.querySelector(".data-grid-topbar-condition-input--expanded") as HTMLTextAreaElement | null;
|
||||
|
||||
expect(overlay).toBeTruthy();
|
||||
expect(document.activeElement).toBe(overlay);
|
||||
expect(overlay?.selectionStart).toBe(30);
|
||||
expect(overlay?.selectionEnd).toBe(30);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("positions suggestions below the measured expanded editor height", () => {
|
||||
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
|
||||
const expandedPaneCss = source.match(/\.data-grid-topbar-condition-pane--expanded\s*\{(?<body>[\s\S]*?)\n\}/)?.groups?.body;
|
||||
|
|
|
|||
Loading…
Reference in New Issue