Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 963822bd28 | |||
| 768d135a19 |
@@ -72,10 +72,7 @@ git log -1 --format='Author: %an <%ae>%nCommitter: %cn <%ce>'
|
|||||||
|
|
||||||
### 4. Push and PR to develop
|
### 4. Push and PR to develop
|
||||||
|
|
||||||
PRs always target `develop`. Two different mechanisms are involved: **pushing
|
PRs always target `develop`. The `claude_code` password lives in the macOS
|
||||||
commits is git-native** (the Gitea MCP cannot push local git history, so the
|
|
||||||
branch is still pushed with `git push`), while **the PR itself is opened through
|
|
||||||
the Gitea MCP** (see below). The `claude_code` password lives in the macOS
|
|
||||||
keychain as a **generic password** under service `gitea-claude-code` (do not
|
keychain as a **generic password** under service `gitea-claude-code` (do not
|
||||||
duplicate it as an internet-password for `gitea.vvzvlad.xyz` — that creates a
|
duplicate it as an internet-password for `gitea.vvzvlad.xyz` — that creates a
|
||||||
conflict with the owner's account in the git credential helper):
|
conflict with the owner's account in the git credential helper):
|
||||||
@@ -97,24 +94,18 @@ git remote set-url gitea "$ORIG_URL"
|
|||||||
unset AGENT_PASS SAFE_PASS
|
unset AGENT_PASS SAFE_PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
The PR is opened through the **Gitea MCP** (server `gitea`), not `curl`/`tea` —
|
The PR is created via the Gitea REST API (Basic Auth as `claude_code`):
|
||||||
the MCP authenticates in-process, so no keychain lookup or Basic-Auth is needed.
|
|
||||||
Call `pull_request_write` with:
|
|
||||||
|
|
||||||
- `method: "create"`
|
```bash
|
||||||
- `owner: "vvzvlad"`, `repo: "gitmost"`
|
curl -s -X POST \
|
||||||
- `base: "develop"`, `head: "<branch>"`
|
-u "claude_code:$(security find-generic-password -s gitea-claude-code -w)" \
|
||||||
- `title`, `body` — in the body: what was done, what is out of scope,
|
-H "Content-Type: application/json" \
|
||||||
verification results (tsc/lint/tests).
|
-d @pr_body.json \
|
||||||
|
"https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls"
|
||||||
|
```
|
||||||
|
|
||||||
Manage and read PRs through the same server: `list_pull_requests`,
|
`base: develop`, `head: <branch>`. In the PR body: what was done, what is out
|
||||||
`pull_request_read` (`get`, `get_diff`, `get_files`, `get_status`),
|
of scope, verification results (tsc/lint/tests).
|
||||||
`pull_request_review_write`.
|
|
||||||
|
|
||||||
**Identity note:** the MCP acts under its **own** configured Gitea token (verify
|
|
||||||
with `get_me`), a different account from the `claude_code` used for git
|
|
||||||
commits/pushes in §3. Only the forge API calls (PR / issue / review) go through
|
|
||||||
the MCP account; the commits themselves stay authored as `claude_code`.
|
|
||||||
|
|
||||||
> If push fails with `User permission denied for writing`, then `claude_code`
|
> If push fails with `User permission denied for writing`, then `claude_code`
|
||||||
> lacks collaborator rights on the repo. Ask the owner to add them (once, via
|
> lacks collaborator rights on the repo. Ask the owner to add them (once, via
|
||||||
@@ -161,25 +152,23 @@ below.
|
|||||||
| Agent user (Gitea/git) | `claude_code` |
|
| Agent user (Gitea/git) | `claude_code` |
|
||||||
| Agent email | `claude_code@vvzvlad.xyz` |
|
| Agent email | `claude_code@vvzvlad.xyz` |
|
||||||
| Keychain password | `security find-generic-password -s gitea-claude-code -w` |
|
| Keychain password | `security find-generic-password -s gitea-claude-code -w` |
|
||||||
| Forge API (PR / issue / review / reads) | **Gitea MCP** — server `gitea` (`pull_request_write`, `issue_write`, `list_pull_requests`, `pull_request_read`, `label_read`, …). Authenticated in-process; acts under its own token — check with `get_me`. Repo slug on the server is `gitmost`. |
|
| PR API | `https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls` (here `gitmost` is the repo's real slug on the server) |
|
||||||
| Base branch | `develop` |
|
| Base branch | `develop` |
|
||||||
| `origin` | GitHub mirror `vvzvlad/gitmost` — **do not push**, updated by the owner's CI |
|
| `origin` | GitHub mirror `vvzvlad/gitmost` — **do not push**, updated by the owner's CI |
|
||||||
| `upstream` | The original Docmost — **never push** |
|
| `upstream` | The original Docmost — **never push** |
|
||||||
|
|
||||||
## Creating issues (Gitea MCP)
|
## Creating issues (Gitea `tea` CLI)
|
||||||
|
|
||||||
File issues through the **Gitea MCP** (server `gitea`), not a CLI — call
|
Issues are filed with the official Gitea CLI `tea`, already logged in as
|
||||||
`issue_write` with:
|
`claude_code` (`tea logins list` shows the `gitea` login as default):
|
||||||
|
|
||||||
- `method: "create"`
|
```bash
|
||||||
- `owner: "vvzvlad"`, `repo: "gitmost"`
|
tea issues create --repo vvzvlad/gitmost --labels feature \
|
||||||
- `title`, `body`
|
--title '<title>' --description "$(cat body.md)"
|
||||||
- `labels` — an array of label **IDs** (numbers), *not* names. Resolve a name
|
```
|
||||||
such as `feature` to its id first with `label_read` (`method: "list"`), then
|
|
||||||
pass e.g. `labels: [<id>]`.
|
|
||||||
|
|
||||||
Read issues with `list_issues`, `issue_read`, or `search_issues`. The MCP is
|
> Gotcha (tea 0.14.1): the issue body flag is `--description`/`-d`, **not**
|
||||||
authenticated in-process, so no `tea`/`curl` and no keychain lookup are needed.
|
> `--body` — passing `--body` fails with `flag provided but not defined: -body`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+2
-70
@@ -14,10 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
- **Place several images side by side in a row.** A new "Inline (side by
|
- **Place several images side by side in a row.** A new "Inline (side by
|
||||||
side)" alignment mode in the image bubble menu renders consecutive inline
|
side)" alignment mode in the image bubble menu renders consecutive inline
|
||||||
images as a row that wraps onto the next line on narrow screens. The row is
|
images as a row that wraps onto the next line on narrow screens. Unlike the
|
||||||
centered horizontally by default in modern browsers (CSS `:has()`), falling
|
float modes, text does not wrap around inline images. The mode round-trips
|
||||||
back to start-aligned rows in browsers without support. Unlike the float
|
|
||||||
modes, text does not wrap around inline images. The mode round-trips
|
|
||||||
losslessly through markdown as `data-align`, like the other alignment
|
losslessly through markdown as `data-align`, like the other alignment
|
||||||
values.
|
values.
|
||||||
|
|
||||||
@@ -86,53 +84,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
with the `||text||` input rule; the rendered span blurs until clicked to reveal.
|
with the `||text||` input rule; the rendered span blurs until clicked to reveal.
|
||||||
The mark is preserved losslessly through Markdown export/import (as a raw
|
The mark is preserved losslessly through Markdown export/import (as a raw
|
||||||
`<span data-spoiler="true">…</span>`) and on public shares. (#259)
|
`<span data-spoiler="true">…</span>`) and on public shares. (#259)
|
||||||
- **Dock the AI chat window into the side menu.** The floating chat window can
|
|
||||||
be pinned to the sidebar — drag it onto the navbar (a drop-zone highlight
|
|
||||||
shows where it lands) or use the new "Dock to sidebar" header button; while
|
|
||||||
docked it fills the sidebar area and follows its live size. "Undock" (or
|
|
||||||
dragging it back out) restores the floating window, a collapsed/absent
|
|
||||||
sidebar falls back to floating, and the docked state survives a reload.
|
|
||||||
(#276, #282)
|
|
||||||
- **Hovering commented text shows the comment thread in a tooltip.** Pointing
|
|
||||||
at a highlighted comment mark pops a small card with the author and plain
|
|
||||||
text of the root comment and its replies, so a thread can be skimmed without
|
|
||||||
opening the side panel. The card appears after a short delay (no flicker on a
|
|
||||||
passing glance), skips resolved and text-less threads, and dismisses on
|
|
||||||
scroll or click — clicking a mark still opens the comments panel. (#268,
|
|
||||||
#271)
|
|
||||||
- **"Move to trash" button in the temporary-note banner.** Besides "Make
|
|
||||||
permanent", the banner on an open temporary note now also offers to trash the
|
|
||||||
note immediately instead of waiting out its lifetime. It reuses the regular
|
|
||||||
soft-delete path, so the "Page moved to trash" undo toast is the safety net —
|
|
||||||
no confirmation dialog. (#273, #277)
|
|
||||||
- **Code-block controls float as an overlay instead of taking a row above the
|
|
||||||
code.** The language selector and copy button now sit in the block's top-right
|
|
||||||
corner, and the selector stays invisible until the block is hovered or the
|
|
||||||
selector is focused, so reading code is chrome-free. In read-only views only
|
|
||||||
the copy button renders. (#275, #278)
|
|
||||||
- **The AI agent is told about your page edits between turns.** The server
|
|
||||||
snapshots the open page's Markdown at the end of every agent turn and, on the
|
|
||||||
next turn, injects a unified diff of what changed in between, so the agent
|
|
||||||
knows its earlier copy of the page is stale and builds on the user's edits
|
|
||||||
instead of reverting or overwriting them. The diff is whitespace-normalized
|
|
||||||
(pure formatting churn injects nothing) and size-capped, with a hint to
|
|
||||||
re-read the full page via `getPage` when truncated. (#274, #281)
|
|
||||||
- **Stress-accent button (U+0301) in the bubble menu.** Select a vowel and
|
|
||||||
toggle a combining acute accent over it — a Russian-style stress mark. The
|
|
||||||
accent is stored as plain text (no custom mark), so it survives Markdown/HTML
|
|
||||||
export, full-text search and public shares unchanged; the toggle is a single
|
|
||||||
undo step and re-clicking removes the accent. (#270, #280)
|
|
||||||
- **Reading position survives a reload.** The editor remembers how far you
|
|
||||||
scrolled in each page (per tab, in `sessionStorage`) and restores that
|
|
||||||
position after an F5 or reopening the document, waiting for the collaborative
|
|
||||||
content to finish laying out first. A URL `#hash` anchor still wins — restore
|
|
||||||
is a no-op then. (#266, #267)
|
|
||||||
- **The slash menu finds commands typed in the wrong keyboard layout.** A query
|
|
||||||
typed with the wrong layout active (e.g. `/сщву` for `/code`, or `/cyjcrf`
|
|
||||||
for the Cyrillic «сноска» → Footnote) is additionally remapped ЙЦУКЕН↔QWERTY
|
|
||||||
by physical key position and matched against the commands; genuine Cyrillic
|
|
||||||
search terms keep priority over remapped candidates, and short wrong-layout
|
|
||||||
prefixes match by command title. (#283, #285, #287)
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -198,25 +149,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
emits a single-use "intentional clear" signal that lets exactly that one empty
|
emits a single-use "intentional clear" signal that lets exactly that one empty
|
||||||
write through the guard, so genuinely emptying a page is persisted while
|
write through the guard, so genuinely emptying a page is persisted while
|
||||||
accidental empties are blocked. (#248, #251)
|
accidental empties are blocked. (#248, #251)
|
||||||
- **Ctrl+Z works again right after using a table menu.** Closing a table
|
|
||||||
row/column menu (grip or chevron) left focus on the menu's portaled target
|
|
||||||
outside the editor, so undo keystrokes went nowhere until you clicked back
|
|
||||||
into a cell. The editor is now refocused after the menu closes — unless you
|
|
||||||
deliberately moved focus to another input or editable (e.g. the page title).
|
|
||||||
(#269, #279)
|
|
||||||
- **The AI reindex progress counter no longer freezes at 0.** Right after
|
|
||||||
"Reindex now" the client could read the stale pre-reindex snapshot of an
|
|
||||||
already-indexed workspace (`reindexing=false`, all pages counted) as
|
|
||||||
"finished" and stop polling on the very first tick, leaving the counter
|
|
||||||
frozen until a manual reload. Polling now keeps going until it has actually
|
|
||||||
observed the active run. (#262, #264)
|
|
||||||
- **An MCP edit can no longer be silently lost to a duplicate collab document.**
|
|
||||||
When the agent addressed a page by its short slugId, the MCP opened a
|
|
||||||
collaboration document named after that slugId while the web editor always
|
|
||||||
uses the page's canonical UUID — two independent live documents for one page,
|
|
||||||
whose debounced stores clobbered each other. The MCP now resolves every page
|
|
||||||
id to the canonical UUID before opening the collab doc (a UUID input
|
|
||||||
short-circuits locally; a slugId is resolved once and cached). (#260, #265)
|
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ community feature, with no enterprise license. Open it from the page header; the
|
|||||||
- ✅ **Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks).
|
- ✅ **Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks).
|
||||||
- ✅ **Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle.
|
- ✅ **Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle.
|
||||||
- ✅ **Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP.
|
- ✅ **Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP.
|
||||||
- ✅ **Temporary notes** — create a note as temporary and it auto-moves to Trash after a configurable per-workspace lifetime (default 24h) unless made permanent first; create one in a click from the Home screen, any space overview.
|
- ✅ **Temporary notes** — mark a note as temporary and it auto-moves to Trash after a configurable per-workspace lifetime (default 24h) unless made permanent first; create one in a click from the Home screen, any space overview, or the space sidebar, with a "Make permanent" rescue banner on the open note.
|
||||||
|
|
||||||
### In progress
|
### In progress
|
||||||
|
|
||||||
@@ -187,17 +187,14 @@ start the new migrations apply on top of your existing schema (`CREATE EXTENSION
|
|||||||
- Spaces
|
- Spaces
|
||||||
- Permissions management
|
- Permissions management
|
||||||
- Groups
|
- Groups
|
||||||
- Comments (with resolve / re-open and hover tooltips showing the comment text)
|
- Comments (with resolve / re-open)
|
||||||
- Page history
|
- Page history
|
||||||
- Search
|
- Search
|
||||||
- File attachments
|
- File attachments
|
||||||
- Embeds (Airtable, Loom, Miro and more)
|
- Embeds (Airtable, Loom, Miro and more)
|
||||||
- Translations (10+ languages)
|
- Translations (10+ languages)
|
||||||
- Embedded MCP server (`/mcp`)
|
- Embedded MCP server (`/mcp`)
|
||||||
- AI agent chat over your wiki (read + write, RAG search, external MCP / web access); the chat window docks into the side menu, and the agent is told about your in-page edits between turns
|
- AI agent chat over your wiki (read + write, RAG search, external MCP / web access)
|
||||||
- Code-block buttons as an overlay, with the language selector revealed on hover
|
|
||||||
- Stress-accent button (U+0301) in the bubble menu
|
|
||||||
- Reading scroll position restored on reload
|
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||
|
|||||||
+3
-7
@@ -105,7 +105,7 @@ real-time-коллаборации Docmost, поэтому запись нико
|
|||||||
- ✅ **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
|
- ✅ **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
|
||||||
- ✅ **AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
|
- ✅ **AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
|
||||||
- ✅ **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
|
- ✅ **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
|
||||||
- ✅ **Временные заметки** — создайте временную заметку, и она автоматически уедет в корзину по истечении настраиваемого срока жизни (по умолчанию 24 ч); создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства.
|
- ✅ **Временные заметки** — пометьте заметку временной, и она автоматически уедет в корзину по истечении настраиваемого срока жизни воркспейса (по умолчанию 24 ч), если её предварительно не сделать постоянной; создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства, а на открытой заметке есть баннер «Сделать постоянной».
|
||||||
|
|
||||||
### В процессе
|
### В процессе
|
||||||
|
|
||||||
@@ -174,18 +174,14 @@ dump/restore, существующий каталог данных переис
|
|||||||
- Пространства (Spaces)
|
- Пространства (Spaces)
|
||||||
- Управление правами доступа
|
- Управление правами доступа
|
||||||
- Группы
|
- Группы
|
||||||
- Комментарии (с резолвом / переоткрытием и всплывающими подсказками с текстом комментария при наведении)
|
- Комментарии (с резолвом / переоткрытием)
|
||||||
- История страниц
|
- История страниц
|
||||||
- Поиск
|
- Поиск
|
||||||
- Вложения файлов
|
- Вложения файлов
|
||||||
- Встраивания (Airtable, Loom, Miro и другие)
|
- Встраивания (Airtable, Loom, Miro и другие)
|
||||||
- Переводы (10+ языков)
|
- Переводы (10+ языков)
|
||||||
- Встроенный MCP-сервер (`/mcp`)
|
- Встроенный MCP-сервер (`/mcp`)
|
||||||
- Чат с AI-агентом по вики (чтение + запись, RAG-поиск, внешние MCP / доступ в интернет); окно чата закрепляется в боковом меню, а агент узнаёт о ваших правках страницы между ходами
|
- Чат с AI-агентом по вики (чтение + запись, RAG-поиск, внешние MCP / доступ в интернет)
|
||||||
- Кнопки код-блока оверлеем, селектор языка появляется при наведении
|
|
||||||
- Кнопка «Ударение» (U+0301) в bubble-меню
|
|
||||||
- Позиция чтения (прокрутка) восстанавливается после перезагрузки
|
|
||||||
- Slash-меню терпимо к неправильной раскладке (ЙЦУКЕН↔QWERTY)
|
|
||||||
|
|
||||||
### Скриншоты
|
### Скриншоты
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
NAVBAR_COLLAPSE_BREAKPOINT,
|
|
||||||
desktopSidebarAtom,
|
desktopSidebarAtom,
|
||||||
mobileSidebarAtom,
|
mobileSidebarAtom,
|
||||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
@@ -54,13 +53,7 @@ export function AppHeader() {
|
|||||||
aria-label={t("Sidebar toggle")}
|
aria-label={t("Sidebar toggle")}
|
||||||
opened={mobileOpened}
|
opened={mobileOpened}
|
||||||
onClick={toggleMobile}
|
onClick={toggleMobile}
|
||||||
// Must match the AppShell navbar breakpoint (md). The navbar
|
hiddenFrom="sm"
|
||||||
// collapses to the MOBILE drawer below md, so the mobile toggle
|
|
||||||
// (which flips mobileOpened) must be the one visible across the
|
|
||||||
// whole <md band — otherwise at 768-991 the desktop toggle showed
|
|
||||||
// but flipped the wrong atom, leaving the drawer unopenable (the
|
|
||||||
// regression from the initial sm->md navbar change).
|
|
||||||
hiddenFrom={NAVBAR_COLLAPSE_BREAKPOINT}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -70,7 +63,7 @@ export function AppHeader() {
|
|||||||
aria-label={t("Sidebar toggle")}
|
aria-label={t("Sidebar toggle")}
|
||||||
opened={desktopOpened}
|
opened={desktopOpened}
|
||||||
onClick={toggleDesktop}
|
onClick={toggleDesktop}
|
||||||
visibleFrom={NAVBAR_COLLAPSE_BREAKPOINT}
|
visibleFrom="sm"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
APP_NAVBAR_ID,
|
APP_NAVBAR_ID,
|
||||||
NAVBAR_COLLAPSE_BREAKPOINT,
|
|
||||||
asideStateAtom,
|
asideStateAtom,
|
||||||
desktopSidebarAtom,
|
desktopSidebarAtom,
|
||||||
mobileSidebarAtom,
|
mobileSidebarAtom,
|
||||||
@@ -89,13 +88,7 @@ export default function GlobalAppShell({
|
|||||||
header={{ height: 45 }}
|
header={{ height: 45 }}
|
||||||
navbar={{
|
navbar={{
|
||||||
width: isSpaceRoute ? sidebarWidth : 300,
|
width: isSpaceRoute ? sidebarWidth : 300,
|
||||||
// `md` (not `sm`): below 992px the fixed ~300px sidebar leaves too little
|
breakpoint: "sm",
|
||||||
// room for content — the settings tables (Members/…) overflow the offset
|
|
||||||
// content area on tablet (~768px) and clip the Role/actions columns
|
|
||||||
// off-screen with no horizontal scroll. Collapsing the navbar to a toggle
|
|
||||||
// drawer across the whole tablet band frees the full width for content
|
|
||||||
// (the mobile drawer is closed by default, so nothing overlaps on load).
|
|
||||||
breakpoint: NAVBAR_COLLAPSE_BREAKPOINT,
|
|
||||||
collapsed: {
|
collapsed: {
|
||||||
mobile: !mobileOpened,
|
mobile: !mobileOpened,
|
||||||
desktop: !desktopOpened,
|
desktop: !desktopOpened,
|
||||||
@@ -104,7 +97,7 @@ export default function GlobalAppShell({
|
|||||||
aside={
|
aside={
|
||||||
isPageRoute && {
|
isPageRoute && {
|
||||||
width: 420,
|
width: 420,
|
||||||
breakpoint: "md",
|
breakpoint: "sm",
|
||||||
collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
|
collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,6 @@ import { atom } from "jotai";
|
|||||||
// would create a shell -> chat-window -> shell import cycle).
|
// would create a shell -> chat-window -> shell import cycle).
|
||||||
export const APP_NAVBAR_ID = "app-shell-navbar";
|
export const APP_NAVBAR_ID = "app-shell-navbar";
|
||||||
|
|
||||||
// Single source of truth for the navbar collapse breakpoint. The AppShell navbar
|
|
||||||
// `breakpoint` and BOTH burger toggles' `hiddenFrom`/`visibleFrom` MUST use this
|
|
||||||
// exact value: if they drift, the sidebar becomes unreachable on tablet widths
|
|
||||||
// (the round-1 regression of #292). Kept here so the shell and the header share
|
|
||||||
// one constant the compiler enforces, instead of three hand-synced string literals.
|
|
||||||
export const NAVBAR_COLLAPSE_BREAKPOINT = "md";
|
|
||||||
|
|
||||||
export const mobileSidebarAtom = atom<boolean>(false);
|
export const mobileSidebarAtom = atom<boolean>(false);
|
||||||
|
|
||||||
export const desktopSidebarAtom = atomWithWebStorage<boolean>(
|
export const desktopSidebarAtom = atomWithWebStorage<boolean>(
|
||||||
|
|||||||
@@ -1,231 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { Editor } from "@tiptap/core";
|
|
||||||
import { Document } from "@tiptap/extension-document";
|
|
||||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
|
||||||
import { Text } from "@tiptap/extension-text";
|
|
||||||
import { ySyncPluginKey } from "@tiptap/y-tiptap";
|
|
||||||
import {
|
|
||||||
CustomTypography,
|
|
||||||
undoGuardKey,
|
|
||||||
findChangedRange,
|
|
||||||
mapRangeThroughChange,
|
|
||||||
} from "./custom-typography";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PR #296 — the collab-safe typography undo-guard is exercised through the REAL
|
|
||||||
* editor path: a fresh Editor with the CustomTypography extension, transactions
|
|
||||||
* tagged exactly the way prosemirror-history / y-tiptap tag undo & remote
|
|
||||||
* changes (`setMeta("history$", …)` and `setMeta(ySyncPluginKey, …)`), plus
|
|
||||||
* direct unit tests of the two pure diff helpers. No hand-poke of plugin state.
|
|
||||||
*
|
|
||||||
* ARMING MECHANISM (verified against custom-typography.ts source):
|
|
||||||
* - A transaction arms the guard only when it is BOTH history/remote
|
|
||||||
* (`getMeta("history$")` truthy, or `isChangeOrigin` via the ySync meta)
|
|
||||||
* AND an undo/redo (`getMeta("history$")` truthy, or ySync
|
|
||||||
* `isUndoRedoOperation`), AND its whole-doc diff is a REPLACE
|
|
||||||
* (change.oldTo > change.from && change.newTo > change.from).
|
|
||||||
* - `history$` is the stringified PluginKey of the single prosemirror-history
|
|
||||||
* plugin; ProseMirror stores meta under `key.key`, so setMeta("history$")
|
|
||||||
* in a test is read identically by the extension's getMeta("history$").
|
|
||||||
*/
|
|
||||||
|
|
||||||
const singlePara = (text: string) => ({
|
|
||||||
type: "doc",
|
|
||||||
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const makeEditor = (text: string) =>
|
|
||||||
new Editor({
|
|
||||||
extensions: [Document, Paragraph, Text, CustomTypography],
|
|
||||||
content: singlePara(text),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build a before/after EditorState pair by applying one plain transaction.
|
|
||||||
const mutate = (text: string, apply: (tr: any, schema: any) => void) => {
|
|
||||||
const editor = new Editor({
|
|
||||||
extensions: [Document, Paragraph, Text],
|
|
||||||
content: singlePara(text),
|
|
||||||
});
|
|
||||||
const before = editor.state;
|
|
||||||
const tr = before.tr;
|
|
||||||
apply(tr, before.schema);
|
|
||||||
editor.view.dispatch(tr);
|
|
||||||
const after = editor.state;
|
|
||||||
return { before, after, editor };
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("findChangedRange", () => {
|
|
||||||
it("returns null for identical docs", () => {
|
|
||||||
const editor = new Editor({
|
|
||||||
extensions: [Document, Paragraph, Text],
|
|
||||||
content: singlePara("hello"),
|
|
||||||
});
|
|
||||||
expect(findChangedRange(editor.state, editor.state)).toBeNull();
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the minimal range for a normal middle insertion", () => {
|
|
||||||
// "hello world" (text at 1..12); insert "there " at pos 6.
|
|
||||||
const { before, after, editor } = mutate("hello world", (tr) =>
|
|
||||||
tr.insertText("there ", 6),
|
|
||||||
);
|
|
||||||
expect(findChangedRange(before, after)).toEqual({
|
|
||||||
from: 6,
|
|
||||||
oldTo: 6,
|
|
||||||
newTo: 12,
|
|
||||||
});
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes the INSERTION overlapping-bounds branch (repeated content)", () => {
|
|
||||||
// Insert one more 'a' into "aaaaa" at pos 3. findDiffStart lands at the end
|
|
||||||
// (6) while findDiffEnd reports an end BEFORE it ({a:1,b:2}); both ends must
|
|
||||||
// be pushed forward by the same delta -> a non-degenerate range.
|
|
||||||
const { before, after, editor } = mutate("aaaaa", (tr) =>
|
|
||||||
tr.insertText("a", 3),
|
|
||||||
);
|
|
||||||
const change = findChangedRange(before, after)!;
|
|
||||||
expect(change).toEqual({ from: 6, oldTo: 6, newTo: 7 });
|
|
||||||
// Invariant the guard logic relies on: never degenerate.
|
|
||||||
expect(change.from).toBeLessThanOrEqual(change.oldTo);
|
|
||||||
expect(change.from).toBeLessThanOrEqual(change.newTo);
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes the DELETION overlapping-bounds branch (F2 fix)", () => {
|
|
||||||
// Delete one repeated 'a' from the middle of "aaaaa" ([3,4)). Here
|
|
||||||
// findDiffEnd reports newTo < start, the symmetric case the old one-sided
|
|
||||||
// normalization missed -> it used to yield a degenerate range (newTo < from).
|
|
||||||
const { before, after, editor } = mutate("aaaaa", (tr) => tr.delete(3, 4));
|
|
||||||
const change = findChangedRange(before, after)!;
|
|
||||||
expect(change).toEqual({ from: 5, oldTo: 6, newTo: 5 });
|
|
||||||
// The whole point of F2: from <= newTo (and from <= oldTo) still holds.
|
|
||||||
expect(change.from).toBeLessThanOrEqual(change.newTo);
|
|
||||||
expect(change.from).toBeLessThanOrEqual(change.oldTo);
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes a multi-char repeated deletion (F2 fix)", () => {
|
|
||||||
const { before, after, editor } = mutate("aaaaa", (tr) => tr.delete(2, 4));
|
|
||||||
const change = findChangedRange(before, after)!;
|
|
||||||
expect(change).toEqual({ from: 4, oldTo: 6, newTo: 4 });
|
|
||||||
expect(change.from).toBeLessThanOrEqual(change.newTo);
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("mapRangeThroughChange", () => {
|
|
||||||
const range = { from: 5, to: 10 };
|
|
||||||
|
|
||||||
it("RELEASES on a strict intersection (edit inside the guarded range)", () => {
|
|
||||||
// change straddles the interior of the guard.
|
|
||||||
expect(
|
|
||||||
mapRangeThroughChange(range, { from: 6, oldTo: 8, newTo: 7 }),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT release on a boundary touch at the guard END", () => {
|
|
||||||
// Edit begins exactly at range.to (10): from < to is false -> no intersect.
|
|
||||||
expect(
|
|
||||||
mapRangeThroughChange(range, { from: 10, oldTo: 10, newTo: 12 }),
|
|
||||||
).toEqual(range);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT release on a boundary touch at the guard START", () => {
|
|
||||||
// Edit ends exactly at range.from (5): oldTo > from is false -> no intersect;
|
|
||||||
// it is treated as a change fully before, shifting the guard.
|
|
||||||
expect(
|
|
||||||
mapRangeThroughChange(range, { from: 3, oldTo: 5, newTo: 8 }),
|
|
||||||
).toEqual({ from: 8, to: 13 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("SHIFTS the guard for a change fully before it", () => {
|
|
||||||
// Insert 2 chars entirely before the range (oldTo 3 <= from 5): +2 delta.
|
|
||||||
expect(
|
|
||||||
mapRangeThroughChange(range, { from: 2, oldTo: 3, newTo: 5 }),
|
|
||||||
).toEqual({ from: 7, to: 12 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves the guard untouched for a change fully after it", () => {
|
|
||||||
expect(
|
|
||||||
mapRangeThroughChange(range, { from: 12, oldTo: 14, newTo: 16 }),
|
|
||||||
).toBe(range);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("undo-guard arming (integration)", () => {
|
|
||||||
it("arms {from, to:newTo} on a LOCAL undo-replace (history meta)", () => {
|
|
||||||
// Undo of an em-dash substitution: "a—b" restored to "a--b" — the em-dash
|
|
||||||
// (pos 2..3) is REPLACED by "--", tagged with the history plugin's meta.
|
|
||||||
const editor = makeEditor("a—b");
|
|
||||||
const { state } = editor;
|
|
||||||
const tr = state.tr
|
|
||||||
.replaceWith(2, 3, state.schema.text("--"))
|
|
||||||
.setMeta("history$", { redo: false });
|
|
||||||
editor.view.dispatch(tr);
|
|
||||||
|
|
||||||
expect(editor.state.doc.textContent).toBe("a--b");
|
|
||||||
// from = diff start (2), to = newTo = end of the inserted "--" (4).
|
|
||||||
expect(undoGuardKey.getState(editor.state)).toEqual({ from: 2, to: 4 });
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT arm on a REMOTE change-origin replace (no undo meta)", () => {
|
|
||||||
// Same replace, but tagged only as a y-sync remote change: history/remote
|
|
||||||
// yes, undo/redo NO -> must not arm.
|
|
||||||
const editor = makeEditor("a—b");
|
|
||||||
const { state } = editor;
|
|
||||||
const tr = state.tr
|
|
||||||
.replaceWith(2, 3, state.schema.text("--"))
|
|
||||||
.setMeta(ySyncPluginKey, { isChangeOrigin: true });
|
|
||||||
editor.view.dispatch(tr);
|
|
||||||
|
|
||||||
expect(editor.state.doc.textContent).toBe("a--b");
|
|
||||||
expect(undoGuardKey.getState(editor.state)).toBeNull();
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT arm on an ordinary local edit", () => {
|
|
||||||
const editor = makeEditor("a—b");
|
|
||||||
editor.view.dispatch(
|
|
||||||
editor.state.tr.replaceWith(2, 3, editor.state.schema.text("--")),
|
|
||||||
);
|
|
||||||
expect(undoGuardKey.getState(editor.state)).toBeNull();
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("undo-guard release / shift (integration)", () => {
|
|
||||||
it("RELEASES when a later edit lands inside the guarded region", () => {
|
|
||||||
const editor = makeEditor("a—b");
|
|
||||||
editor.view.dispatch(
|
|
||||||
editor.state.tr
|
|
||||||
.replaceWith(2, 3, editor.state.schema.text("--"))
|
|
||||||
.setMeta("history$", { redo: false }),
|
|
||||||
);
|
|
||||||
const guard = undoGuardKey.getState(editor.state)!;
|
|
||||||
expect(guard).toEqual({ from: 2, to: 4 });
|
|
||||||
|
|
||||||
// Type a character inside the restored region -> guard is dropped.
|
|
||||||
editor.view.dispatch(editor.state.tr.insertText("x", guard.from + 1));
|
|
||||||
expect(undoGuardKey.getState(editor.state)).toBeNull();
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps and SHIFTS the guard when a later edit lands before it", () => {
|
|
||||||
const editor = makeEditor("zz a—b");
|
|
||||||
// "zz a—b": em-dash at pos 5; replace the 'a' at 4..5 with "--" to arm.
|
|
||||||
editor.view.dispatch(
|
|
||||||
editor.state.tr
|
|
||||||
.replaceWith(4, 5, editor.state.schema.text("--"))
|
|
||||||
.setMeta("history$", { redo: false }),
|
|
||||||
);
|
|
||||||
const guard = undoGuardKey.getState(editor.state)!;
|
|
||||||
expect(guard).toEqual({ from: 4, to: 6 });
|
|
||||||
|
|
||||||
// Insert one char at the very start (before the guard) -> guard shifts +1.
|
|
||||||
editor.view.dispatch(editor.state.tr.insertText("Q", 1));
|
|
||||||
expect(undoGuardKey.getState(editor.state)).toEqual({ from: 5, to: 7 });
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
import { InputRule } from "@tiptap/core";
|
|
||||||
import {
|
|
||||||
Plugin,
|
|
||||||
PluginKey,
|
|
||||||
type EditorState,
|
|
||||||
type Transaction,
|
|
||||||
} from "@tiptap/pm/state";
|
|
||||||
import { Typography } from "@tiptap/extension-typography";
|
|
||||||
import { isChangeOrigin } from "@tiptap/extension-collaboration";
|
|
||||||
import { ySyncPluginKey } from "@tiptap/y-tiptap";
|
|
||||||
|
|
||||||
// Region restored by the latest undo — while it is intact, typography
|
|
||||||
// input rules overlapping it must not fire again.
|
|
||||||
interface UndoGuardRange {
|
|
||||||
from: number;
|
|
||||||
to: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exported for tests: the plugin key lets a test read the armed guard state,
|
|
||||||
// and the two pure helpers below are unit-tested directly.
|
|
||||||
export const undoGuardKey = new PluginKey<UndoGuardRange | null>(
|
|
||||||
"typographyUndoGuard",
|
|
||||||
);
|
|
||||||
|
|
||||||
// prosemirror-history does not export its plugin key, so template-editor
|
|
||||||
// undo/redo is detected via the stable stringified key. Only one
|
|
||||||
// PluginKey("history") exists in the dependency tree, so "history$" is stable.
|
|
||||||
const HISTORY_META = "history$";
|
|
||||||
|
|
||||||
const isUndoRedoTransaction = (tr: Transaction): boolean => {
|
|
||||||
if (tr.getMeta(HISTORY_META)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Read yjs undo/redo meta via the real ySyncPluginKey object (imported, not
|
|
||||||
// a fragile stringified key), which y-tiptap sets on Y.UndoManager changes.
|
|
||||||
const ySyncMeta = tr.getMeta(ySyncPluginKey) as
|
|
||||||
| { isUndoRedoOperation?: boolean }
|
|
||||||
| undefined;
|
|
||||||
return !!ySyncMeta?.isUndoRedoOperation;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface DocChange {
|
|
||||||
from: number;
|
|
||||||
oldTo: number;
|
|
||||||
newTo: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute the minimal changed region between two docs. yjs undo/redo (and any
|
|
||||||
// remote change) arrives as a whole-document replace step, so the transaction
|
|
||||||
// step maps are useless — diff the docs to recover the real minimal change.
|
|
||||||
// Returns null when the docs are identical.
|
|
||||||
export const findChangedRange = (
|
|
||||||
oldState: EditorState,
|
|
||||||
newState: EditorState,
|
|
||||||
): DocChange | null => {
|
|
||||||
const start = oldState.doc.content.findDiffStart(newState.doc.content);
|
|
||||||
const end = oldState.doc.content.findDiffEnd(newState.doc.content);
|
|
||||||
if (start == null || end == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
let { a: oldTo, b: newTo } = end;
|
|
||||||
// findDiffEnd can report an end BEFORE the diff start when the changed text
|
|
||||||
// abuts repeated content (insertion -> oldTo<start, deletion -> newTo<start).
|
|
||||||
// Push both ends forward by the same delta so the range stays non-degenerate
|
|
||||||
// (from <= oldTo and from <= newTo), matching ProseMirror's own diff bounds.
|
|
||||||
const minTo = Math.min(oldTo, newTo);
|
|
||||||
if (minTo < start) {
|
|
||||||
const delta = start - minTo;
|
|
||||||
oldTo += delta;
|
|
||||||
newTo += delta;
|
|
||||||
}
|
|
||||||
return { from: start, oldTo, newTo };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Map an armed guard range across a single document change described by a diff.
|
|
||||||
// Returns null when the change touches the guarded text itself (the restored
|
|
||||||
// substitution was edited, so the guard must be released).
|
|
||||||
export const mapRangeThroughChange = (
|
|
||||||
range: UndoGuardRange,
|
|
||||||
change: DocChange,
|
|
||||||
): UndoGuardRange | null => {
|
|
||||||
// Strict intersection: an edit exactly at a guard boundary (e.g. the user
|
|
||||||
// typing the suppressed space right after the restored text, or deleting it)
|
|
||||||
// must NOT drop the guard.
|
|
||||||
if (change.from < range.to && change.oldTo > range.from) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// Change fully before the guard: shift the guard by the length delta.
|
|
||||||
if (change.oldTo <= range.from) {
|
|
||||||
const delta = change.newTo - change.oldTo;
|
|
||||||
return { from: range.from + delta, to: range.to + delta };
|
|
||||||
}
|
|
||||||
// Change fully after the guard: positions are unaffected.
|
|
||||||
return range;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Detect history/remote transactions that may arrive as a whole-document
|
|
||||||
// replace step: prosemirror-history undo/redo, or any yjs remote-origin change
|
|
||||||
// (isChangeOrigin is the canonical predicate already used across the app).
|
|
||||||
const isHistoryOrRemoteTransaction = (tr: Transaction): boolean =>
|
|
||||||
!!tr.getMeta(HISTORY_META) || isChangeOrigin(tr);
|
|
||||||
|
|
||||||
export const CustomTypography = Typography.extend({
|
|
||||||
addProseMirrorPlugins() {
|
|
||||||
return [
|
|
||||||
...(this.parent?.() ?? []),
|
|
||||||
new Plugin({
|
|
||||||
key: undoGuardKey,
|
|
||||||
state: {
|
|
||||||
init: () => null,
|
|
||||||
apply(tr, prev, oldState, newState): UndoGuardRange | null {
|
|
||||||
if (tr.docChanged && isHistoryOrRemoteTransaction(tr)) {
|
|
||||||
const change = findChangedRange(oldState, newState);
|
|
||||||
if (change == null) {
|
|
||||||
// Attribute-only or otherwise content-neutral change: keep the
|
|
||||||
// guard.
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
// Arm the guard only when the LOCAL user's undo/redo REPLACED text
|
|
||||||
// (deleted + inserted) — the signature of reverting an input-rule
|
|
||||||
// substitution. Pure insertions/deletions and remote peer edits
|
|
||||||
// must not arm it.
|
|
||||||
if (
|
|
||||||
isUndoRedoTransaction(tr) &&
|
|
||||||
change.oldTo > change.from &&
|
|
||||||
change.newTo > change.from
|
|
||||||
) {
|
|
||||||
return { from: change.from, to: change.newTo };
|
|
||||||
}
|
|
||||||
// Non-arming history/remote change: map the existing guard through
|
|
||||||
// the real diff instead of the (whole-document) step map.
|
|
||||||
if (!prev) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return mapRangeThroughChange(prev, change);
|
|
||||||
}
|
|
||||||
if (!prev) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!tr.docChanged) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
// Ordinary local edit: minimal step maps are accurate and cheap.
|
|
||||||
let range: UndoGuardRange | null = prev;
|
|
||||||
for (const stepMap of tr.mapping.maps) {
|
|
||||||
const { from: rangeFrom, to: rangeTo } = range;
|
|
||||||
let touched = false;
|
|
||||||
stepMap.forEach((fromA, toA) => {
|
|
||||||
if (fromA < rangeTo && toA > rangeFrom) {
|
|
||||||
touched = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (touched) {
|
|
||||||
range = null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
range = {
|
|
||||||
from: stepMap.map(rangeFrom, 1),
|
|
||||||
to: stepMap.map(rangeTo, -1),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return range && range.to > range.from ? range : null;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
addInputRules() {
|
|
||||||
// Wrap every typography rule: skip it when its match overlaps the text
|
|
||||||
// just restored by undo, so an undone substitution is not re-applied.
|
|
||||||
return (this.parent?.() ?? []).map(
|
|
||||||
(rule) =>
|
|
||||||
new InputRule({
|
|
||||||
find: rule.find,
|
|
||||||
undoable: rule.undoable,
|
|
||||||
handler: (props) => {
|
|
||||||
const guard = undoGuardKey.getState(props.state);
|
|
||||||
if (
|
|
||||||
guard &&
|
|
||||||
props.range.from < guard.to &&
|
|
||||||
props.range.to > guard.from
|
|
||||||
) {
|
|
||||||
// Returning null skips this rule and lets the typed character
|
|
||||||
// be inserted as plain text.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return rule.handler(props);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -6,7 +6,7 @@ import { TaskList, TaskItem } from "@tiptap/extension-list";
|
|||||||
import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions";
|
import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions";
|
||||||
import { Superscript } from "@tiptap/extension-superscript";
|
import { Superscript } from "@tiptap/extension-superscript";
|
||||||
import SubScript from "@tiptap/extension-subscript";
|
import SubScript from "@tiptap/extension-subscript";
|
||||||
import { CustomTypography } from "./custom-typography";
|
import { Typography } from "@tiptap/extension-typography";
|
||||||
import { TextStyle } from "@tiptap/extension-text-style";
|
import { TextStyle } from "@tiptap/extension-text-style";
|
||||||
import { Color } from "@tiptap/extension-color";
|
import { Color } from "@tiptap/extension-color";
|
||||||
import { Youtube } from "@tiptap/extension-youtube";
|
import { Youtube } from "@tiptap/extension-youtube";
|
||||||
@@ -245,9 +245,7 @@ export const mainExtensions = [
|
|||||||
return ReactMarkViewRenderer(SpoilerView);
|
return ReactMarkViewRenderer(SpoilerView);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
// Typography with an undo guard: does not re-apply a substitution the user
|
Typography,
|
||||||
// just undid (e.g. Ctrl+Z on "1/2" -> "½" followed by another space).
|
|
||||||
CustomTypography,
|
|
||||||
TrailingNode,
|
TrailingNode,
|
||||||
GlobalDragHandle.configure({
|
GlobalDragHandle.configure({
|
||||||
customNodes: ["transclusionSource", "transclusionReference", "pageEmbed"],
|
customNodes: ["transclusionSource", "transclusionReference", "pageEmbed"],
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
import { normalizeTableColumnWidths } from "./markdown-clipboard";
|
||||||
import {
|
|
||||||
normalizeTableColumnWidths,
|
|
||||||
classifyClipboardSelection,
|
|
||||||
} from "./markdown-clipboard";
|
|
||||||
|
|
||||||
// normalizeTableColumnWidths mutates a DOM subtree (jsdom provides document).
|
// normalizeTableColumnWidths mutates a DOM subtree (jsdom provides document).
|
||||||
function root(html: string): HTMLElement {
|
function root(html: string): HTMLElement {
|
||||||
@@ -128,171 +124,3 @@ describe("normalizeTableColumnWidths", () => {
|
|||||||
).toEqual([null, null]);
|
).toEqual([null, null]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("classifyClipboardSelection", () => {
|
|
||||||
it("serializes a list of 2+ items as markdown", () => {
|
|
||||||
expect(
|
|
||||||
classifyClipboardSelection([{ name: "bulletList", childCount: 2 }]),
|
|
||||||
).toEqual({ asMarkdown: true, wrapBareRows: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves a single-item list as plain text", () => {
|
|
||||||
expect(
|
|
||||||
classifyClipboardSelection([{ name: "bulletList", childCount: 1 }]),
|
|
||||||
).toEqual({ asMarkdown: false, wrapBareRows: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serializes a whole table without wrapping bare rows", () => {
|
|
||||||
expect(
|
|
||||||
classifyClipboardSelection([{ name: "table", childCount: 3 }]),
|
|
||||||
).toEqual({ asMarkdown: true, wrapBareRows: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serializes a partial cell selection (bare rows) and flags wrapping", () => {
|
|
||||||
expect(
|
|
||||||
classifyClipboardSelection([
|
|
||||||
{ name: "tableRow", childCount: 2 },
|
|
||||||
{ name: "tableRow", childCount: 2 },
|
|
||||||
]),
|
|
||||||
).toEqual({ asMarkdown: true, wrapBareRows: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves plain paragraphs as plain text", () => {
|
|
||||||
expect(
|
|
||||||
classifyClipboardSelection([{ name: "paragraph", childCount: 1 }]),
|
|
||||||
).toEqual({ asMarkdown: false, wrapBareRows: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not wrap when rows are mixed with other block types", () => {
|
|
||||||
expect(
|
|
||||||
classifyClipboardSelection([
|
|
||||||
{ name: "tableRow", childCount: 2 },
|
|
||||||
{ name: "paragraph", childCount: 1 },
|
|
||||||
]),
|
|
||||||
).toEqual({ asMarkdown: false, wrapBareRows: false });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Output-level tests for the table clipboard regression: copying a table must
|
|
||||||
// yield a real GFM pipe table, NOT one-value-per-line concatenated cells.
|
|
||||||
// These exercise the actual markdown produced by htmlToMarkdown (the same
|
|
||||||
// serializer step the clipboardTextSerializer runs), so they pin the OUTPUT
|
|
||||||
// shape that the classifier-flag tests above do not cover.
|
|
||||||
describe("table clipboard markdown output (htmlToMarkdown)", () => {
|
|
||||||
// Trim each line and drop blanks so structural assertions are whitespace-robust.
|
|
||||||
function lines(md: string): string[] {
|
|
||||||
return md
|
|
||||||
.split("\n")
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter((l) => l.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A GFM separator row like "| --- | --- |" (any number of columns), tolerant
|
|
||||||
// of the padding turndown emits.
|
|
||||||
function isSeparatorRow(line: string): boolean {
|
|
||||||
const compact = line.replace(/\s+/g, "");
|
|
||||||
return /^\|(?:-{3,}\|)+$/.test(compact);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split a pipe-delimited row into trimmed cell values.
|
|
||||||
function cells(line: string): string[] {
|
|
||||||
return line
|
|
||||||
.replace(/^\|/, "")
|
|
||||||
.replace(/\|$/, "")
|
|
||||||
.split("|")
|
|
||||||
.map((c) => c.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
it("serializes a header-less partial cell selection (bare rows) as a valid GFM pipe table", () => {
|
|
||||||
// Mirror the serializer's `wrapBareRows` branch exactly: bare <tr> nodes are
|
|
||||||
// wrapped in <table><tbody> and htmlToMarkdown(div.innerHTML) is called.
|
|
||||||
// See markdown-clipboard.ts clipboardTextSerializer:
|
|
||||||
// const table = document.createElement("table");
|
|
||||||
// const tbody = document.createElement("tbody");
|
|
||||||
// tbody.appendChild(fragment); table.appendChild(tbody);
|
|
||||||
// div.appendChild(table);
|
|
||||||
// return htmlToMarkdown(div.innerHTML);
|
|
||||||
const div = document.createElement("div");
|
|
||||||
const table = document.createElement("table");
|
|
||||||
const tbody = document.createElement("tbody");
|
|
||||||
for (const [c1, c2] of [
|
|
||||||
["a", "b"],
|
|
||||||
["c", "d"],
|
|
||||||
]) {
|
|
||||||
const tr = document.createElement("tr");
|
|
||||||
const td1 = document.createElement("td");
|
|
||||||
td1.textContent = c1;
|
|
||||||
const td2 = document.createElement("td");
|
|
||||||
td2.textContent = c2;
|
|
||||||
tr.appendChild(td1);
|
|
||||||
tr.appendChild(td2);
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
}
|
|
||||||
table.appendChild(tbody);
|
|
||||||
div.appendChild(table);
|
|
||||||
|
|
||||||
const md = htmlToMarkdown(div.innerHTML);
|
|
||||||
const ls = lines(md);
|
|
||||||
|
|
||||||
// Valid GFM: a header/data separator row is present (an empty header is
|
|
||||||
// synthesized by the GFM turndown plugin for a header-less table — fine).
|
|
||||||
expect(ls.some(isSeparatorRow)).toBe(true);
|
|
||||||
// NOT the old broken "one value per line" shape: every line is pipe-delimited
|
|
||||||
// and no line is a bare cell value on its own.
|
|
||||||
expect(ls.every((l) => l.includes("|"))).toBe(true);
|
|
||||||
expect(md).not.toMatch(/^\s*(a|b|c|d)\s*$/m);
|
|
||||||
// The cell values land in real pipe-delimited data rows.
|
|
||||||
const dataRows = ls.filter((l) => !isSeparatorRow(l)).map(cells);
|
|
||||||
expect(dataRows).toContainEqual(["a", "b"]);
|
|
||||||
expect(dataRows).toContainEqual(["c", "d"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serializes a whole table with a header row as a proper GFM table (headline regression)", () => {
|
|
||||||
// Mirror the serializer's non-wrap branch: the full <table> node is appended
|
|
||||||
// directly (div.appendChild(fragment)) and htmlToMarkdown(div.innerHTML) runs.
|
|
||||||
const div = document.createElement("div");
|
|
||||||
const table = document.createElement("table");
|
|
||||||
|
|
||||||
const thead = document.createElement("thead");
|
|
||||||
const headerRow = document.createElement("tr");
|
|
||||||
for (const h of ["Name", "Age"]) {
|
|
||||||
const th = document.createElement("th");
|
|
||||||
th.textContent = h;
|
|
||||||
headerRow.appendChild(th);
|
|
||||||
}
|
|
||||||
thead.appendChild(headerRow);
|
|
||||||
table.appendChild(thead);
|
|
||||||
|
|
||||||
const tbody = document.createElement("tbody");
|
|
||||||
for (const [name, age] of [
|
|
||||||
["Alice", "30"],
|
|
||||||
["Bob", "25"],
|
|
||||||
]) {
|
|
||||||
const tr = document.createElement("tr");
|
|
||||||
const td1 = document.createElement("td");
|
|
||||||
td1.textContent = name;
|
|
||||||
const td2 = document.createElement("td");
|
|
||||||
td2.textContent = age;
|
|
||||||
tr.appendChild(td1);
|
|
||||||
tr.appendChild(td2);
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
}
|
|
||||||
table.appendChild(tbody);
|
|
||||||
div.appendChild(table);
|
|
||||||
|
|
||||||
const md = htmlToMarkdown(div.innerHTML);
|
|
||||||
const ls = lines(md);
|
|
||||||
|
|
||||||
// Proper GFM structure: separator row + all rows pipe-delimited.
|
|
||||||
expect(ls.some(isSeparatorRow)).toBe(true);
|
|
||||||
expect(ls.every((l) => l.includes("|"))).toBe(true);
|
|
||||||
|
|
||||||
const rows = ls.filter((l) => !isSeparatorRow(l)).map(cells);
|
|
||||||
// Header row comes first, followed by both data rows.
|
|
||||||
expect(rows[0]).toEqual(["Name", "Age"]);
|
|
||||||
expect(rows).toContainEqual(["Alice", "30"]);
|
|
||||||
expect(rows).toContainEqual(["Bob", "25"]);
|
|
||||||
// Headline regression: the table is NOT concatenated one-value-per-line.
|
|
||||||
expect(md).not.toMatch(/^\s*(Name|Age|Alice|Bob|30|25)\s*$/m);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -27,36 +27,24 @@ export const MarkdownClipboard = Extension.create({
|
|||||||
key: new PluginKey("markdownClipboard"),
|
key: new PluginKey("markdownClipboard"),
|
||||||
props: {
|
props: {
|
||||||
clipboardTextSerializer: (slice) => {
|
clipboardTextSerializer: (slice) => {
|
||||||
const topLevelNodes: { name: string; childCount: number }[] = [];
|
const listTypes = ["bulletList", "orderedList", "taskList"];
|
||||||
|
let topLevelCount = 0;
|
||||||
|
let hasList = false;
|
||||||
slice.content.forEach((node) => {
|
slice.content.forEach((node) => {
|
||||||
topLevelNodes.push({
|
if (listTypes.includes(node.type.name)) {
|
||||||
name: node.type.name,
|
hasList = true;
|
||||||
childCount: node.childCount,
|
topLevelCount += node.childCount;
|
||||||
});
|
} else {
|
||||||
|
topLevelCount++;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const { asMarkdown, wrapBareRows } =
|
if (!hasList || topLevelCount < 2) return null;
|
||||||
classifyClipboardSelection(topLevelNodes);
|
|
||||||
if (!asMarkdown) return null;
|
|
||||||
|
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
const serializer = DOMSerializer.fromSchema(this.editor.schema);
|
const serializer = DOMSerializer.fromSchema(this.editor.schema);
|
||||||
const fragment = serializer.serializeFragment(slice.content);
|
const fragment = serializer.serializeFragment(slice.content);
|
||||||
|
div.appendChild(fragment);
|
||||||
if (wrapBareRows) {
|
|
||||||
// A partial table cell-selection serializes to bare <tr> nodes
|
|
||||||
// (prosemirror-tables returns the whole `table` node only when the
|
|
||||||
// entire table is selected). Bare <tr> would be foster-parented
|
|
||||||
// away by the HTML parser inside htmlToMarkdown, so wrap them in
|
|
||||||
// <table><tbody> first for the GFM turndown rule to detect them.
|
|
||||||
const table = document.createElement("table");
|
|
||||||
const tbody = document.createElement("tbody");
|
|
||||||
tbody.appendChild(fragment);
|
|
||||||
table.appendChild(tbody);
|
|
||||||
div.appendChild(table);
|
|
||||||
} else {
|
|
||||||
div.appendChild(fragment);
|
|
||||||
}
|
|
||||||
return htmlToMarkdown(div.innerHTML);
|
return htmlToMarkdown(div.innerHTML);
|
||||||
},
|
},
|
||||||
handlePaste: (view, event, slice) => {
|
handlePaste: (view, event, slice) => {
|
||||||
@@ -165,55 +153,6 @@ export const MarkdownClipboard = Extension.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Decide whether a copied slice's plain-text clipboard payload should be
|
|
||||||
* serialized as Markdown (instead of ProseMirror's default text serializer,
|
|
||||||
* which joins block leaves with newlines — the "one value per line" bug for
|
|
||||||
* tables).
|
|
||||||
*
|
|
||||||
* Serialize as Markdown for structured content:
|
|
||||||
* - lists with 2+ total items (a single copied bullet stays literal text);
|
|
||||||
* - a whole table (top-level `table` node);
|
|
||||||
* - a partial table cell-selection, which prosemirror-tables copies as bare
|
|
||||||
* `tableRow` nodes (only a full-table selection yields a `table` node).
|
|
||||||
*
|
|
||||||
* `wrapBareRows` flags the bare-rows case so the caller wraps the serialized
|
|
||||||
* <tr> nodes in <table><tbody> before the HTML->Markdown step. Plain paragraphs
|
|
||||||
* return asMarkdown=false so a simple text copy stays literal, and internal
|
|
||||||
* copy/paste keeps using the richer text/html clipboard payload.
|
|
||||||
*/
|
|
||||||
export function classifyClipboardSelection(
|
|
||||||
nodes: { name: string; childCount: number }[],
|
|
||||||
): { asMarkdown: boolean; wrapBareRows: boolean } {
|
|
||||||
const listTypes = ["bulletList", "orderedList", "taskList"];
|
|
||||||
let topLevelCount = 0;
|
|
||||||
let hasList = false;
|
|
||||||
let hasTable = false;
|
|
||||||
let tableRowCount = 0;
|
|
||||||
let nonRowCount = 0;
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (listTypes.includes(node.name)) {
|
|
||||||
hasList = true;
|
|
||||||
topLevelCount += node.childCount;
|
|
||||||
nonRowCount++;
|
|
||||||
} else {
|
|
||||||
if (node.name === "table") hasTable = true;
|
|
||||||
if (node.name === "tableRow") tableRowCount++;
|
|
||||||
else nonRowCount++;
|
|
||||||
topLevelCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bare tableRow nodes at the top level only occur for a partial cell
|
|
||||||
// selection; a slice never mixes bare rows with other block types, so
|
|
||||||
// "every top-level node is a row" is a safe signal to wrap-and-serialize.
|
|
||||||
const wrapBareRows = tableRowCount > 0 && nonRowCount === 0;
|
|
||||||
const asMarkdown =
|
|
||||||
(hasList && topLevelCount >= 2) || hasTable || wrapBareRows;
|
|
||||||
return { asMarkdown, wrapBareRows };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reorder/dedup the footnotes of a SELF-CONTAINED pasted markdown block to the
|
* Reorder/dedup the footnotes of a SELF-CONTAINED pasted markdown block to the
|
||||||
* canonical invariant (the live footnoteSyncPlugin never reorders an existing
|
* canonical invariant (the live footnoteSyncPlugin never reorders an existing
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ describe("useScrollPosition", () => {
|
|||||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("(a3) restores at most once per mount even if called again", () => {
|
it("(a3) is idempotent: re-asserting the same target does not scroll again", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
window.sessionStorage.setItem(`${KEY_PREFIX}once`, "500");
|
window.sessionStorage.setItem(`${KEY_PREFIX}once`, "500");
|
||||||
setScrollHeight(2000); // tall enough to restore synchronously
|
setScrollHeight(2000); // tall enough to restore synchronously
|
||||||
@@ -111,8 +111,12 @@ describe("useScrollPosition", () => {
|
|||||||
});
|
});
|
||||||
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Simulate the browser now being at the restored position.
|
||||||
|
setScrollY(500);
|
||||||
|
|
||||||
// A second call (e.g. the wiring effect re-running on [showStatic, editor,
|
// A second call (e.g. the wiring effect re-running on [showStatic, editor,
|
||||||
// restoreScrollPosition]) must NOT scroll again and yank the reader.
|
// restoreScrollPosition]) must NOT scroll again: the redundancy guard sees
|
||||||
|
// the window is already at the target and does nothing.
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.restoreScrollPosition();
|
result.current.restoreScrollPosition();
|
||||||
});
|
});
|
||||||
@@ -162,6 +166,84 @@ describe("useScrollPosition", () => {
|
|||||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("(g) does not restore if the reader scrolled (wheel) before restore fires", () => {
|
||||||
|
window.sessionStorage.setItem(`${KEY_PREFIX}g1`, "500");
|
||||||
|
setScrollHeight(2000); // tall enough to restore synchronously
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useScrollPosition("g1"));
|
||||||
|
|
||||||
|
// The reader shows scroll intent before restore is triggered.
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("wheel"));
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
result.current.restoreScrollPosition();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("(h) aborts an in-flight restore poll when the reader scrolls", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
window.sessionStorage.setItem(`${KEY_PREFIX}h1`, "500");
|
||||||
|
setInnerHeight(800);
|
||||||
|
setScrollHeight(100); // maxScroll = -700: target not reachable yet, so it polls.
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useScrollPosition("h1"));
|
||||||
|
act(() => {
|
||||||
|
result.current.restoreScrollPosition();
|
||||||
|
});
|
||||||
|
expect(window.scrollTo).not.toHaveBeenCalled(); // still polling
|
||||||
|
|
||||||
|
// The reader takes over mid-poll: this cancels the in-flight poll.
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("wheel"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Content of the page grows tall enough and time passes: the cancelled poll
|
||||||
|
// must NOT resurrect and yank the reader.
|
||||||
|
setScrollHeight(2000);
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
});
|
||||||
|
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("(i) a non-scroll keydown does NOT abort restore", () => {
|
||||||
|
window.sessionStorage.setItem(`${KEY_PREFIX}i1`, "500");
|
||||||
|
setScrollHeight(2000); // tall enough to restore synchronously
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useScrollPosition("i1"));
|
||||||
|
|
||||||
|
// A non-scroll key (e.g. typing, a shortcut) must NOT count as scroll intent.
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: "a" }));
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
result.current.restoreScrollPosition();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore still happens: the innocuous keypress did not disable it.
|
||||||
|
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("(j) a scroll keydown (Space) DOES abort restore", () => {
|
||||||
|
window.sessionStorage.setItem(`${KEY_PREFIX}j1`, "500");
|
||||||
|
setScrollHeight(2000); // tall enough to restore synchronously
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useScrollPosition("j1"));
|
||||||
|
|
||||||
|
// Space scrolls the page: this is real scroll intent and must abort restore.
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: " " }));
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
result.current.restoreScrollPosition();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("(c) does nothing when nothing is saved or the saved value is <= 0", () => {
|
it("(c) does nothing when nothing is saved or the saved value is <= 0", () => {
|
||||||
// Nothing saved.
|
// Nothing saved.
|
||||||
const a = renderHook(() => useScrollPosition("nope"));
|
const a = renderHook(() => useScrollPosition("nope"));
|
||||||
@@ -221,6 +303,55 @@ describe("useScrollPosition", () => {
|
|||||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 200, behavior: "auto" });
|
expect(window.scrollTo).toHaveBeenCalledWith({ top: 200, behavior: "auto" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("(k) shares ONE timeout budget across re-triggers (does not restart the clock)", () => {
|
||||||
|
// The static->live editor swap re-invokes restore. The shared budget
|
||||||
|
// (restoreStartRef) must measure the MAX_RESTORE_WAIT_MS (5000) deadline
|
||||||
|
// from the FIRST trigger, not restart it on every re-trigger. This pins
|
||||||
|
// the `if (restoreStartRef.current === null)` guard: a mutant that resets
|
||||||
|
// `restoreStartRef.current = Date.now()` on every trigger would push the
|
||||||
|
// deadline out to t=8000 (3000 + 5000) and fail the t=5000 assertion below.
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(0);
|
||||||
|
window.sessionStorage.setItem(`${KEY_PREFIX}k1`, "5000");
|
||||||
|
setInnerHeight(800);
|
||||||
|
setScrollHeight(1000); // maxScroll = 200, never reaches 5000 -> it polls.
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useScrollPosition("k1"));
|
||||||
|
|
||||||
|
// First trigger at t=0: starts the shared budget and begins polling.
|
||||||
|
act(() => {
|
||||||
|
result.current.restoreScrollPosition();
|
||||||
|
});
|
||||||
|
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Advance to t=3000 (still polling: content short, not yet timed out).
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
});
|
||||||
|
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Second trigger at t=3000 (the swap re-assert). Under the real code the
|
||||||
|
// budget is shared, so `start` stays 0; under the reset-mutant it becomes 3000.
|
||||||
|
act(() => {
|
||||||
|
result.current.restoreScrollPosition();
|
||||||
|
});
|
||||||
|
|
||||||
|
// At t=4900 the FIRST budget has not yet elapsed (4900 - 0 < 5000): no clamp.
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1900);
|
||||||
|
});
|
||||||
|
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// At t=5000 the shared budget (measured from t=0) times out and clamps to the
|
||||||
|
// furthest reachable position (maxScroll = 200). The reset-mutant, measuring
|
||||||
|
// from t=3000, would still be waiting (5000 - 3000 = 2000 < 5000) and would
|
||||||
|
// NOT have scrolled here -> this assertion fails against that mutant.
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
expect(window.scrollTo).toHaveBeenCalledWith({ top: 200, behavior: "auto" });
|
||||||
|
});
|
||||||
|
|
||||||
it("(e) never throws when storage access throws", () => {
|
it("(e) never throws when storage access throws", () => {
|
||||||
const err = new Error("storage denied");
|
const err = new Error("storage denied");
|
||||||
vi.spyOn(window.sessionStorage, "getItem").mockImplementation(() => {
|
vi.spyOn(window.sessionStorage, "getItem").mockImplementation(() => {
|
||||||
|
|||||||
@@ -13,6 +13,18 @@ const RESTORE_POLL_MS = 100;
|
|||||||
// "remember where I was reading" feature (self-limiting, no cross-tab leak).
|
// "remember where I was reading" feature (self-limiting, no cross-tab leak).
|
||||||
const STORAGE_PREFIX = "gitmost:scroll-position:";
|
const STORAGE_PREFIX = "gitmost:scroll-position:";
|
||||||
|
|
||||||
|
// Keys that scroll the window. Only these count as scroll intent for keydown;
|
||||||
|
// other keys (shortcuts, modifiers, typing) must NOT disable scroll restore.
|
||||||
|
const SCROLL_KEYS = new Set([
|
||||||
|
"ArrowUp",
|
||||||
|
"ArrowDown",
|
||||||
|
"PageUp",
|
||||||
|
"PageDown",
|
||||||
|
"Home",
|
||||||
|
"End",
|
||||||
|
" ", // Space (and Shift+Space) scroll the page
|
||||||
|
]);
|
||||||
|
|
||||||
function storageKey(pageId: string): string {
|
function storageKey(pageId: string): string {
|
||||||
return `${STORAGE_PREFIX}${pageId}`;
|
return `${STORAGE_PREFIX}${pageId}`;
|
||||||
}
|
}
|
||||||
@@ -48,32 +60,41 @@ function writeStorage(pageId: string, scrollY: number): void {
|
|||||||
* Persists and restores the window scroll position per page so a reader keeps
|
* Persists and restores the window scroll position per page so a reader keeps
|
||||||
* their place across a reload (F5) or reopening the document.
|
* their place across a reload (F5) or reopening the document.
|
||||||
*
|
*
|
||||||
* Returns `restoreScrollPosition`, which the page editor calls once the live
|
* Returns `restoreScrollPosition`, which the page editor calls from two triggers
|
||||||
* (non-static) content is laid out. The two scroll mechanisms are mutually
|
* (early, while the static/cached content is laid out, and again after the
|
||||||
* exclusive: if the URL has a `#hash` anchor, the existing anchor-scroll logic
|
* static->live editor swap); it is idempotent, so re-asserting the same target is
|
||||||
* wins and restore is a no-op.
|
* a no-op. The two scroll mechanisms are mutually exclusive: if the URL has a
|
||||||
|
* `#hash` anchor, the existing anchor-scroll logic wins and restore is a no-op.
|
||||||
*/
|
*/
|
||||||
export function useScrollPosition(pageId: string): {
|
export function useScrollPosition(pageId: string): {
|
||||||
restoreScrollPosition: () => void;
|
restoreScrollPosition: () => void;
|
||||||
} {
|
} {
|
||||||
// CONTRACT: this hook assumes PageEditor REMOUNTS per page — page.tsx renders
|
// CONTRACT: this hook assumes PageEditor REMOUNTS per page — page.tsx renders
|
||||||
// `<MemoizedFullEditor key={page.id} ...>`, so switching pages creates a fresh
|
// `<MemoizedFullEditor key={page.id} ...>`, so switching pages creates a fresh
|
||||||
// hook instance with fresh refs. These refs latch per-mount and are NOT reset
|
// hook instance with fresh refs. Restore is idempotent and interaction-gated
|
||||||
// when `pageId` changes in place (only the effect re-runs on [pageId]). If that
|
// (not single-shot): it may be called from several triggers and re-asserts the
|
||||||
// `key={page.id}` is ever removed, restore would silently break on the 2nd page
|
// SAME captured target, which is a no-op once the window is already positioned.
|
||||||
// (refs would hold the first page's target / already-restored flag) — in that
|
// The per-mount refs that latch are `initialTargetRef` (the captured target)
|
||||||
// case the refs must be reset on a pageId change.
|
// and `userInteractedRef` (the reader has taken over scrolling). They are NOT
|
||||||
|
// reset when `pageId` changes in place (only the effect re-runs on [pageId]).
|
||||||
|
// If that `key={page.id}` is ever removed, restore would silently break on the
|
||||||
|
// 2nd page (refs would hold the first page's target / interaction flag) — in
|
||||||
|
// that case the refs must be reset on a pageId change.
|
||||||
//
|
//
|
||||||
// The target Y captured synchronously at mount, BEFORE any scroll/visibility
|
// The target Y captured synchronously at mount, BEFORE any scroll/visibility
|
||||||
// handler can overwrite the stored value with a fresh 0 (the page starts
|
// handler can overwrite the stored value with a fresh 0 (the page starts
|
||||||
// scrolled to top on load). `null` means "not yet captured".
|
// scrolled to top on load). `null` means "not yet captured".
|
||||||
const initialTargetRef = useRef<number | null>(null);
|
const initialTargetRef = useRef<number | null>(null);
|
||||||
// Guards so restore runs at most once per page mount.
|
// Set once the reader shows unambiguous scroll intent; restore must never yank
|
||||||
const hasRestoredRef = useRef(false);
|
// a reader who has already started scrolling.
|
||||||
|
const userInteractedRef = useRef(false);
|
||||||
// Holds the in-flight restore poll timer so the cleanup can cancel it: without
|
// Holds the in-flight restore poll timer so the cleanup can cancel it: without
|
||||||
// this, a fast SPA navigation away mid-poll would let the old page's poll fire
|
// this, a fast SPA navigation away mid-poll would let the old page's poll fire
|
||||||
// window.scrollTo against the NEW page's document (visible wrong-page scroll).
|
// window.scrollTo against the NEW page's document (visible wrong-page scroll).
|
||||||
const pollTimerRef = useRef<number | null>(null);
|
const pollTimerRef = useRef<number | null>(null);
|
||||||
|
// Timestamp of the FIRST restore attempt so re-triggers (e.g. the static→live
|
||||||
|
// editor swap) share ONE bounded timeout budget instead of restarting it.
|
||||||
|
const restoreStartRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// Capture the previously-saved value synchronously during render, before the
|
// Capture the previously-saved value synchronously during render, before the
|
||||||
// effect below registers handlers that would persist the current (0) scrollY.
|
// effect below registers handlers that would persist the current (0) scrollY.
|
||||||
@@ -114,14 +135,43 @@ export function useScrollPosition(pageId: string): {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// User scroll-intent signals. wheel and touch are unconditional scroll
|
||||||
|
// intent; keydown is filtered to actual scroll keys only (SCROLL_KEYS) so
|
||||||
|
// shortcuts, lone modifiers, and typing do not abort restore. Our own
|
||||||
|
// window.scrollTo does NOT emit these, so restore can never self-abort via
|
||||||
|
// them. Once the reader shows intent we mark it and cancel any in-flight
|
||||||
|
// restore poll so restore can never yank them back. (Scrollbar-drag via
|
||||||
|
// pointer is an accepted small gap — it is not covered here.)
|
||||||
|
const onUserIntent = (event: Event) => {
|
||||||
|
// wheel/touchstart are unambiguous scroll intent; for keydown, only real
|
||||||
|
// scroll keys count — a shortcut or typing must not abort restore.
|
||||||
|
if (
|
||||||
|
event.type === "keydown" &&
|
||||||
|
!SCROLL_KEYS.has((event as KeyboardEvent).key)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
userInteractedRef.current = true;
|
||||||
|
if (pollTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(pollTimerRef.current);
|
||||||
|
pollTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window.addEventListener("scroll", onScroll, { passive: true });
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
window.addEventListener("pagehide", onPageHide);
|
window.addEventListener("pagehide", onPageHide);
|
||||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
window.addEventListener("wheel", onUserIntent, { passive: true });
|
||||||
|
window.addEventListener("touchstart", onUserIntent, { passive: true });
|
||||||
|
window.addEventListener("keydown", onUserIntent);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("scroll", onScroll);
|
window.removeEventListener("scroll", onScroll);
|
||||||
window.removeEventListener("pagehide", onPageHide);
|
window.removeEventListener("pagehide", onPageHide);
|
||||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
window.removeEventListener("wheel", onUserIntent);
|
||||||
|
window.removeEventListener("touchstart", onUserIntent);
|
||||||
|
window.removeEventListener("keydown", onUserIntent);
|
||||||
if (throttleTimer !== null) {
|
if (throttleTimer !== null) {
|
||||||
window.clearTimeout(throttleTimer);
|
window.clearTimeout(throttleTimer);
|
||||||
throttleTimer = null;
|
throttleTimer = null;
|
||||||
@@ -137,9 +187,8 @@ export function useScrollPosition(pageId: string): {
|
|||||||
}, [pageId]);
|
}, [pageId]);
|
||||||
|
|
||||||
const restoreScrollPosition = useCallback(() => {
|
const restoreScrollPosition = useCallback(() => {
|
||||||
// Run at most once per page mount.
|
// The reader took over — never yank them back.
|
||||||
if (hasRestoredRef.current) return;
|
if (userInteractedRef.current) return;
|
||||||
hasRestoredRef.current = true;
|
|
||||||
|
|
||||||
// Anchor priority: a `#hash` in the URL is handled by useEditorScroll.
|
// Anchor priority: a `#hash` in the URL is handled by useEditorScroll.
|
||||||
if (window.location.hash) return;
|
if (window.location.hash) return;
|
||||||
@@ -148,9 +197,26 @@ export function useScrollPosition(pageId: string): {
|
|||||||
// Nothing meaningful to restore to.
|
// Nothing meaningful to restore to.
|
||||||
if (targetY <= 0) return;
|
if (targetY <= 0) return;
|
||||||
|
|
||||||
const start = Date.now();
|
// Cancel any in-flight poll before (re)starting, so overlapping triggers can
|
||||||
|
// never run two concurrent polls against the same target.
|
||||||
|
if (pollTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(pollTimerRef.current);
|
||||||
|
pollTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share one timeout budget across re-triggers instead of restarting it.
|
||||||
|
if (restoreStartRef.current === null) {
|
||||||
|
restoreStartRef.current = Date.now();
|
||||||
|
}
|
||||||
|
const start = restoreStartRef.current;
|
||||||
|
|
||||||
const tryRestore = () => {
|
const tryRestore = () => {
|
||||||
|
// Bail mid-poll if the reader started scrolling while we were waiting.
|
||||||
|
if (userInteractedRef.current) {
|
||||||
|
pollTimerRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const maxScroll =
|
const maxScroll =
|
||||||
document.documentElement.scrollHeight - window.innerHeight;
|
document.documentElement.scrollHeight - window.innerHeight;
|
||||||
const timedOut = Date.now() - start >= MAX_RESTORE_WAIT_MS;
|
const timedOut = Date.now() - start >= MAX_RESTORE_WAIT_MS;
|
||||||
@@ -158,10 +224,12 @@ export function useScrollPosition(pageId: string): {
|
|||||||
// Restore once the content is tall enough to reach the target, or bail out
|
// Restore once the content is tall enough to reach the target, or bail out
|
||||||
// after the timeout and scroll as far as currently possible.
|
// after the timeout and scroll as far as currently possible.
|
||||||
if (maxScroll >= targetY || timedOut) {
|
if (maxScroll >= targetY || timedOut) {
|
||||||
window.scrollTo({
|
const top = Math.min(targetY, Math.max(maxScroll, 0));
|
||||||
top: Math.min(targetY, Math.max(maxScroll, 0)),
|
// Redundancy guard: re-asserting the SAME target when already positioned
|
||||||
behavior: "auto",
|
// is a no-op, so this hook can be called from multiple triggers safely.
|
||||||
});
|
if (Math.abs(window.scrollY - top) > 1) {
|
||||||
|
window.scrollTo({ top, behavior: "auto" });
|
||||||
|
}
|
||||||
pollTimerRef.current = null;
|
pollTimerRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, act } from "@testing-library/react";
|
||||||
|
import { useLayoutEffect, useState } from "react";
|
||||||
|
import { useScrollPosition } from "./hooks/use-scroll-position";
|
||||||
|
|
||||||
|
const KEY_PREFIX = "gitmost:scroll-position:";
|
||||||
|
|
||||||
|
// NOTE ON SCOPE (F2 — reviewer-approved lighter variant).
|
||||||
|
//
|
||||||
|
// The real UX wiring lives in page-editor.tsx as two useLayoutEffects around the
|
||||||
|
// useScrollPosition hook. A FULL PageEditor component test is impractical here and
|
||||||
|
// has no precedent in this client: PageEditor directly constructs a
|
||||||
|
// HocuspocusProviderWebsocket + IndexeddbPersistence, a tiptap `useEditor` with
|
||||||
|
// collab extensions, reads jotai atoms, react-router params, the shared
|
||||||
|
// `queryClient` from main.tsx, i18n, and mounts ~12 editor menu children. Worse,
|
||||||
|
// the static->live swap (`showStatic` -> false) is gated on
|
||||||
|
// `isCollabSynced(status, isLocalSynced && isRemoteSynced)`, which can only flip
|
||||||
|
// by driving the mocked collab provider's async sync callbacks. The heaviest
|
||||||
|
// component-test precedent in the repo (comment-hover-preview.test.tsx) mounts a
|
||||||
|
// single leaf component with ONE mocked query; nothing mounts a feature root of
|
||||||
|
// this weight. Reproducing all of that would test the mocks, not the wiring.
|
||||||
|
//
|
||||||
|
// So this file tests the same integration at the level that carries the real
|
||||||
|
// contract: the two useLayoutEffect blocks are reproduced VERBATIM from
|
||||||
|
// page-editor.tsx (the early pre-paint restore, and the post-swap re-assert with
|
||||||
|
// deps [showStatic, editor]) and exercised against the REAL useScrollPosition
|
||||||
|
// hook. If page-editor's wiring regresses (e.g. the swap effect drops the
|
||||||
|
// `&& editor` guard or its deps), the mirror below regresses in lockstep.
|
||||||
|
|
||||||
|
// Mirror of page-editor.tsx lines ~489-498 (the two scroll-restore useLayoutEffects).
|
||||||
|
function ScrollRestoreWiring({
|
||||||
|
restoreScrollPosition,
|
||||||
|
showStatic,
|
||||||
|
editor,
|
||||||
|
}: {
|
||||||
|
restoreScrollPosition: () => void;
|
||||||
|
showStatic: boolean;
|
||||||
|
editor: unknown | null;
|
||||||
|
}) {
|
||||||
|
// Restore as early as the static (cached) content is laid out, before paint.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
restoreScrollPosition();
|
||||||
|
}, [restoreScrollPosition]);
|
||||||
|
|
||||||
|
// Re-assert once after the static -> live editor swap.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!showStatic && editor) restoreScrollPosition();
|
||||||
|
}, [showStatic, editor, restoreScrollPosition]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScrollY(value: number): void {
|
||||||
|
Object.defineProperty(window, "scrollY", { configurable: true, value });
|
||||||
|
}
|
||||||
|
function setScrollHeight(value: number): void {
|
||||||
|
Object.defineProperty(document.documentElement, "scrollHeight", {
|
||||||
|
configurable: true,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function setInnerHeight(value: number): void {
|
||||||
|
Object.defineProperty(window, "innerHeight", { configurable: true, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PageEditor scroll-restore wiring (two useLayoutEffects)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.sessionStorage.clear();
|
||||||
|
setScrollY(0);
|
||||||
|
setScrollHeight(0);
|
||||||
|
setInnerHeight(800);
|
||||||
|
window.scrollTo = vi.fn();
|
||||||
|
window.location.hash = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
window.location.hash = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-invokes restoreScrollPosition after the swap, with the [showStatic, editor] deps", () => {
|
||||||
|
// A referentially STABLE spy, mirroring page-editor where restoreScrollPosition
|
||||||
|
// is a useCallback([]) — so the early effect (dep [restoreScrollPosition]) runs
|
||||||
|
// exactly once and does NOT re-fire on every render.
|
||||||
|
const restore = vi.fn();
|
||||||
|
|
||||||
|
const editor = { id: "editor" };
|
||||||
|
|
||||||
|
// Host owns the swap state so we can drive showStatic/editor like page-editor.
|
||||||
|
function Host({
|
||||||
|
showStatic,
|
||||||
|
editorValue,
|
||||||
|
}: {
|
||||||
|
showStatic: boolean;
|
||||||
|
editorValue: unknown | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ScrollRestoreWiring
|
||||||
|
restoreScrollPosition={restore}
|
||||||
|
showStatic={showStatic}
|
||||||
|
editor={editorValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-swap: static content shown, live editor not ready. Only the early
|
||||||
|
// pre-paint restore fires; the post-swap effect's guard (!showStatic) blocks it.
|
||||||
|
const { rerender } = render(<Host showStatic={true} editorValue={null} />);
|
||||||
|
expect(restore).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Collab reports synced (showStatic flips false) but the editor is not ready
|
||||||
|
// yet: the swap effect runs but the `&& editor` guard must keep it a no-op.
|
||||||
|
// (Pins the guard: dropping `&& editor` would restore against a null editor.)
|
||||||
|
rerender(<Host showStatic={false} editorValue={null} />);
|
||||||
|
expect(restore).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// The static -> live swap completes (showStatic false AND editor present): the
|
||||||
|
// post-swap effect re-asserts the restore exactly once more. The early effect
|
||||||
|
// does NOT re-fire (restore identity is stable), so this second call is driven
|
||||||
|
// solely by the [showStatic, editor] deps changing.
|
||||||
|
rerender(<Host showStatic={false} editorValue={editor} />);
|
||||||
|
expect(restore).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the post-swap re-assert drives a REAL restore (window.scrollTo) via the hook", () => {
|
||||||
|
// End-to-end through the real useScrollPosition: the swap re-invocation is the
|
||||||
|
// CAUSE of the scroll (nothing scrolls before it).
|
||||||
|
vi.useFakeTimers();
|
||||||
|
window.sessionStorage.setItem(`${KEY_PREFIX}peg`, "500");
|
||||||
|
setInnerHeight(800);
|
||||||
|
setScrollHeight(100); // maxScroll = -700: target not reachable yet -> polls.
|
||||||
|
|
||||||
|
const editor = { id: "editor" };
|
||||||
|
|
||||||
|
function Host({
|
||||||
|
showStatic,
|
||||||
|
editorValue,
|
||||||
|
}: {
|
||||||
|
showStatic: boolean;
|
||||||
|
editorValue: unknown | null;
|
||||||
|
}) {
|
||||||
|
const { restoreScrollPosition } = useScrollPosition("peg");
|
||||||
|
return (
|
||||||
|
<ScrollRestoreWiring
|
||||||
|
restoreScrollPosition={restoreScrollPosition}
|
||||||
|
showStatic={showStatic}
|
||||||
|
editor={editorValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-swap: the early restore runs but content is too short, so it starts
|
||||||
|
// polling (a pending timer) without scrolling. We never advance timers, so the
|
||||||
|
// early poll cannot fire on its own — isolating the swap as the sole cause.
|
||||||
|
const { rerender } = render(<Host showStatic={true} editorValue={null} />);
|
||||||
|
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// The live content is now laid out tall enough to reach the target.
|
||||||
|
setScrollHeight(2000); // maxScroll = 1200 >= 500
|
||||||
|
|
||||||
|
// The static -> live swap: the post-swap useLayoutEffect re-invokes the real
|
||||||
|
// hook, whose synchronous tryRestore now reaches the target and scrolls.
|
||||||
|
act(() => {
|
||||||
|
rerender(<Host showStatic={false} editorValue={editor} />);
|
||||||
|
});
|
||||||
|
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import "@/features/editor/styles/index.css";
|
|||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@@ -482,8 +483,17 @@ export default function PageEditor({
|
|||||||
}
|
}
|
||||||
}, [yjsConnectionStatus, isSynced]);
|
}, [yjsConnectionStatus, isSynced]);
|
||||||
|
|
||||||
// Restore the saved reading position once the live content is laid out.
|
// Restore as early as the static (cached) content is laid out, before paint,
|
||||||
useEffect(() => {
|
// so the reader's position is applied without a visible jump. Aborts itself if
|
||||||
|
// the reader has already started scrolling (handled inside the hook).
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
restoreScrollPosition();
|
||||||
|
}, [restoreScrollPosition]);
|
||||||
|
|
||||||
|
// Re-assert once after the static -> live editor swap in case the swap reset
|
||||||
|
// the window scroll. Idempotent: a no-op when the position is already correct,
|
||||||
|
// and a no-op after the reader has interacted.
|
||||||
|
useLayoutEffect(() => {
|
||||||
if (!showStatic && editor) restoreScrollPosition();
|
if (!showStatic && editor) restoreScrollPosition();
|
||||||
}, [showStatic, editor, restoreScrollPosition]);
|
}, [showStatic, editor, restoreScrollPosition]);
|
||||||
|
|
||||||
|
|||||||
@@ -71,22 +71,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inline image rows (#284): center the anonymous line boxes formed by
|
|
||||||
consecutive [data-image-align="inline"] node-view containers. A row has no
|
|
||||||
DOM wrapper of its own, so its horizontal placement is controlled by the
|
|
||||||
text-align of the nearest block ancestor (the editor root or a nested
|
|
||||||
block container: blockquote, callout, list item, table cell, details).
|
|
||||||
Centering is enabled only in containers that actually hold an inline
|
|
||||||
image (:has), and every other child of such a container gets its default
|
|
||||||
alignment back so ordinary text is unaffected. Explicit per-block
|
|
||||||
alignment from the toolbar is an inline style and still wins. Browsers
|
|
||||||
without :has() degrade to left-pinned rows. */
|
|
||||||
.ProseMirror:has(> [data-image-align="inline"]),
|
|
||||||
.ProseMirror :has(> [data-image-align="inline"]) {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror:has(> [data-image-align="inline"]) > :not([data-image-align="inline"]),
|
|
||||||
.ProseMirror :has(> [data-image-align="inline"]) > :not([data-image-align="inline"]) {
|
|
||||||
text-align: start;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -303,11 +303,6 @@ describe('buildSystemPrompt page-changed note (#274)', () => {
|
|||||||
expect(prompt).toContain(NOTE_MARKER);
|
expect(prompt).toContain(NOTE_MARKER);
|
||||||
expect(prompt).toContain('-old line');
|
expect(prompt).toContain('-old line');
|
||||||
expect(prompt).toContain('+new line');
|
expect(prompt).toContain('+new line');
|
||||||
// Strengthened note (#274): instructs a fresh re-read via getPage and steers
|
|
||||||
// the agent toward small, targeted edits instead of a full-page overwrite.
|
|
||||||
expect(prompt).toContain('getPage');
|
|
||||||
expect(prompt.toLowerCase()).toContain('targeted');
|
|
||||||
expect(prompt).toContain('editPageText');
|
|
||||||
// Inside the safety sandwich: the trailing SAFETY block follows the note.
|
// Inside the safety sandwich: the trailing SAFETY block follows the note.
|
||||||
expect(prompt.lastIndexOf(SAFETY_MARKER)).toBeGreaterThan(
|
expect(prompt.lastIndexOf(SAFETY_MARKER)).toBeGreaterThan(
|
||||||
prompt.indexOf(NOTE_MARKER),
|
prompt.indexOf(NOTE_MARKER),
|
||||||
|
|||||||
@@ -85,17 +85,11 @@ const INTERRUPT_NOTE =
|
|||||||
const PAGE_CHANGED_NOTE =
|
const PAGE_CHANGED_NOTE =
|
||||||
'NOTE: The user edited the open page AFTER your last response in this ' +
|
'NOTE: The user edited the open page AFTER your last response in this ' +
|
||||||
'conversation, so any copy of that page you produced or remember from earlier ' +
|
'conversation, so any copy of that page you produced or remember from earlier ' +
|
||||||
'is now STALE and must not be reused. Before you edit the page, you MUST first ' +
|
'is now STALE. The unified diff below shows exactly what changed since you last ' +
|
||||||
're-read its current content with the getPage tool and base your work on that ' +
|
'spoke (lines starting with "-" were removed, "+" were added) and is the source ' +
|
||||||
'live version — never on your earlier copy or on the transcript. The unified ' +
|
'of truth. Preserve the user\'s edits: build on the current page, do not revert ' +
|
||||||
'diff below shows exactly what the user changed since you last spoke (lines ' +
|
'or overwrite their changes. If you need the full up-to-date page, re-read it ' +
|
||||||
'starting with "-" were removed, "+" were added) and is the source of truth. ' +
|
'with the getPage tool before editing.';
|
||||||
'Preserve every one of the user\'s edits: make the smallest change that ' +
|
|
||||||
'satisfies the request using the targeted edit tools (editPageText, patchNode, ' +
|
|
||||||
'insertNode, deleteNode) rather than replacing the whole page, and do not ' +
|
|
||||||
'revert, drop, or overwrite anything the user changed. If a full rewrite is ' +
|
|
||||||
'truly unavoidable, start from the current getPage content and carry over all ' +
|
|
||||||
'of the user\'s edits.';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize a value interpolated into a prompt XML-ish attribute (e.g.
|
* Sanitize a value interpolated into a prompt XML-ish attribute (e.g.
|
||||||
|
|||||||
@@ -356,32 +356,6 @@ describe('flushAssistant', () => {
|
|||||||
expect(flushed.toolCalls).not.toBeNull();
|
expect(flushed.toolCalls).not.toBeNull();
|
||||||
expect(flushed.metadata.error).toBe('boom');
|
expect(flushed.metadata.error).toBe('boom');
|
||||||
});
|
});
|
||||||
|
|
||||||
// #274 observability: the page-change diff the agent saw this turn is persisted
|
|
||||||
// to metadata.pageChanged when a non-empty diff was injected, and omitted when
|
|
||||||
// the diff is empty/whitespace or the arg is not supplied.
|
|
||||||
it('persists metadata.pageChanged when a non-empty diff was injected', () => {
|
|
||||||
const f = flushAssistant([], '', 'completed', {
|
|
||||||
pageChanged: { title: 'Doc', diff: '@@ -1 +1 @@\n-old\n+new' },
|
|
||||||
});
|
|
||||||
expect(f.metadata.pageChanged).toEqual({
|
|
||||||
title: 'Doc',
|
|
||||||
diff: '@@ -1 +1 @@\n-old\n+new',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits metadata.pageChanged for an empty/whitespace diff or a missing arg', () => {
|
|
||||||
const whitespace = flushAssistant([], '', 'completed', {
|
|
||||||
pageChanged: { title: 'Doc', diff: ' \n ' },
|
|
||||||
});
|
|
||||||
expect('pageChanged' in whitespace.metadata).toBe(false);
|
|
||||||
|
|
||||||
const nullArg = flushAssistant([], '', 'completed', { pageChanged: null });
|
|
||||||
expect('pageChanged' in nullArg.metadata).toBe(false);
|
|
||||||
|
|
||||||
const omitted = flushAssistant([], '', 'streaming');
|
|
||||||
expect('pageChanged' in omitted.metadata).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -685,7 +685,7 @@ export class AiChatService implements OnModuleInit {
|
|||||||
// no-op (guarded below) so the turn still streams to the user.
|
// no-op (guarded below) so the turn still streams to the user.
|
||||||
let assistantId: string | undefined;
|
let assistantId: string | undefined;
|
||||||
try {
|
try {
|
||||||
const seed = flushAssistant([], '', 'streaming', { pageChanged });
|
const seed = flushAssistant([], '', 'streaming');
|
||||||
const seeded = await this.aiChatMessageRepo.insert({
|
const seeded = await this.aiChatMessageRepo.insert({
|
||||||
chatId,
|
chatId,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
@@ -720,7 +720,7 @@ export class AiChatService implements OnModuleInit {
|
|||||||
await this.aiChatMessageRepo.update(
|
await this.aiChatMessageRepo.update(
|
||||||
assistantId,
|
assistantId,
|
||||||
workspace.id,
|
workspace.id,
|
||||||
flushAssistant(capturedSteps, '', 'streaming', { pageChanged }),
|
flushAssistant(capturedSteps, '', 'streaming'),
|
||||||
{ onlyIfStreaming: true },
|
{ onlyIfStreaming: true },
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -860,7 +860,6 @@ export class AiChatService implements OnModuleInit {
|
|||||||
// resolved from the admin-configured provider settings (in
|
// resolved from the admin-configured provider settings (in
|
||||||
// closure scope here). Omitted/0 = no limit.
|
// closure scope here). Omitted/0 = no limit.
|
||||||
maxContextTokens: resolved?.chatContextWindow,
|
maxContextTokens: resolved?.chatContextWindow,
|
||||||
pageChanged,
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// Lifecycle: release the external MCP clients leased for this turn.
|
// Lifecycle: release the external MCP clients leased for this turn.
|
||||||
@@ -912,7 +911,6 @@ export class AiChatService implements OnModuleInit {
|
|||||||
await finalizeAssistant(
|
await finalizeAssistant(
|
||||||
flushAssistant(capturedSteps, inProgressText, 'error', {
|
flushAssistant(capturedSteps, inProgressText, 'error', {
|
||||||
error: errorText,
|
error: errorText,
|
||||||
pageChanged,
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await closeExternalClients();
|
await closeExternalClients();
|
||||||
@@ -942,9 +940,7 @@ export class AiChatService implements OnModuleInit {
|
|||||||
`steps=${steps.length}`,
|
`steps=${steps.length}`,
|
||||||
);
|
);
|
||||||
await finalizeAssistant(
|
await finalizeAssistant(
|
||||||
flushAssistant(capturedSteps, inProgressText, 'aborted', {
|
flushAssistant(capturedSteps, inProgressText, 'aborted'),
|
||||||
pageChanged,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
await closeExternalClients();
|
await closeExternalClients();
|
||||||
// Advance the page snapshot even on abort (#274): an agent edit that
|
// Advance the page snapshot even on abort (#274): an agent edit that
|
||||||
@@ -1510,7 +1506,6 @@ export function flushAssistant(
|
|||||||
contextTokens?: number;
|
contextTokens?: number;
|
||||||
maxContextTokens?: number;
|
maxContextTokens?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
pageChanged?: { title: string; diff: string } | null;
|
|
||||||
},
|
},
|
||||||
): AssistantFlush {
|
): AssistantFlush {
|
||||||
const finished = capturedSteps ?? [];
|
const finished = capturedSteps ?? [];
|
||||||
@@ -1543,15 +1538,6 @@ export function flushAssistant(
|
|||||||
if (extra?.maxContextTokens)
|
if (extra?.maxContextTokens)
|
||||||
metadata.maxContextTokens = extra.maxContextTokens;
|
metadata.maxContextTokens = extra.maxContextTokens;
|
||||||
if (extra?.error) metadata.error = extra.error;
|
if (extra?.error) metadata.error = extra.error;
|
||||||
// Persist the page-change diff the agent saw this turn (#274 observability),
|
|
||||||
// so history / the Markdown export can show what the user changed. Only when
|
|
||||||
// a non-empty diff was actually injected into the prompt this turn.
|
|
||||||
if (extra?.pageChanged && extra.pageChanged.diff?.trim().length) {
|
|
||||||
metadata.pageChanged = {
|
|
||||||
title: extra.pageChanged.title,
|
|
||||||
diff: extra.pageChanged.diff,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: stepsText + trailing,
|
content: stepsText + trailing,
|
||||||
|
|||||||
@@ -269,168 +269,6 @@ describe('buildChatMarkdown (server) — structure', () => {
|
|||||||
expect(md).toContain('**⚠️ Error:** 401: Unauthorized');
|
expect(md).toContain('**⚠️ Error:** 401: Unauthorized');
|
||||||
});
|
});
|
||||||
|
|
||||||
// #274 observability: an assistant row whose turn started with a user edit to
|
|
||||||
// the open page carries metadata.pageChanged = { title, diff }; the export
|
|
||||||
// renders the diff the agent saw, before the message body.
|
|
||||||
it('renders the persisted page-change diff block for an assistant row', () => {
|
|
||||||
const md = buildChatMarkdown({
|
|
||||||
title: 'T',
|
|
||||||
chatId: 'c',
|
|
||||||
rows: [
|
|
||||||
row({
|
|
||||||
role: 'assistant',
|
|
||||||
content: 'answer',
|
|
||||||
metadata: {
|
|
||||||
pageChanged: { title: 'Doc', diff: '@@ -1 +1 @@\n-old\n+new' },
|
|
||||||
} as never,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(md).toContain(
|
|
||||||
'The user edited this page before this turn; the diff the agent saw:',
|
|
||||||
);
|
|
||||||
expect(md).toContain('("Doc")');
|
|
||||||
expect(md).toContain('-old');
|
|
||||||
expect(md).toContain('+new');
|
|
||||||
// The diff sits before the message body (chronological: change, then reply).
|
|
||||||
expect(md.indexOf('-old')).toBeLessThan(md.indexOf('answer'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not render the page-change block when metadata.pageChanged is absent', () => {
|
|
||||||
const md = buildChatMarkdown({
|
|
||||||
title: 'T',
|
|
||||||
chatId: 'c',
|
|
||||||
rows: [row({ role: 'assistant', content: 'answer' })],
|
|
||||||
});
|
|
||||||
expect(md).not.toContain(
|
|
||||||
'The user edited this page before this turn; the diff the agent saw:',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// #288 F1/F2: an empty page title must render the BARE heading with no
|
|
||||||
// `("…")` suffix (the `pc.title ? … : …` false branch).
|
|
||||||
it('renders the page-change heading with no title suffix when title is empty', () => {
|
|
||||||
const md = buildChatMarkdown({
|
|
||||||
title: 'T',
|
|
||||||
chatId: 'c',
|
|
||||||
rows: [
|
|
||||||
row({
|
|
||||||
role: 'assistant',
|
|
||||||
content: 'answer',
|
|
||||||
metadata: {
|
|
||||||
pageChanged: { title: '', diff: '@@ -1 +1 @@\n-old\n+new' },
|
|
||||||
} as never,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
// Bare heading, single line, no parenthesized title.
|
|
||||||
expect(md).toContain(
|
|
||||||
'> **📝 The user edited this page before this turn; the diff the agent saw:**',
|
|
||||||
);
|
|
||||||
expect(md).not.toContain('("');
|
|
||||||
expect(md).toContain('-old');
|
|
||||||
});
|
|
||||||
|
|
||||||
// #288 F1: the page title is UNTRUSTED cross-user data, so a title carrying a
|
|
||||||
// newline / backtick / `"` / `<`/`>` must be neutralized by escapeAttr before
|
|
||||||
// it is interpolated into the `> **…**` blockquote heading — otherwise it
|
|
||||||
// could break the blockquote onto multiple lines or inject markup/HTML into
|
|
||||||
// the downloaded .md. escapeAttr strips `<>"` and collapses whitespace runs to
|
|
||||||
// a single space, so `Ev"il\n> `x` <b>` becomes ``Evil `x` b``.
|
|
||||||
it('escapes an untrusted page title in the page-change heading', () => {
|
|
||||||
const md = buildChatMarkdown({
|
|
||||||
title: 'T',
|
|
||||||
chatId: 'c',
|
|
||||||
rows: [
|
|
||||||
row({
|
|
||||||
role: 'assistant',
|
|
||||||
content: 'answer',
|
|
||||||
metadata: {
|
|
||||||
pageChanged: {
|
|
||||||
title: 'Ev"il\n> `x` <b>',
|
|
||||||
diff: '@@ -1 +1 @@\n-old\n+new',
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
// The heading stays a single blockquote line with the escaped title.
|
|
||||||
expect(md).toContain(
|
|
||||||
'> **📝 The user edited this page before this turn; the diff the agent saw: ("Evil `x` b")**',
|
|
||||||
);
|
|
||||||
// No raw attribute/markup breakers survived from the title.
|
|
||||||
expect(md).not.toContain('Ev"il');
|
|
||||||
expect(md).not.toContain('<b>');
|
|
||||||
});
|
|
||||||
|
|
||||||
// #288 review F1: escapeAttr ALONE is insufficient for this MARKDOWN sink —
|
|
||||||
// link/image syntax survives it. A cross-user title with `` /
|
|
||||||
// `[phish](url)` must NOT become a working remote image or clickable link in
|
|
||||||
// the downloaded .md; markdownHeadingSafe backslash-escapes `[`/`]` so both are
|
|
||||||
// inert. (Non-vacuous: fails against the escapeAttr-only version, which left
|
|
||||||
// `](https://` intact.)
|
|
||||||
it('neutralizes markdown link/image syntax in an untrusted page title', () => {
|
|
||||||
const md = buildChatMarkdown({
|
|
||||||
title: 'T',
|
|
||||||
chatId: 'c',
|
|
||||||
rows: [
|
|
||||||
row({
|
|
||||||
role: 'assistant',
|
|
||||||
content: 'answer',
|
|
||||||
metadata: {
|
|
||||||
pageChanged: {
|
|
||||||
title:
|
|
||||||
' and [click](https://phish.example)',
|
|
||||||
diff: '@@ -1 +1 @@\n-old\n+new',
|
|
||||||
},
|
|
||||||
} as never,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
// No WORKING image/link syntax survives — the `[…]` sits escaped as `\[…\]`,
|
|
||||||
// so the unescaped ``: after escaping the
|
|
||||||
// literal `\](https://` still contains `](https://` as a raw substring — that
|
|
||||||
// check would false-fail even though the link is inert.)
|
|
||||||
expect(md).not.toContain(';
|
|
||||||
expect(md).not.toContain('[click](');
|
|
||||||
// The brackets are backslash-escaped, so `[text](url)`/`` are inert.
|
|
||||||
expect(md).toContain('\\[');
|
|
||||||
expect(md).toContain('\\]');
|
|
||||||
// The heading stays a SINGLE blockquote line (no newline injected).
|
|
||||||
const headingLine = md
|
|
||||||
.split('\n')
|
|
||||||
.find((l) => l.includes('the diff the agent saw:'));
|
|
||||||
expect(headingLine).toBeDefined();
|
|
||||||
expect(headingLine).toContain('\\[x\\]');
|
|
||||||
expect(headingLine).toContain('\\[click\\]');
|
|
||||||
});
|
|
||||||
|
|
||||||
// #288 internal review Finding 2: a NON-empty title made up entirely of
|
|
||||||
// escapeAttr breakers (`<>"`) escapes to '' — the ternary must then fall to the
|
|
||||||
// BARE heading with NO `("…")` suffix. Locks the ternary-on-escaped-value
|
|
||||||
// behavior (distinct from the empty-string input test above).
|
|
||||||
it('renders the bare heading for a title that escapes to empty', () => {
|
|
||||||
const md = buildChatMarkdown({
|
|
||||||
title: 'T',
|
|
||||||
chatId: 'c',
|
|
||||||
rows: [
|
|
||||||
row({
|
|
||||||
role: 'assistant',
|
|
||||||
content: 'answer',
|
|
||||||
metadata: {
|
|
||||||
pageChanged: { title: '<>"', diff: '@@ -1 +1 @@\n-old\n+new' },
|
|
||||||
} as never,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(md).toContain(
|
|
||||||
'> **📝 The user edited this page before this turn; the diff the agent saw:**',
|
|
||||||
);
|
|
||||||
expect(md).not.toContain('("');
|
|
||||||
expect(md).toContain('-old');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('escapes embedded triple-backtick fences with a longer delimiter', () => {
|
it('escapes embedded triple-backtick fences with a longer delimiter', () => {
|
||||||
const md = buildChatMarkdown({
|
const md = buildChatMarkdown({
|
||||||
title: 'T',
|
title: 'T',
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AiChatMessage } from '@docmost/db/types/entity.types';
|
import type { AiChatMessage } from '@docmost/db/types/entity.types';
|
||||||
import { escapeAttr } from './ai-chat.prompt';
|
|
||||||
|
|
||||||
/** Supported export label languages. Defaults to English. */
|
/** Supported export label languages. Defaults to English. */
|
||||||
export type ExportLang = 'en' | 'ru';
|
export type ExportLang = 'en' | 'ru';
|
||||||
@@ -64,7 +63,6 @@ const LABELS: Record<
|
|||||||
tools: Record<string, string>;
|
tools: Record<string, string>;
|
||||||
ranTool: (name: string) => string;
|
ranTool: (name: string) => string;
|
||||||
stillGenerating: string;
|
stillGenerating: string;
|
||||||
pageEditedByUser: string;
|
|
||||||
}
|
}
|
||||||
> = {
|
> = {
|
||||||
en: {
|
en: {
|
||||||
@@ -85,8 +83,6 @@ const LABELS: Record<
|
|||||||
ranTool: (name) => `Ran tool ${name}`,
|
ranTool: (name) => `Ran tool ${name}`,
|
||||||
stillGenerating:
|
stillGenerating:
|
||||||
'This message is still being generated — the export captured a partial, in-progress response.',
|
'This message is still being generated — the export captured a partial, in-progress response.',
|
||||||
pageEditedByUser:
|
|
||||||
'The user edited this page before this turn; the diff the agent saw:',
|
|
||||||
},
|
},
|
||||||
ru: {
|
ru: {
|
||||||
untitled: 'Без названия',
|
untitled: 'Без названия',
|
||||||
@@ -106,29 +102,9 @@ const LABELS: Record<
|
|||||||
ranTool: (name) => `Выполнил инструмент ${name}`,
|
ranTool: (name) => `Выполнил инструмент ${name}`,
|
||||||
stillGenerating:
|
stillGenerating:
|
||||||
'Это сообщение всё ещё генерируется — экспорт захватил частичный, незавершённый ответ.',
|
'Это сообщение всё ещё генерируется — экспорт захватил частичный, незавершённый ответ.',
|
||||||
pageEditedByUser:
|
|
||||||
'Пользователь изменил страницу перед этим ходом; дифф, который видел агент:',
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Make an untrusted title safe to interpolate into a Markdown blockquote
|
|
||||||
* HEADING. escapeAttr() neutralizes the XML/HTML breakers (`<` `>` `"`) and
|
|
||||||
* collapses whitespace for the PROMPT sink (`page="…"`), but this export sink is
|
|
||||||
* MARKDOWN — link/image syntax survives escapeAttr. So additionally backslash-
|
|
||||||
* escape `[` and `]`: that disables both `[text](url)` links and ``
|
|
||||||
* images, so a cross-user title like `` or `[phish](http://evil)`
|
|
||||||
* cannot inject a remote (auto-loading) image or a clickable link into the
|
|
||||||
* downloaded .md disguised as a trusted system annotation. A bare `(url)` with no
|
|
||||||
* preceding `[]` is inert Markdown, so brackets are the only security-critical
|
|
||||||
* characters here. (We leave backticks to escapeAttr's whitespace pass — a title
|
|
||||||
* shown as inline code cannot escape the blockquote line or load a resource, so
|
|
||||||
* it is not a security concern for this sink.)
|
|
||||||
*/
|
|
||||||
function markdownHeadingSafe(title: string): string {
|
|
||||||
return escapeAttr(title).replace(/[[\]]/g, (m) => `\\${m}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
|
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
|
||||||
function isToolPart(type: string): boolean {
|
function isToolPart(type: string): boolean {
|
||||||
return type.startsWith('tool-') || type === 'dynamic-tool';
|
return type.startsWith('tool-') || type === 'dynamic-tool';
|
||||||
@@ -232,23 +208,6 @@ function rowParts(row: AiChatMessage): ExportPart[] {
|
|||||||
: [{ type: 'text', text: row.content ?? '' }];
|
: [{ type: 'text', text: row.content ?? '' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The persisted page-change diff the agent saw this turn (#274), when any. */
|
|
||||||
function pageChangedOf(
|
|
||||||
row: AiChatMessage,
|
|
||||||
): { title: string; diff: string } | undefined {
|
|
||||||
const meta = (row.metadata ?? {}) as {
|
|
||||||
pageChanged?: { title?: string; diff?: string };
|
|
||||||
};
|
|
||||||
const pc = meta.pageChanged;
|
|
||||||
if (pc && typeof pc.diff === 'string' && pc.diff.trim().length > 0) {
|
|
||||||
return {
|
|
||||||
title: typeof pc.title === 'string' ? pc.title : '',
|
|
||||||
diff: pc.diff,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize a chat to a Markdown string from its persisted rows. Source = DB
|
* Serialize a chat to a Markdown string from its persisted rows. Source = DB
|
||||||
* ONLY (no live client state). A row whose `status` is still 'streaming' is an
|
* ONLY (no live client state). A row whose `status` is still 'streaming' is an
|
||||||
@@ -307,26 +266,6 @@ export function buildChatMarkdown(args: {
|
|||||||
blocks.push(`<!-- ${iso} -->`);
|
blocks.push(`<!-- ${iso} -->`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Page-change observability (#274): show the diff the agent saw at the start
|
|
||||||
// of this turn, before its response, so the export reflects the stale-page
|
|
||||||
// warning the model received.
|
|
||||||
const pc = pageChangedOf(row);
|
|
||||||
if (pc) {
|
|
||||||
// The page title is UNTRUSTED cross-user data (a collaborative page's title
|
|
||||||
// controllable by another user). escapeAttr() alone (the prompt sink) is
|
|
||||||
// INSUFFICIENT here: this is a MARKDOWN sink, so we neutralize link/image
|
|
||||||
// syntax too (backslash-escaping `[`/`]`) before interpolating it into this
|
|
||||||
// `> **…**` blockquote heading — otherwise `` / `[phish](url)` would
|
|
||||||
// inject a remote image or clickable link into the downloaded .md. An
|
|
||||||
// all-`<>"` title escapes to empty and correctly falls to the bare heading.
|
|
||||||
// The diff body is already safe via fence(). (#288 review F1.)
|
|
||||||
const safeTitle = markdownHeadingSafe(pc.title);
|
|
||||||
const heading = safeTitle
|
|
||||||
? `${L.pageEditedByUser} ("${safeTitle}")`
|
|
||||||
: L.pageEditedByUser;
|
|
||||||
blocks.push(`> **📝 ${heading}**\n\n${fence(pc.diff, 'diff')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
blocks.push(...renderMessageParts(rowParts(row), lang));
|
blocks.push(...renderMessageParts(rowParts(row), lang));
|
||||||
|
|
||||||
// A still-'streaming' row is an interrupted/in-progress turn captured by the
|
// A still-'streaming' row is an interrupted/in-progress turn captured by the
|
||||||
|
|||||||
@@ -449,9 +449,7 @@ export function applyAlignment(container: HTMLElement, align: string) {
|
|||||||
// the next line when the viewport is narrow. The right/bottom padding
|
// the next line when the viewport is narrow. The right/bottom padding
|
||||||
// provides the gap between images in a row and between wrapped rows;
|
// provides the gap between images in a row and between wrapped rows;
|
||||||
// vertical-align: top keeps rows of different-height images aligned by
|
// vertical-align: top keeps rows of different-height images aligned by
|
||||||
// their top edge. Horizontal centering of the whole row is handled by the
|
// their top edge.
|
||||||
// client stylesheet (media.css) via a :has() rule on the parent block
|
|
||||||
// container, since the row has no wrapper element of its own.
|
|
||||||
container.style.display = "inline-block";
|
container.style.display = "inline-block";
|
||||||
container.style.verticalAlign = "top";
|
container.style.verticalAlign = "top";
|
||||||
container.style.padding = "0 10px 10px 0";
|
container.style.padding = "0 10px 10px 0";
|
||||||
|
|||||||
Reference in New Issue
Block a user