mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
feat(workbench): add a selection from an Office preview to the chat with its position
Drag across cells in an Excel preview or select text in a Word preview, and the existing quote path carries it to the composer with a structural place: sheet and cell range, or the body blocks it covers. The frame becomes allow-same-origin so the page can read the selection; scripts stay off through the sandbox and the CSP. Refs #14622
This commit is contained in:
1 parent
f6aa704059
commit
dc6c69a5f8
12 files changed
+243
-8
No files matched your search
@@ -34,9 +34,35 @@ try {
|
||||
await page.locator(".delivery-open").filter({ hasText: name }).click();
|
||||
const frame = page.frameLocator('iframe[title="Office 文档预览"]');
|
||||
await frame.locator(name.endsWith("docx") ? "section.docx" : "table").first().waitFor({ timeout: 15000 });
|
||||
assert.equal(await page.locator('iframe[title="Office 文档预览"]').getAttribute("sandbox"), "");
|
||||
const sandbox = await page.locator('iframe[title="Office 文档预览"]').getAttribute("sandbox");
|
||||
assert.equal(sandbox, "allow-same-origin");
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: join(out, `office-${name.split(".")[1]}-${scheme}-${width}.png`) });
|
||||
if (name === "report.docx") {
|
||||
await page.frameLocator('iframe[title="Office 文档预览"]').getByText("续约率", { exact: false }).click({ clickCount: 3 });
|
||||
await page.locator('[data-action="document.quote"]').filter({ hasText: "正文第" }).waitFor();
|
||||
await page.screenshot({ path: join(out, `office-word-select-${scheme}-${width}.png`) });
|
||||
}
|
||||
if (name === "sales.xlsx" && width >= 600) {
|
||||
const sheet = page.frameLocator('iframe[title="Office 文档预览"]');
|
||||
const centre = async (cell) => {
|
||||
const box = await sheet.locator(`[data-cell="${cell}"]`).boundingBox();
|
||||
return [box.x + box.width / 2, box.y + box.height / 2];
|
||||
};
|
||||
const [bx, by] = await centre("B2");
|
||||
await page.mouse.move(bx, by);
|
||||
await page.mouse.down();
|
||||
const [dx, dy] = await centre("D4");
|
||||
await page.mouse.move(dx, dy, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
const quote = page.locator('[data-action="document.quote"]');
|
||||
await quote.filter({ hasText: "B2:D4" }).waitFor();
|
||||
await page.screenshot({ path: join(out, `office-select-${scheme}-${width}.png`) });
|
||||
await quote.click();
|
||||
await page.locator(".compose").getByText("sales.xlsx", { exact: false }).first().waitFor();
|
||||
await page.waitForTimeout(800);
|
||||
await page.screenshot({ path: join(out, `office-quoted-${scheme}-${width}.png`) });
|
||||
}
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(requests.filter((u) => !u.startsWith("http://127.0.0.1") && !u.startsWith("http://localhost") && !u.startsWith("data:") && !u.startsWith("blob:")), []);
|
||||
|
||||
@@ -4,6 +4,9 @@ export const DOCUMENT_ACTIONS: UIAction[] = [
|
||||
{ id: "document.zoom", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "document.page", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "document.sheet", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "document.quote", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "document.select-cells", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "document.select-text", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "document.scroll", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "document.refresh", kind: "repeatable", target: "entity", proof: "interaction" },
|
||||
{ id: "delivery.preview", kind: "navigation", target: "entity", proof: "interaction" },
|
||||
|
||||
@@ -46,4 +46,10 @@ export const EN_DOCUMENT: Record<string, string> = {
|
||||
"这个工作簿没有可见的工作表": "This workbook has no visible sheets.",
|
||||
"Office 文档预览": "Office document preview",
|
||||
"Office 文档预览已在设置中关闭": "Office document preview is turned off in Settings.",
|
||||
"加入对话 · {where}": "Add to chat · {where}",
|
||||
"选中文字后可加入对话": "Select text to add it to the chat",
|
||||
"点选或拖选单元格后可加入对话": "Click or drag across cells to add them to the chat",
|
||||
"{sheet} 工作表 {range}": "sheet {sheet}, {range}",
|
||||
"正文第 {n} 块": "body block {n}",
|
||||
"正文第 {a}–{b} 块": "body blocks {a}–{b}",
|
||||
};
|
||||
@@ -598,7 +598,7 @@ export function WorkbenchPanel({
|
||||
)}
|
||||
{mode === "document" || unpreviewableText ? (
|
||||
<Suspense fallback={<div className="workbench-empty">{t("正在读取…")}</div>}>
|
||||
<DocumentPreview key={filePath} port={port} path={filePath} shown={shown && !showFiles && !launcher}
|
||||
<DocumentPreview key={filePath} port={port} path={filePath} shown={shown && !showFiles && !launcher} onQuote={onQuoteSelection}
|
||||
view={documentState(documentStates, filePath)}
|
||||
refresh={JSON.stringify([changeKey, glance, running, wrote])}
|
||||
onOpenExternal={!remote && port.opensFiles() && /\.(pdf|html?|svg|docx?|xlsx?|pptx?)$/i.test(filePath) ? () => {
|
||||
|
||||
@@ -35,8 +35,9 @@ function Picture({ data, path, view }: { data: ArrayBuffer; path: string; view:
|
||||
</>;
|
||||
}
|
||||
|
||||
export default function DocumentPreview({ port, path, shown, refresh, onOpenExternal, view }: {
|
||||
export default function DocumentPreview({ port, path, shown, refresh, onOpenExternal, view, onQuote }: {
|
||||
port: AgentPort; path: string; shown: boolean; refresh: string; onOpenExternal?: () => void;
|
||||
onQuote?: (text: string, source: string) => void;
|
||||
view: DocumentViewState;
|
||||
}) {
|
||||
const { value, reload } = useDocument(port, path, shown, refresh);
|
||||
@@ -57,7 +58,7 @@ export default function DocumentPreview({ port, path, shown, refresh, onOpenExte
|
||||
case "table": body = <DelimitedPreview path={path} text={text()} view={view} />; break;
|
||||
case "html": body = <StaticHtmlPreview text={text()} />; break;
|
||||
case "image": body = <Picture data={data} path={path} view={view} />; break;
|
||||
case "office": body = <OfficePreview data={data} path={path} view={view} />; break;
|
||||
case "office": body = <OfficePreview data={data} path={path} view={view} onQuote={onQuote} />; break;
|
||||
default: body = fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { t } from "../../i18n";
|
||||
import { staticDocument } from "./staticDocument";
|
||||
import { readWorkbook, SHEET_COLUMN_LIMIT, SHEET_ROW_LIMIT, type SheetView } from "./officeSheet";
|
||||
import { fitZoom, readWordDocument, type WordView } from "./officeWord";
|
||||
import { markCells, sheetSelection, wordSelection, type OfficeSelection } from "./officeSelection";
|
||||
import { listenAction } from "../listen";
|
||||
import type { DocumentViewState } from "./viewState";
|
||||
|
||||
type Parsed = { kind: "word"; word: WordView } | { kind: "sheets"; sheets: SheetView[] } | { kind: "failed" };
|
||||
@@ -20,7 +22,13 @@ function useWidth() {
|
||||
return [ref, width] as const;
|
||||
}
|
||||
|
||||
export default function OfficePreview({ data, path, view }: { data: ArrayBuffer; path: string; view: DocumentViewState }) {
|
||||
export default function OfficePreview({ data, path, view, onQuote }: {
|
||||
data: ArrayBuffer; path: string; view: DocumentViewState; onQuote?: (text: string, source: string) => void;
|
||||
}) {
|
||||
const [picked, setPicked] = useState<OfficeSelection>();
|
||||
const frame = useRef<HTMLIFrameElement>(null);
|
||||
const unwatch = useRef<() => void>(undefined);
|
||||
useEffect(() => () => unwatch.current?.(), []);
|
||||
const [parsed, setParsed] = useState<Parsed>();
|
||||
const [sheet, updateSheet] = useState(view.sheet ?? 0);
|
||||
const setSheet = (i: number) => { view.sheet = i; updateSheet(i); };
|
||||
@@ -41,12 +49,39 @@ export default function OfficePreview({ data, path, view }: { data: ArrayBuffer;
|
||||
const zoom = chosen ?? fit;
|
||||
const html = parsed?.kind === "word" ? `<style>body{zoom:${zoom}}</style>${parsed.word.html}` : current?.html;
|
||||
const doc = useMemo(() => html === undefined ? "" : staticDocument(html), [html]);
|
||||
const onFrameLoad = () => {
|
||||
unwatch.current?.();
|
||||
unwatch.current = undefined;
|
||||
setPicked(undefined);
|
||||
const inner = frame.current?.contentDocument;
|
||||
if (!inner || !onQuote) return;
|
||||
const sheet = current?.name;
|
||||
if (sheet === undefined) {
|
||||
const read = () => setPicked(wordSelection(inner));
|
||||
const stops = [
|
||||
listenAction(inner, "mouseup", { action: "document.select-text", listener: read }),
|
||||
listenAction(inner, "keyup", { action: "document.select-text", listener: read }),
|
||||
];
|
||||
unwatch.current = () => stops.forEach((stop) => stop());
|
||||
return;
|
||||
}
|
||||
let anchor = "";
|
||||
const cellAt = (e: Event) => (e.target as Element | null)?.closest?.("td[data-cell]")?.getAttribute("data-cell") ?? "";
|
||||
const pick = (end: string) => { markCells(inner, anchor, end); setPicked(sheetSelection(inner, sheet, anchor, end)); };
|
||||
const down = (e: MouseEvent) => { const at = cellAt(e); if (!at) return; e.preventDefault(); anchor = e.shiftKey && anchor ? anchor : at; pick(at); };
|
||||
const move = (e: MouseEvent) => { const at = cellAt(e); if (at && anchor && e.buttons & 1) pick(at); };
|
||||
const stops = [
|
||||
listenAction(inner, "pointerdown", { action: "document.select-cells", listener: down as EventListener }),
|
||||
listenAction(inner, "pointermove", { action: "document.select-cells", listener: move as EventListener }),
|
||||
];
|
||||
unwatch.current = () => stops.forEach((stop) => stop());
|
||||
};
|
||||
|
||||
let body;
|
||||
if (!parsed) body = <p role="status">{t("正在读取…")}</p>;
|
||||
else if (parsed.kind === "failed") body = <p role="alert">{t("无法解析这个文件,可以用系统应用打开")}</p>;
|
||||
else if (parsed.kind === "sheets" && !current) body = <p role="status">{t("这个工作簿没有可见的工作表")}</p>;
|
||||
else body = <iframe className="document-html document-office" title={t("Office 文档预览")} sandbox="" srcDoc={doc} />;
|
||||
else body = <iframe ref={frame} onLoad={onFrameLoad} className="document-html document-office" title={t("Office 文档预览")} sandbox="allow-same-origin" srcDoc={doc} />;
|
||||
return <div className="document-office-view" ref={box}>
|
||||
{parsed && parsed.kind !== "failed" && <p className="document-notice">{t("只读预览,宏、脚本和外部资源未加载")}</p>}
|
||||
{parsed?.kind === "word" && (
|
||||
@@ -63,6 +98,13 @@ export default function OfficePreview({ data, path, view }: { data: ArrayBuffer;
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{onQuote && parsed && parsed.kind !== "failed" && (
|
||||
<div className="document-controls">
|
||||
<button data-action="document.quote" data-target={path} disabled={!picked} onClick={() => picked && onQuote(picked.text, `${path} (${picked.position})`)}>
|
||||
{picked ? t("加入对话 · {where}", { where: picked.label }) : t(parsed.kind === "word" ? "选中文字后可加入对话" : "点选或拖选单元格后可加入对话")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{current?.clipped && <p className="document-notice">{t("只显示前 {rows} 行、{cols} 列", { rows: SHEET_ROW_LIMIT, cols: SHEET_COLUMN_LIMIT })}</p>}
|
||||
{body}
|
||||
</div>;
|
||||
|
||||
@@ -33,7 +33,8 @@ it("renders a workbook in a sandboxed frame", async () => {
|
||||
const { port } = officePort(await sheetBytes());
|
||||
render(<DocumentPreview port={port} path="budget.xlsx" shown refresh="" view={{}} />);
|
||||
const frame = await screen.findByTitle("Office 文档预览", undefined, { timeout: 5000 });
|
||||
expect(frame.getAttribute("sandbox")).toBe("");
|
||||
expect(frame.getAttribute("sandbox")).toBe("allow-same-origin");
|
||||
expect(frame.getAttribute("sandbox")).not.toContain("allow-scripts");
|
||||
expect(frame.getAttribute("srcdoc")).toContain("1100");
|
||||
expect(frame.getAttribute("srcdoc")).toContain("Content-Security-Policy");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// @vitest-environment jsdom
|
||||
import { expect, it } from "vitest";
|
||||
import { cellRange, markCells, sheetSelection, wordSelection } from "./officeSelection";
|
||||
|
||||
it("normalises two cell addresses into one rectangle", () => {
|
||||
expect(cellRange("D4", "B2")?.text).toBe("B2:D4");
|
||||
expect(cellRange("B4", "D2")?.text).toBe("B2:D4");
|
||||
expect(cellRange("C3", "C3")?.text).toBe("C3");
|
||||
expect(cellRange("Z1", "AB2")?.text).toBe("Z1:AB2");
|
||||
expect(cellRange("B2", "nope")).toBeUndefined();
|
||||
});
|
||||
|
||||
function sheet(): Document {
|
||||
const doc = document.implementation.createHTMLDocument();
|
||||
doc.body.innerHTML = '<table><tbody>'
|
||||
+ '<tr><th>1</th><td data-cell="A1">区域</td><td data-cell="B1">7月</td></tr>'
|
||||
+ '<tr><th>2</th><td data-cell="A2">华东</td><td data-cell="B2">1,100</td></tr>'
|
||||
+ '</tbody></table>';
|
||||
return doc;
|
||||
}
|
||||
|
||||
it("quotes a cell range as tab-separated rows with its sheet and range", () => {
|
||||
const doc = sheet();
|
||||
const picked = sheetSelection(doc, "Q3", "B2", "A1");
|
||||
expect(picked?.text).toBe("区域\t7月\n华东\t1,100");
|
||||
expect(picked?.position).toBe('sheet "Q3", cells A1:B2');
|
||||
markCells(doc, "A2", "B2");
|
||||
expect([...doc.querySelectorAll(".sel")].map((el) => el.getAttribute("data-cell"))).toEqual(["A2", "B2"]);
|
||||
markCells(doc, "A1", "A1");
|
||||
expect([...doc.querySelectorAll(".sel")].map((el) => el.getAttribute("data-cell"))).toEqual(["A1"]);
|
||||
});
|
||||
|
||||
function word(): Document {
|
||||
const doc = document;
|
||||
doc.body.innerHTML = '<section class="docx"><header><p>页眉</p></header><article>'
|
||||
+ '<p id="b1">标题</p><p id="b2">第一段</p><table id="b3"><tr><td>表格</td></tr></table></article></section>'
|
||||
+ '<section class="docx"><article><p id="b4">第二页</p></article></section>';
|
||||
return doc;
|
||||
}
|
||||
|
||||
it("names the body blocks a Word selection covers, across pages and past the header", () => {
|
||||
const doc = word();
|
||||
const select = (from: string, to: string) => {
|
||||
const range = doc.createRange();
|
||||
range.setStart(doc.getElementById(from)!.firstChild!, 0);
|
||||
const end = doc.getElementById(to)!;
|
||||
range.setEnd(end.querySelector("td")?.firstChild ?? end.firstChild!, 1);
|
||||
doc.getSelection()!.removeAllRanges();
|
||||
doc.getSelection()!.addRange(range);
|
||||
};
|
||||
select("b2", "b2");
|
||||
expect(wordSelection(doc)?.position).toBe("body block 2");
|
||||
select("b2", "b4");
|
||||
expect(wordSelection(doc)?.position).toBe("body blocks 2-4");
|
||||
select("b3", "b3");
|
||||
expect(wordSelection(doc)?.position).toBe("body block 3");
|
||||
const triple = doc.createRange();
|
||||
triple.setStart(doc.getElementById("b2")!.firstChild!, 0);
|
||||
triple.setEnd(doc.getElementById("b3")!, 0);
|
||||
doc.getSelection()!.removeAllRanges();
|
||||
doc.getSelection()!.addRange(triple);
|
||||
expect(wordSelection(doc)?.position).toBe("body block 2");
|
||||
doc.getSelection()!.removeAllRanges();
|
||||
expect(wordSelection(doc)).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { t } from "../../i18n";
|
||||
|
||||
export interface OfficeSelection {
|
||||
text: string;
|
||||
/** Where the text sits in the file, in terms a later read of the same file can resolve. */
|
||||
position: string;
|
||||
/** The same place, worded for the reader. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
const CELL = /^([A-Z]+)(\d+)$/;
|
||||
const columnNumber = (s: string) => [...s].reduce((n, ch) => n * 26 + ch.charCodeAt(0) - 64, 0);
|
||||
function columnLetters(n: number): string {
|
||||
let s = "";
|
||||
for (; n > 0; n = Math.floor((n - 1) / 26)) s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
|
||||
return s;
|
||||
}
|
||||
|
||||
/** The rectangle two cell addresses span, written top-left to bottom-right. */
|
||||
export function cellRange(a: string, b: string): { top: number; left: number; bottom: number; right: number; text: string } | undefined {
|
||||
const x = CELL.exec(a), y = CELL.exec(b);
|
||||
if (!x || !y) return undefined;
|
||||
const top = Math.min(Number(x[2]), Number(y[2])), bottom = Math.max(Number(x[2]), Number(y[2]));
|
||||
const left = Math.min(columnNumber(x[1]), columnNumber(y[1])), right = Math.max(columnNumber(x[1]), columnNumber(y[1]));
|
||||
const start = `${columnLetters(left)}${top}`, end = `${columnLetters(right)}${bottom}`;
|
||||
return { top, left, bottom, right, text: start === end ? start : `${start}:${end}` };
|
||||
}
|
||||
|
||||
/** The cells of a range in the rendered sheet, as tab-separated rows. */
|
||||
export function sheetSelection(doc: Document, sheet: string, a: string, b: string): OfficeSelection | undefined {
|
||||
const range = cellRange(a, b);
|
||||
if (!range) return undefined;
|
||||
const rows: string[] = [];
|
||||
for (let r = range.top; r <= range.bottom; r++) {
|
||||
const row: string[] = [];
|
||||
for (let c = range.left; c <= range.right; c++) {
|
||||
row.push(doc.querySelector(`[data-cell="${columnLetters(c)}${r}"]`)?.textContent ?? "");
|
||||
}
|
||||
rows.push(row.join("\t"));
|
||||
}
|
||||
return { text: rows.join("\n"), position: `sheet "${sheet}", cells ${range.text}`, label: t("{sheet} 工作表 {range}", { sheet, range: range.text }) };
|
||||
}
|
||||
|
||||
/** Marks the cells of a range, clearing any earlier mark. */
|
||||
export function markCells(doc: Document, a: string, b: string): void {
|
||||
doc.querySelectorAll(".sel").forEach((el) => el.classList.remove("sel"));
|
||||
const range = cellRange(a, b);
|
||||
if (!range) return;
|
||||
for (let r = range.top; r <= range.bottom; r++) {
|
||||
for (let c = range.left; c <= range.right; c++) doc.querySelector(`[data-cell="${columnLetters(c)}${r}"]`)?.classList.add("sel");
|
||||
}
|
||||
}
|
||||
|
||||
const bodyBlocks = (doc: Document) => [...doc.querySelectorAll("section.docx > article > *")];
|
||||
|
||||
function blockOf(blocks: Element[], node: Node | null): number {
|
||||
for (let el: Node | null = node; el; el = el.parentNode) {
|
||||
const at = blocks.indexOf(el as Element);
|
||||
if (at >= 0) return at + 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** The selected text of a rendered Word body and the body blocks it covers, counted
|
||||
* from 1 in document order with each paragraph and each table one block. */
|
||||
export function wordSelection(doc: Document): OfficeSelection | undefined {
|
||||
const sel = doc.getSelection();
|
||||
const text = sel?.toString().trim();
|
||||
if (!sel || sel.isCollapsed || !text || sel.rangeCount === 0) return undefined;
|
||||
const range = sel.getRangeAt(0);
|
||||
const blocks = bodyBlocks(doc);
|
||||
const first = blockOf(blocks, range.startContainer);
|
||||
let last = blockOf(blocks, range.endContainer);
|
||||
if (first === 0 || last === 0) return undefined;
|
||||
// A triple click ends the range at the start of the next block; a block none of
|
||||
// whose text is selected is not part of the selection.
|
||||
if (last > first) {
|
||||
const tail = doc.createRange();
|
||||
tail.setStart(blocks[last - 1], 0);
|
||||
tail.setEnd(range.endContainer, range.endOffset);
|
||||
if (!tail.toString().trim()) last--;
|
||||
}
|
||||
return first === last
|
||||
? { text, position: `body block ${first}`, label: t("正文第 {n} 块", { n: first }) }
|
||||
: { text, position: `body blocks ${first}-${last}`, label: t("正文第 {a}–{b} 块", { a: first, b: last }) };
|
||||
}
|
||||
@@ -104,7 +104,9 @@ const SHEET_CSS = "body{margin:0;font:13px system-ui,sans-serif;color:#1f1f1f;ba
|
||||
+ "table{border-collapse:collapse;table-layout:fixed}"
|
||||
+ "td,th{border:1px solid #d9d9d9;padding:2px 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}"
|
||||
+ "th{background:#f3f3f3;color:#666;font-weight:normal;position:sticky;top:0}"
|
||||
+ "tbody th{position:sticky;left:0}";
|
||||
+ "tbody th{position:sticky;left:0}"
|
||||
+ "td{cursor:cell;user-select:none}"
|
||||
+ ".sel{outline:2px solid #2f6fdf;outline-offset:-2px;background-image:linear-gradient(rgba(47,111,223,.16),rgba(47,111,223,.16))}";
|
||||
|
||||
export async function readWorkbook(data: ArrayBuffer): Promise<SheetView[]> {
|
||||
const { default: ExcelJS } = await import("exceljs");
|
||||
|
||||
@@ -326,6 +326,7 @@ function analysisContext(sources: Map<string, string | Node>, opts: Options) {
|
||||
const prop = (node.property as Node)?.name as string | undefined;
|
||||
if (/^(window|document|globalThis)$/.test(flat(node))) return "Window";
|
||||
if (prop === "currentTarget" || prop === "target") return "EventTarget";
|
||||
if (prop === "contentDocument" && targetTypeOf(node.object as Node, path, depth + 1, stack) === "HTMLIFrameElement") return "Document";
|
||||
if (prop !== "current" || (node.object as Node)?.type !== "Identifier") return null;
|
||||
return refArgs.get(path + "#" + ((node.object as Node).name as string)) ?? null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
- 新增:Office 预览里可以选中 Word 文字或拖选 Excel 单元格,一键加入对话,引用会带上所在位置(如「Q3 工作表 B2:D4」「正文第 5 块」)。关联 #14622。
|
||||
- New: in the Office preview, select Word text or drag across Excel cells and add it to the chat with its position, such as sheet Q3 B2:D4 or body block 5. Refs #14622.
|
||||
Reference in new issue
Block a user