Skip to content

Commit cda2bac

Browse files
committed
feat(ai): add Markdown and editor selection context
Capture bounded Markdown selection snapshots and expose follow-up context retrieval. Add uniform attachment styling and editor Ask AI placement that pauses during resizing. Include regression coverage and pin Phoenix Pro to 35c2b1236eae102fd15a2c429a7ea9bc656f1395.
1 parent b732be9 commit cda2bac

18 files changed

Lines changed: 1592 additions & 166 deletions

File tree

‎src-mdviewer/src/bridge.js‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { setLocale } from "./core/i18n.js";
99
import { marked } from "marked";
1010
import * as docCache from "./core/doc-cache.js";
1111
import { broadcastSelectionStateSync, flushPendingContentChange } from "./components/editor.js";
12+
import { captureSelection, restoreSelection, getRenderedMdLineText } from "./core/selection-context.js";
1213

1314
let _syncId = 0;
1415
let _lastReceivedSyncId = -1;
@@ -81,6 +82,7 @@ function _annotateTokenLines(tokens) {
8182
for (const token of tokens) {
8283
if (token.type !== "space") {
8384
token._sourceLine = line;
85+
token._sourceEndLine = line + (token.raw.replace(/\n$/, "").match(/\n/g) || []).length;
8486
}
8587
// Recursively annotate children with their source lines
8688
_annotateTokenChildren(token, line);
@@ -132,6 +134,7 @@ function _annotateNestedTokens(tokens, startLine) {
132134
for (const token of tokens) {
133135
if (token.type !== "space") {
134136
token._sourceLine = line;
137+
token._sourceEndLine = line + (token.raw.replace(/\n$/, "").match(/\n/g) || []).length;
135138
}
136139
// Recurse into nested lists
137140
if (token.type === "list" && token.items) {
@@ -182,7 +185,8 @@ function _withSourceLine(protoFn, tagRegex) {
182185
return function (token) {
183186
const html = protoFn.call(this, token);
184187
if (token._sourceLine != null) {
185-
return html.replace(tagRegex, `$& data-source-line="${token._sourceLine}"`);
188+
return html.replace(tagRegex, `$& data-source-line="${token._sourceLine}"` +
189+
` data-source-end-line="${token._sourceEndLine || token._sourceLine}"`);
186190
}
187191
return html;
188192
};
@@ -278,6 +282,24 @@ export function initBridge() {
278282
if (!data || !data.type) return;
279283

280284
switch (data.type) {
285+
case "MDVIEWR_ASK_AI_ENABLED":
286+
if (event.source === window.parent) { emit("ai:enabled", !!data.enabled); }
287+
break;
288+
case "MDVIEWR_ASK_AI_SELECTION":
289+
if (event.source === window.parent) { emit("ai:attach-selection", {titlebar: true}); }
290+
break;
291+
case "MDVIEWR_SELECT_SOURCE_RANGE":
292+
if (event.source === window.parent && data.filePath === docCache.getActiveFilePath()) {
293+
restoreSelection(document.getElementById("viewer-content"), getState().currentContent,
294+
data.selectionId);
295+
}
296+
break;
297+
case "MDVIEWR_RENDERED_LINES":
298+
if (event.source === window.parent) {
299+
sendToParent("mdviewrRenderedLines", {requestId: data.requestId,
300+
result: getRenderedMdLineText(data.params)});
301+
}
302+
break;
281303
case "MDVIEWR_SET_CONTENT":
282304
handleSetContent(data);
283305
break;
@@ -550,6 +572,20 @@ export function initBridge() {
550572
}, true);
551573

552574
// Listen for content changes from editor (debounced by editor.js)
575+
on("ai:attach-selection", ({rect, titlebar}) => {
576+
if (flushPendingContentChange()) {
577+
emit("editor:source-lines", getState().currentContent);
578+
}
579+
const content = document.getElementById("viewer-content");
580+
const selection = captureSelection(content, getState().currentContent, docCache.getActiveFilePath());
581+
if (selection) {
582+
sendToParent("mdviewrAskAI", {selection, rect, filePath: docCache.getActiveFilePath()});
583+
} else {
584+
if (titlebar) { sendToParent("mdviewrAskAIEmpty", {}); }
585+
else { emit("ai:selection-unavailable"); }
586+
}
587+
});
588+
553589
on("bridge:contentChanged", ({ markdown }) => {
554590
if (_suppressContentChange) return;
555591
_syncId++;

‎src-mdviewer/src/components/editor.js‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { gfm } from "turndown-plugin-gfm";
55
import { on, emit } from "../core/events.js";
66
import { getState, setState } from "../core/state.js";
77
import { t, tp } from "../core/i18n.js";
8-
import { initFormatBar, destroyFormatBar, focusFormatBar } from "./format-bar.js";
8+
import { initFormatBar, focusFormatBar } from "./format-bar.js";
99
import { initSlashMenu, destroySlashMenu, isSlashMenuVisible } from "./slash-menu.js";
1010
import { initLinkPopover, destroyLinkPopover } from "./link-popover.js";
1111
import { initImagePopover, destroyImagePopover } from "./image-popover.js";
@@ -1853,12 +1853,14 @@ function _updateSourceLineAttrs(contentEl, markdown) {
18531853
mdLineIdx++;
18541854
}
18551855
}
1856+
el.setAttribute("data-source-end-line", String(Math.min(mdLines.length, mdLineIdx)));
18561857
}
18571858
}
18581859

18591860
function emitContentChange(contentEl) {
18601861
clearTimeout(contentChangeTimer);
18611862
contentChangeTimer = setTimeout(() => {
1863+
contentChangeTimer = null;
18621864
const markdown = convertToMarkdown(contentEl);
18631865
emit("bridge:contentChanged", { markdown });
18641866
}, CONTENT_CHANGE_DEBOUNCE);
@@ -1868,6 +1870,7 @@ function emitContentChange(contentEl) {
18681870
* Flush any pending debounced content-change emission immediately.
18691871
* Called during file switch so the outgoing file's edits are synced
18701872
* to its cache entry and CM document before switching away.
1873+
* @return {boolean} Whether a pending edit was emitted synchronously.
18711874
*/
18721875
export function flushPendingContentChange() {
18731876
if (contentChangeTimer) {
@@ -1877,8 +1880,10 @@ export function flushPendingContentChange() {
18771880
if (contentEl) {
18781881
const markdown = convertToMarkdown(contentEl);
18791882
emit("bridge:contentChanged", { markdown });
1883+
return true;
18801884
}
18811885
}
1886+
return false;
18821887
}
18831888

18841889
function getContentEl() {
@@ -2781,7 +2786,6 @@ function cleanupEditMode(content) {
27812786
_dragEndHandler = null;
27822787
}
27832788

2784-
destroyFormatBar();
27852789
destroyLinkPopover();
27862790
destroyImagePopover();
27872791
destroyLangPicker();

‎src-mdviewer/src/components/format-bar.js‎

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { on, emit } from "../core/events.js";
1111
import { getSelectionRect } from "./editor.js";
1212
import { t, tp } from "../core/i18n.js";
13+
import { getState } from "../core/state.js";
1314

1415
const isMac = /Mac|iPhone|iPad/.test(navigator.platform);
1516
const mod = isMac ? "\u2318" : "Ctrl";
@@ -19,6 +20,22 @@ let contentEl = null;
1920
let rafId = null;
2021
let linkMode = false;
2122
let savedRange = null;
23+
let askAIEnabled = false;
24+
let initialized = false;
25+
26+
on("ai:enabled", enabled => {
27+
askAIEnabled = enabled;
28+
initFormatBar(document.getElementById("viewer-content"));
29+
buildBar();
30+
updatePosition();
31+
});
32+
on("state:editMode", () => {
33+
if (initialized) { hide(); buildBar(); }
34+
});
35+
on("ai:selection-unavailable", () => {
36+
const button = document.getElementById("fb-ask-ai");
37+
if (button) { button.textContent = t("format.selection_unavailable"); }
38+
});
2239

2340
const buttons = [
2441
{ id: "fb-bold", icon: "bold", command: "bold", tooltipKey: "format.bold", stateKey: "bold" },
@@ -36,13 +53,17 @@ function buildBar() {
3653

3754
let html = '<div class="format-bar-buttons">';
3855
for (const btn of buttons) {
56+
if (!getState().editMode) { continue; }
3957
if (btn === null) {
4058
html += '<div class="toolbar-divider"></div>';
4159
} else {
4260
const tooltip = tp(btn.tooltipKey, { mod });
4361
html += `<button class="toolbar-btn format-btn" id="${btn.id}" data-tooltip="${tooltip}" aria-pressed="false" tabindex="-1"><i data-lucide="${btn.icon}"></i></button>`;
4462
}
4563
}
64+
if (askAIEnabled) {
65+
html += '<button class="toolbar-btn format-ask-ai" id="fb-ask-ai" tabindex="-1"></button>';
66+
}
4667
html += "</div>";
4768

4869
// Link input (hidden by default)
@@ -53,6 +74,16 @@ function buildBar() {
5374

5475
bar.innerHTML = html;
5576

77+
const askAI = document.getElementById("fb-ask-ai");
78+
if (askAI) {
79+
askAI.textContent = t("format.ask_ai");
80+
askAI.addEventListener("mousedown", event => event.preventDefault());
81+
askAI.addEventListener("click", () => {
82+
const rect = askAI.getBoundingClientRect();
83+
emit("ai:attach-selection", {rect: {x: rect.left + rect.width / 2, y: rect.top + rect.height / 2}});
84+
});
85+
}
86+
5687
createIcons({
5788
icons: { Bold, Italic, Strikethrough, Underline, Code, Link },
5889
attrs: { class: "" },
@@ -201,6 +232,8 @@ function updatePosition() {
201232
if (rafId) cancelAnimationFrame(rafId);
202233
rafId = requestAnimationFrame(() => {
203234
rafId = null;
235+
contentEl = document.getElementById("viewer-content");
236+
if (!getState().editMode && !askAIEnabled) { hide(); return; }
204237
if (linkMode) return; // don't reposition while editing link
205238
const sel = window.getSelection();
206239
if (!sel || sel.isCollapsed || !sel.rangeCount) {
@@ -227,7 +260,9 @@ function updatePosition() {
227260
// Skip if selection is inside a code block — formatting doesn't apply
228261
const anchorEl = sel.anchorNode.nodeType === Node.ELEMENT_NODE
229262
? sel.anchorNode : sel.anchorNode.parentElement;
230-
if (anchorEl && anchorEl.closest("pre")) {
263+
const inCode = !!(anchorEl && anchorEl.closest("pre"));
264+
bar.classList.toggle("ai-code-selection", inCode);
265+
if (inCode && !askAIEnabled) {
231266
hide();
232267
return;
233268
}
@@ -266,13 +301,15 @@ function onSelectionState(state) {
266301

267302
export function initFormatBar(editorEl) {
268303
contentEl = editorEl;
304+
if (initialized) { buildBar(); return; }
305+
initialized = true;
269306
buildBar();
270307

271308
document.addEventListener("selectionchange", updatePosition);
272309
document.addEventListener("mousedown", onDocumentMousedown);
273310
// Fallback for WebKitGTK
274-
contentEl.addEventListener("mouseup", updatePosition);
275-
contentEl.addEventListener("keyup", updatePosition);
311+
document.addEventListener("mouseup", updatePosition);
312+
document.addEventListener("keyup", updatePosition);
276313
// Dismiss on scroll
277314
const appViewer = document.getElementById("app-viewer");
278315
if (appViewer) {
@@ -286,13 +323,12 @@ export function initFormatBar(editorEl) {
286323
}
287324

288325
export function destroyFormatBar() {
326+
initialized = false;
289327
hide();
290328
document.removeEventListener("selectionchange", updatePosition);
291329
document.removeEventListener("mousedown", onDocumentMousedown);
292-
if (contentEl) {
293-
contentEl.removeEventListener("mouseup", updatePosition);
294-
contentEl.removeEventListener("keyup", updatePosition);
295-
}
330+
document.removeEventListener("mouseup", updatePosition);
331+
document.removeEventListener("keyup", updatePosition);
296332
if (bar) bar.innerHTML = "";
297333
contentEl = null;
298334
linkMode = false;

0 commit comments

Comments
 (0)