diff --git a/js/app/packages/block-md/component/MarkdownEditor.tsx b/js/app/packages/block-md/component/MarkdownEditor.tsx
index 54ba9893f4..00d22b43b4 100644
--- a/js/app/packages/block-md/component/MarkdownEditor.tsx
+++ b/js/app/packages/block-md/component/MarkdownEditor.tsx
@@ -83,6 +83,11 @@ import {
type NodekeyOffset,
SearchHighlight,
} from '@core/component/LexicalMarkdown/plugins/find-and-replace';
+import {
+ createHoverTooltipStore,
+ HoverTooltip,
+ hoverTooltipPlugin,
+} from '@core/component/LexicalMarkdown/plugins/hover-tooltip';
import { iosCursorScrollPlugin } from '@core/component/LexicalMarkdown/plugins/ios-cursor-scroll';
import {
GO_TO_LOCATION_COMMAND,
@@ -937,6 +942,9 @@ export function MarkdownEditor(props: {
},
});
+ const [hoverTooltipStore, setHoverTooltipStore] = createHoverTooltipStore();
+ plugins.use(hoverTooltipPlugin({ setState: (s) => setHoverTooltipStore(s) }));
+
const [wordcountStats, setWordcountStats] = createWordcountStatsStore();
plugins.use(
wordcountPlugin({ setStore: setWordcountStats, debounceTime: 200 })
@@ -1021,6 +1029,7 @@ export function MarkdownEditor(props: {
{getBlankMarkdownPlaceholder(canEdit())}
+
diff --git a/js/app/packages/core/component/LexicalMarkdown/plugins/hover-tooltip/HoverTooltip.tsx b/js/app/packages/core/component/LexicalMarkdown/plugins/hover-tooltip/HoverTooltip.tsx
new file mode 100644
index 0000000000..2d97e64991
--- /dev/null
+++ b/js/app/packages/core/component/LexicalMarkdown/plugins/hover-tooltip/HoverTooltip.tsx
@@ -0,0 +1,116 @@
+import { UserIcon } from '@core/component/UserIcon';
+import { macroIdToEmail, tryMacroId, useDisplayNameParts } from '@core/user';
+import { formatRelativeTimestamp } from '@entity';
+import { syncServiceClient } from '@service-sync/client';
+import { debounce } from '@solid-primitives/scheduled';
+import {
+ createEffect,
+ createResource,
+ createSignal,
+ onCleanup,
+ Show,
+ untrack,
+} from 'solid-js';
+import type { Store } from 'solid-js/store';
+import type { HoverTooltipState } from './hoverTooltipPlugin';
+
+const FETCH_DELAY_MS = 400;
+const SHOW_DELAY_MS = 500;
+
+function UserLine(props: { userId: string; editedAt: Date }) {
+ const macroId = tryMacroId(props.userId);
+ const { firstName } = useDisplayNameParts(macroId);
+ const name = () =>
+ firstName() || (macroId ? macroIdToEmail(macroId) : props.userId);
+
+ return (
+
+
+ {name()}, {formatRelativeTimestamp(props.editedAt)}
+
+ );
+}
+
+export function HoverTooltip(props: {
+ state: Store;
+ documentId: string;
+}) {
+ const [visible, setVisible] = createSignal(false);
+ // The nodeId we've actually committed to fetching for. Debounced from the
+ // raw hovered nodeId so we don't fire a request the instant the cursor
+ // crosses a text node.
+ const [armedNodeId, setArmedNodeId] = createSignal(null);
+ let shownAtX = 0;
+ let shownAtY = 0;
+ let showTimer: ReturnType | null = null;
+
+ const debouncedArm = debounce((nodeId: string | null) => {
+ setArmedNodeId(nodeId);
+ }, FETCH_DELAY_MS);
+
+ const hide = () => {
+ debouncedArm.clear();
+ if (showTimer) clearTimeout(showTimer);
+ showTimer = null;
+ setArmedNodeId(null);
+ setVisible(false);
+ };
+
+ const [blame] = createResource(armedNodeId, async (nodeId) => {
+ const res = await syncServiceClient.getNodeBlame({
+ documentId: props.documentId,
+ nodeId,
+ });
+ return res.isOk() ? res.value : null;
+ });
+
+ // Drive the fetch — debounced on nodeId only, ignores cursor x/y.
+ createEffect(() => {
+ const nodeId = props.state.hovering ? props.state.nodeId : null;
+ if (nodeId === null) {
+ debouncedArm.clear();
+ setArmedNodeId(null);
+ } else {
+ debouncedArm(nodeId);
+ }
+ });
+
+ // Drive the visibility — based on cursor stillness (x/y).
+ createEffect(() => {
+ const { hovering, x, y } = props.state;
+
+ if (!hovering) return hide();
+
+ // After shown: any pointer move dismisses.
+ if (untrack(visible)) {
+ if (x !== shownAtX || y !== shownAtY) hide();
+ return;
+ }
+
+ // Pre-show: each move restarts the show timer.
+ if (showTimer) clearTimeout(showTimer);
+ showTimer = setTimeout(() => {
+ shownAtX = x;
+ shownAtY = y;
+ setVisible(true);
+ }, SHOW_DELAY_MS);
+ });
+
+ onCleanup(hide);
+
+ return (
+
+ {(b) => (
+
+
+
+ )}
+
+ );
+}
diff --git a/js/app/packages/core/component/LexicalMarkdown/plugins/hover-tooltip/hoverTooltipPlugin.ts b/js/app/packages/core/component/LexicalMarkdown/plugins/hover-tooltip/hoverTooltipPlugin.ts
new file mode 100644
index 0000000000..0ab48f6fb0
--- /dev/null
+++ b/js/app/packages/core/component/LexicalMarkdown/plugins/hover-tooltip/hoverTooltipPlugin.ts
@@ -0,0 +1,108 @@
+import { isTouchDevice } from '@core/mobile/isTouchDevice';
+import { mergeRegister } from '@lexical/utils';
+import { $getId } from '@lexical-core';
+import {
+ $getNearestNodeFromDOMNode,
+ $isTextNode,
+ type LexicalEditor,
+ type LexicalNode,
+} from 'lexical';
+import { createStore } from 'solid-js/store';
+
+export type HoverTooltipState = {
+ hovering: boolean;
+ x: number;
+ y: number;
+ nodeId: string | null;
+};
+
+export function createHoverTooltipStore() {
+ return createStore({
+ hovering: false,
+ x: 0,
+ y: 0,
+ nodeId: null,
+ });
+}
+
+type HoverTooltipPluginProps = {
+ setState: (state: Partial) => void;
+};
+
+/**
+ * Given a DOM element under the cursor, find the stable Lexical id of the
+ * nearest ancestor text-bearing node. Must be called inside `editor.read()`
+ * so the Lexical state is readable.
+ *
+ * Returns null when the cursor isn't over a text node, or when no ancestor
+ * has been assigned a stable id yet (typically transient nodes).
+ */
+function $resolveHoveredNodeId(target: HTMLElement): string | null {
+ const node = $getNearestNodeFromDOMNode(target);
+ if (!node || !$isTextNode(node)) return null;
+
+ let cursor: LexicalNode | null = node;
+ while (cursor) {
+ const id = $getId(cursor);
+ if (id) return id;
+ cursor = cursor.getParent();
+ }
+ return null;
+}
+
+function registerHoverTooltipPlugin(
+ editor: LexicalEditor,
+ props: HoverTooltipPluginProps
+) {
+ const handlePointerMove = (e: MouseEvent) => {
+ if (isTouchDevice()) return;
+ const target = e.target;
+ if (!(target instanceof HTMLElement)) {
+ props.setState({ hovering: false, nodeId: null });
+ return;
+ }
+
+ // Suppress while the user has a text selection — that's when the
+ // formatting popup shows and the tooltip would compete with it.
+ const sel = window.getSelection();
+ if (sel && !sel.isCollapsed && sel.toString().length > 0) {
+ props.setState({ hovering: false, nodeId: null });
+ return;
+ }
+
+ const nodeId = editor.read(() => $resolveHoveredNodeId(target));
+ if (nodeId === null) {
+ props.setState({ hovering: false, nodeId: null });
+ return;
+ }
+ props.setState({
+ hovering: true,
+ x: e.clientX,
+ y: e.clientY,
+ nodeId,
+ });
+ };
+
+ const dismiss = () => {
+ props.setState({ hovering: false, nodeId: null });
+ };
+
+ return mergeRegister(
+ editor.registerRootListener((root, prevRoot) => {
+ if (root) {
+ root.addEventListener('pointermove', handlePointerMove);
+ root.addEventListener('pointerleave', dismiss);
+ root.addEventListener('pointerdown', dismiss);
+ }
+ if (prevRoot) {
+ prevRoot.removeEventListener('pointermove', handlePointerMove);
+ prevRoot.removeEventListener('pointerleave', dismiss);
+ prevRoot.removeEventListener('pointerdown', dismiss);
+ }
+ })
+ );
+}
+
+export function hoverTooltipPlugin(props: HoverTooltipPluginProps) {
+ return (editor: LexicalEditor) => registerHoverTooltipPlugin(editor, props);
+}
diff --git a/js/app/packages/core/component/LexicalMarkdown/plugins/hover-tooltip/index.ts b/js/app/packages/core/component/LexicalMarkdown/plugins/hover-tooltip/index.ts
new file mode 100644
index 0000000000..00c698eef8
--- /dev/null
+++ b/js/app/packages/core/component/LexicalMarkdown/plugins/hover-tooltip/index.ts
@@ -0,0 +1,2 @@
+export * from './HoverTooltip';
+export * from './hoverTooltipPlugin';
diff --git a/js/app/packages/core/component/LexicalMarkdown/plugins/index.ts b/js/app/packages/core/component/LexicalMarkdown/plugins/index.ts
index fc6010a27d..3c1933e660 100644
--- a/js/app/packages/core/component/LexicalMarkdown/plugins/index.ts
+++ b/js/app/packages/core/component/LexicalMarkdown/plugins/index.ts
@@ -15,6 +15,7 @@ export * from './file-paste';
export * from './find-and-replace';
export * from './generate';
export * from './horizontal-rules';
+export * from './hover-tooltip';
export * from './insert-text';
export * from './ios-cursor-scroll';
export * from './katex';
diff --git a/js/app/packages/core/component/LexicalMarkdown/plugins/pluginManager.ts b/js/app/packages/core/component/LexicalMarkdown/plugins/pluginManager.ts
index b90a8eb9e2..940fa39828 100644
--- a/js/app/packages/core/component/LexicalMarkdown/plugins/pluginManager.ts
+++ b/js/app/packages/core/component/LexicalMarkdown/plugins/pluginManager.ts
@@ -21,7 +21,7 @@ import { checklistPlugin } from './checklist/';
import { customDeletePlugin } from './custom-delete';
import { markdownShortcutsPlugin } from './markdown-shortcuts';
-type PluginFunction = (editor: LexicalEditor) => () => void;
+export type PluginFunction = (editor: LexicalEditor) => () => void;
/**
* Create a binding between a LexicalEditor and the ability to register plugins
diff --git a/js/app/packages/service-clients/service-sync/client.ts b/js/app/packages/service-clients/service-sync/client.ts
index 3a6e420af2..5e81971bf1 100644
--- a/js/app/packages/service-clients/service-sync/client.ts
+++ b/js/app/packages/service-clients/service-sync/client.ts
@@ -179,6 +179,36 @@ export const syncServiceClient = {
return ok(response.value as MetadataResponse);
},
+ /**
+ * Look up who last edited a given Lexical node and when. `user_id` is a
+ * MacroId resolved server-side; `null` if the peer has no recorded user
+ * (anonymous edits or legacy data not yet mirrored locally).
+ */
+ async getNodeBlame(args: { documentId: string; nodeId: string }) {
+ const token = await getPermissionToken('document', args.documentId);
+
+ const response = await syncFetch<{
+ peer_id: string;
+ user_id: string | null;
+ timestamp_ms: number;
+ }>(`/document/${args.documentId}/blame/${args.nodeId}`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ method: 'GET',
+ });
+
+ if (response.isErr()) {
+ return err(response.error);
+ }
+ const { peer_id, user_id, timestamp_ms } = response.value;
+ return ok({
+ peerId: peer_id,
+ userId: user_id,
+ editedAt: new Date(timestamp_ms),
+ });
+ },
async getSnapshot(args: { documentId: string }) {
const token = await getPermissionToken('document', args.documentId);
const response = await platformFetch(
diff --git a/rust/sync-service/database/user-peer-mapping/migrations/0002_add_blame.sql b/rust/sync-service/database/user-peer-mapping/migrations/0002_add_blame.sql
new file mode 100644
index 0000000000..b149981f52
--- /dev/null
+++ b/rust/sync-service/database/user-peer-mapping/migrations/0002_add_blame.sql
@@ -0,0 +1,9 @@
+CREATE TABLE blame (
+ document_id TEXT NOT NULL,
+ node_id TEXT NOT NULL,
+ peer_id TEXT NOT NULL,
+ timestamp_ms INTEGER NOT NULL,
+ PRIMARY KEY (document_id, node_id)
+);
+
+CREATE INDEX idx_blame_document_id ON blame (document_id);
diff --git a/rust/sync-service/src/d1.rs b/rust/sync-service/src/d1.rs
index ce4dfcaf1c..b4a40e00ff 100644
--- a/rust/sync-service/src/d1.rs
+++ b/rust/sync-service/src/d1.rs
@@ -88,3 +88,96 @@ pub async fn get_peers_for_document_id(
Ok(peers)
}
+
+/// A single pending "last edited by" event, buffered until the
+/// next alarm tick flushes everything
+#[derive(Debug, Clone)]
+pub struct BlameEvent {
+ pub document_id: String,
+ pub node_id: String,
+ pub peer_id: u64,
+ pub timestamp_ms: i64,
+}
+
+/// Maximum statements per D1 `batch()` call. D1 has a per-batch statement
+/// limit; chunking keeps us comfortably under it.
+const BATCH_CHUNK_SIZE: usize = 100;
+
+/// Bulk-upsert a list of buffered blame events. Uses D1's `batch()` so all
+/// events in a chunk commit in a single round-trip.
+pub async fn insert_blame_many(
+ env: &worker::Env,
+ events: &[BlameEvent],
+) -> worker::Result<()> {
+ if events.is_empty() {
+ return Ok(());
+ }
+ tracing::info!(count = events.len(), "insert_blame_many");
+
+ for chunk in events.chunks(BATCH_CHUNK_SIZE) {
+ let db = env.d1(crate::constants::USER_PEER_D1_BINDING)?;
+ let stmts: Vec<_> = chunk
+ .iter()
+ .map(|e| {
+ db.prepare(
+ "INSERT INTO blame (document_id, node_id, peer_id, timestamp_ms) \
+ VALUES (?, ?, ?, ?) \
+ ON CONFLICT(document_id, node_id) DO UPDATE SET \
+ peer_id = excluded.peer_id, \
+ timestamp_ms = excluded.timestamp_ms;",
+ )
+ .bind(&[
+ e.document_id.as_str().into(),
+ e.node_id.as_str().into(),
+ e.peer_id.to_string().into(),
+ // d1 js doesn't support bigint
+ (e.timestamp_ms as f64).into(),
+ ])
+ })
+ .collect::>>()?;
+ db.batch(stmts).await?;
+ }
+ Ok(())
+}
+
+
+#[derive(serde::Deserialize, serde::Serialize)]
+pub struct BlameRow {
+ pub peer_id: String,
+ pub user_id: Option,
+ pub timestamp_ms: i64,
+}
+
+/// JOIN blame with peer_user_map to get last-edit info plus resolved user_id.
+pub async fn get_blame_for_node(
+ db: D1Database,
+ document_id: &str,
+ node_id: &str,
+) -> worker::Result