Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md

Large diffs are not rendered by default.

36 changes: 22 additions & 14 deletions README.en.md

Large diffs are not rendered by default.

34 changes: 20 additions & 14 deletions README.md

Large diffs are not rendered by default.

Binary file modified public/screenshots/27-landing-stats.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/screenshots/en/27-landing-stats.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions public/screenshots/screenshots.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"27-landing-stats.png": {
"paletteFormulas": 37,
"engineFunctions": 66,
"tests": 1985,
"tests": 1992,
"securityTests": 325
},
"38-landing-problems.png": {
Expand All @@ -21,7 +21,7 @@
"en/27-landing-stats.png": {
"paletteFormulas": 37,
"engineFunctions": 66,
"tests": 1985,
"tests": 1992,
"securityTests": 325
},
"en/38-landing-problems.png": {
Expand Down
Binary file modified public/social-preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 40 additions & 0 deletions scripts/check-e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1560,6 +1560,46 @@ const FLOWS = [
note((await cell(page, 1, 1).locator("input").count()) === 0 && REFUSED.test((await refused()) ?? ""), "a double-click opens no editor and says why");
},
},
{
// #232. Pasting was the other door into a live cell: written, announced as pasted, then wiped
// by the next refresh with nothing said and nothing Ctrl+Z could bring back. The seam is the
// clipboard meeting a block that rewrites itself every five seconds.
name: "pasting into a live block is refused whole and said, and a refresh changes nothing (#232)",
async run(page) {
const REFUSED = /^B3 (เป็นข้อมูลสด|is live data)/;
await cell(page, 0, 0).click();
await page.getByRole("button", { name: label.liveData }).first().click();
await page.getByRole("button", { name: label.trySales }).click();
const form = page.getByRole("dialog");
await form.getByRole("button", { name: label.test }).click();
await form.getByText(/ได้ข้อมูล 5 แถว|Got 5 rows/).waitFor({ timeout: 15_000 });
await form.getByRole("button", { name: label.saveAndAdd }).click();
const insert = page.getByRole("dialog").getByRole("button", { name: label.insert });
await insert.waitFor({ timeout: 15_000 });
await insert.click();
await page.waitForFunction(() => document.querySelector('td[data-row="1"][data-col="0"]')?.innerText.trim() === "CF-01", null, { timeout: 15_000 });

await typeInCell(page, 1, 8, "PASTED");
await cell(page, 1, 8).click();
await page.keyboard.press("Control+c");
const before = (await cell(page, 2, 1).innerText()).trim();
await cell(page, 2, 1).click();
await page.keyboard.press("Control+v");
const spoken = await page.evaluate(() => [...document.querySelectorAll('[role="status"][aria-live]')].map((e) => e.textContent).join(" "));
note(!/วาง|[Pp]asted/.test(spoken), `nothing is said as pasted ("${spoken.trim().slice(0, 80)}")`);
note(REFUSED.test((await cell(page, 2, 1).getAttribute("data-refused")) ?? ""), "B3 says under itself that it is live data");
note((await cell(page, 2, 1).innerText()).trim() === before, `B3 still holds the source's value ("${before}")`);
await page.waitForTimeout(6000); // past the source's 5-second refresh, when the paste used to vanish
const after = (await cell(page, 2, 1).innerText()).trim();
note(after !== "PASTED", `after a refresh B3 is the source's value, as it was all along ("${after}")`);

// A range whose first cell is live: typing is answered under that cell too.
await cell(page, 4, 1).click();
await cell(page, 5, 2).click({ modifiers: ["Shift"] });
await page.keyboard.type("9");
note(/^B5 (เป็นข้อมูลสด|is live data)/.test((await cell(page, 4, 1).getAttribute("data-refused")) ?? ""), "typing on B5:C6 says why under B5");
},
},
{
// #83. The app is used to open a file, fix it and send it back, and a file with pictures or
// Excel's own charts loses them on the way — which the person used to hear from whoever got
Expand Down
2 changes: 1 addition & 1 deletion src/app/opengraph-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default function OgImage() {
const stats = [
["66", "engine functions"],
["37", "palette formulas"],
["1985", "automated tests"],
["1992", "automated tests"],
["325", "of them security"],
["0", "formula libraries"],
];
Expand Down
74 changes: 43 additions & 31 deletions src/features/grid/SpreadsheetGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,6 @@ export default function SpreadsheetGrid() {
}, [sheet]);
const hasContent = (col: number) => columnsWithContent.has(col);
const blockAt = (row: number, col: number) => boundCells.get(`${row},${col}`);
const isBound = (row: number, col: number) => boundCells.has(`${row},${col}`);
const sourceNameOf = (sourceId: string) => sources.find((s) => s.id === sourceId)?.name ?? "";
const selectedBlock = blockAt(selection.anchorRow, selection.anchorCol);

Expand Down Expand Up @@ -570,28 +569,6 @@ export default function SpreadsheetGrid() {
return () => window.clearTimeout(timer);
}, [tapHintShown, tapHint]);

/**
* A cell that refused an edit, and why (#207).
*
* A live-data cell, or a template's structure, cannot be typed over, and it used to refuse in
* silence: no editor opened, the keys went nowhere, Enter moved on, and what was typed was gone
* with only a hover tooltip to explain it. Now the cell says so under itself, the way the tap
* hint does, and says it out loud once. Every keystroke that is refused keeps it up a little longer.
*/
const [refusal, setRefusal] = useState<{ row: number; col: number; shift: number; text: string } | null>(null);
const refusalShown =
refusal !== null &&
!editing &&
selection.startRow === selection.endRow &&
selection.startCol === selection.endCol &&
refusal.row === selection.anchorRow &&
refusal.col === selection.anchorCol;
useEffect(() => {
if (!refusalShown) return;
const timer = window.setTimeout(() => setRefusal(null), 6000);
return () => window.clearTimeout(timer);
}, [refusalShown, refusal]);

/**
* Put the browser's focus where the cursor is.
*
Expand Down Expand Up @@ -696,23 +673,58 @@ export default function SpreadsheetGrid() {
return Math.max(0, Math.round(overhang));
};

/**
* A cell that refused an edit, and why (#207).
*
* A live-data cell, or a template's structure, cannot be typed over, and it used to refuse in
* silence: no editor opened, the keys went nowhere, Enter moved on, and what was typed was gone
* with only a hover tooltip to explain it. Now the cell says so under itself, the way the tap
* hint does, and says it out loud once. Every keystroke that is refused keeps it up a little longer.
*
* The refusal lives in the store (#232), because a paste, a fill, a cut or a delete is refused
* there, and each says so with the same note under the cell the cursor is on. It shows whatever
* the selection, so typing on a range whose first cell is live data is answered too.
*/
const refusal = useSheetStore((s) => s.refusal);
const dismissRefusal = useSheetStore((s) => s.dismissRefusal);
const refusalShown =
refusal !== null &&
refusal.sheetId === activeSheetId &&
!editing &&
refusal.row === selection.anchorRow &&
refusal.col === selection.anchorCol;
// Measured as a note arrives rather than kept with it: the store does not know the screen.
const [refusalShift, setRefusalShift] = useState(0);
useEffect(
() =>
useSheetStore.subscribe((now, before) => {
if (now.refusal && now.refusal !== before.refusal) {
setRefusalShift(labelShift(now.refusal.row, now.refusal.col, REFUSAL_ROOM));
}
}),
[]
);
useEffect(() => {
if (!refusalShown) return;
const timer = window.setTimeout(dismissRefusal, 6000);
return () => window.clearTimeout(timer);
}, [refusalShown, refusal?.seq, dismissRefusal]);

/** Say under the cell why it did not take an edit (#207), out loud only the first time in a row. */
const refuseEdit = (row: number, col: number) => {
const text = boundCells.has(`${row},${col}`)
? t.data.liveCellRefused(cellRef(row, col))
: t.template.lockedCellRefused(cellRef(row, col));
const again = refusalShown && refusal.row === row && refusal.col === col;
setTapHint(null);
setRefusal({ row, col, shift: labelShift(row, col, REFUSAL_ROOM), text });
if (!again) announce(text);
useSheetStore.getState().refuseEdit(row, col, text);
};

const startEdit = (row: number, col: number, initialValue?: string) => {
if (!canEdit(row, col)) return refuseEdit(row, col);
// A tab that is only looking (#47) says so rather than opening an editor it cannot save.
if (!isEditingTab()) return noteRefusedEdit();
setTapHint(null);
setRefusal(null);
if (refusal) dismissRefusal();
setEditing({ row, col, value: initialValue ?? rawAt(row, col), typed: initialValue !== undefined });
};

Expand Down Expand Up @@ -825,7 +837,7 @@ export default function SpreadsheetGrid() {
// Typing and pressing Enter in one go was how the value vanished: the note came with the first
// key and the cursor left with Enter, taking the note with it before anyone could read it.
if (refusalShown && (e.key === "Enter" || e.key === "Escape") && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
setRefusal(null);
dismissRefusal();
e.preventDefault();
return;
}
Expand Down Expand Up @@ -1020,8 +1032,8 @@ export default function SpreadsheetGrid() {
}
case "Delete":
case "Backspace":
if (!isBound(row, col)) clearSelection();
else refuseEdit(row, col);
// Refused in the store when the range reaches live data or a template's structure (#232).
clearSelection();
e.preventDefault();
break;
case "F2":
Expand Down Expand Up @@ -1344,7 +1356,7 @@ export default function SpreadsheetGrid() {
? ({ "--tap-hint-shift": `${tapHint.shift}px` } as React.CSSProperties)
: null),
...(refusalShown && refusal.row === r && refusal.col === c
? ({ "--refused-shift": `${refusal.shift}px` } as React.CSSProperties)
? ({ "--refused-shift": `${refusalShift}px` } as React.CSSProperties)
: null),
};
})()}
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const en: Messages = {
liveCellTitle: (source) => `Live data from "${source}" — click for options`,
liveCellReadOnly: "This cell is live data and can't be edited — click \"Remove\" first to type over it",
liveCellRefused: (address) => `${address} is live data and can't be typed over — press "Remove" first to edit it yourself`,
liveCellsRefused: (cells) => `${cells} is live data, so nothing was changed — press "Remove" first to edit it yourself`,
partial: (rows) => `Only the first ${rows.toLocaleString("en-US")} rows`,
partialHint: "This source has more data — raise the row limit in the source's settings",
partialSizeHint: (mb) =>
Expand Down Expand Up @@ -431,7 +432,7 @@ export const en: Messages = {
stats: [
{ value: "37", label: "ready-made formulas" },
{ value: "66", label: "engine functions" },
{ value: "1985", label: "automated tests" },
{ value: "1992", label: "automated tests" },
{ value: "325", label: "security tests" },
{ value: "0", label: "formula libraries used" },
],
Expand Down Expand Up @@ -523,6 +524,7 @@ export const en: Messages = {
hint: "The white cells are yours to fill in; the grey ones are the template's structure",
lockedCell: "This cell is part of the template's structure — click \"Unlock the sheet\" to change it",
lockedCellRefused: (address) => `${address} is part of the template's structure — press "Unlock the sheet" to change it`,
lockedCellsRefused: (cells) => `${cells} is part of the template's structure, so nothing was changed — press "Unlock the sheet" to change it`,
unlock: "Unlock the sheet",
confirmUnlock: "Unlock this template? Every cell becomes editable like an ordinary sheet and the dropdowns are dropped (Ctrl+Z undoes it).",
unlocked: "Unlocked — every cell is editable",
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/th.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export const th: Messages = {
liveCellReadOnly: "เซลล์นี้เป็นข้อมูลสด แก้ไขเองไม่ได้ — กด \"เอาออก\" ก่อนถ้าต้องการพิมพ์ทับ",
// U+2060 (word joiner) keeps a button's name on one line when the note under the cell wraps.
liveCellRefused: (address) => `${address} เป็นข้อมูลสด พิมพ์ทับไม่ได้ — กด "เอา\u2060ออก" ก่อนถ้าจะแก้เอง`,
liveCellsRefused: (cells) => `${cells} เป็นข้อมูลสด จึงไม่ได้เปลี่ยนอะไร — กด "เอา\u2060ออก" ก่อนถ้าจะแก้เอง`,
partial: (rows) => `ได้มาแค่ ${rows.toLocaleString("th-TH")} แถวแรก`,
partialHint: "แหล่งข้อมูลนี้ยังมีข้อมูลมากกว่านี้ — เพิ่มขีดจำกัดแถวได้ที่การตั้งค่าแหล่งข้อมูล",
partialSizeHint: (mb) =>
Expand Down Expand Up @@ -431,7 +432,7 @@ export const th: Messages = {
stats: [
{ value: "37", label: "สูตรพร้อมใช้" },
{ value: "66", label: "ฟังก์ชันในเอนจิน" },
{ value: "1985", label: "เทสต์อัตโนมัติ" },
{ value: "1992", label: "เทสต์อัตโนมัติ" },
{ value: "325", label: "เทสต์ด้านความปลอดภัย" },
{ value: "0", label: "ไลบรารีคำนวณสูตร" },
],
Expand Down Expand Up @@ -523,6 +524,7 @@ export const th: Messages = {
hint: "ช่องสีขาวคือช่องที่กรอกได้ ช่องสีเทาเป็นโครงของแม่แบบ แก้ไม่ได้",
lockedCell: "ช่องนี้เป็นโครงของแม่แบบ แก้ไม่ได้ — กด \"ปลดล็อกทั้งชีต\" ถ้าต้องการแก้",
lockedCellRefused: (address) => `${address} เป็นโครงของแม่แบบ แก้ไม่ได้ — กด "ปลด\u2060ล็อก\u2060ทั้ง\u2060ชีต" ถ้าต้องการแก้`,
lockedCellsRefused: (cells) => `${cells} เป็นโครงของแม่แบบ จึงไม่ได้เปลี่ยนอะไร — กด "ปลด\u2060ล็อก\u2060ทั้ง\u2060ชีต" ถ้าต้องการแก้`,
unlock: "ปลดล็อกทั้งชีต",
confirmUnlock: "ปลดล็อกแม่แบบ? จะแก้ได้ทุกช่องเหมือนตารางทั่วไป และตัวเลือก dropdown จะหายไป (กด Ctrl+Z ย้อนได้)",
unlocked: "ปลดล็อกแล้ว — แก้ได้ทุกช่อง",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ export interface Messages {
liveCellReadOnly: string;
/** Under a live cell that was typed into (#207): which cell, and how to type over it after all. */
liveCellRefused: (address: string) => string;
/** A paste, fill, cut or delete refused because it reached live cells (#232), which it names. */
liveCellsRefused: (cells: string) => string;
/** Shown wherever a paginated source's table is only part of the data. */
partial: (rows: number) => string;
partialHint: string;
Expand Down Expand Up @@ -354,6 +356,8 @@ export interface Messages {
lockedCell: string;
/** Under a template cell that was typed into (#207). */
lockedCellRefused: (address: string) => string;
/** The same for a template's structure (#232). */
lockedCellsRefused: (cells: string) => string;
unlock: string;
confirmUnlock: string;
unlocked: string;
Expand Down
Loading
Loading