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:
esengine committed 2026-11-01 09:22:08 -08:00
1 parent f6aa704059
commit dc6c69a5f8
12 files changed
+243 -8

No files matched your search

+27 -1
View File
@@ -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");
+1
View File
@@ -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.