mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 10:57:07 +00:00
fix(app): keep the active match visible and drop the Find button
The floating widget covered the active match whenever the match sat under it and the document was too short to scroll clear. CodeMirror's own panel scroll margin cannot reveal a first line, so the file integration now owns placement: the widget renders as an overlay beside the editor, the model measures it against the active match, and flips it to the opposite corner only when the current corner collides. Hysteresis keeps navigation from bouncing the widget between corners. Also removes the standalone Find button shown while Find is closed, per user direction. Cmd/Ctrl+F is the entry point.
This commit is contained in:
@@ -6,15 +6,10 @@ import {
|
||||
useSyncExternalStore,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { Search } from "lucide-react-native";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { View, type View as ViewInstance } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { mutedIconColorMapping } from "@/components/ui/icon-button-chrome";
|
||||
import { paneContentToolbarIconSize, ToolbarButton } from "@/components/ui/pane-content-toolbar";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { PaneFind, type PaneFindHandle } from "@/pane-find";
|
||||
import { usePaneFocus } from "@/panels/pane-context";
|
||||
import { hasActiveWebOverlay } from "@/lib/overlay-root";
|
||||
@@ -24,8 +19,6 @@ import { FileFindModel } from "./model.web";
|
||||
|
||||
export { FileFindModel } from "./model.web";
|
||||
|
||||
const SearchIcon = withUnistyles(Search, mutedIconColorMapping);
|
||||
|
||||
export function FileFind({
|
||||
model,
|
||||
editor,
|
||||
@@ -35,16 +28,14 @@ export function FileFind({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const widget = useRef<PaneFindHandle>(null);
|
||||
const { isInteractive, focusPane } = usePaneFocus();
|
||||
const { isInteractive } = usePaneFocus();
|
||||
const active = useRetainedPanelActive();
|
||||
const open = useCallback(() => {
|
||||
focusPane();
|
||||
model.open(editor.current);
|
||||
widget.current?.focus();
|
||||
}, [editor, focusPane, model]);
|
||||
|
||||
// RN Web hands the underlying DOM element to a View ref; the model measures it.
|
||||
const setWidgetNode = useCallback(
|
||||
(node: ViewInstance | null) => model.setWidgetNode(node as unknown as HTMLElement | null),
|
||||
[model],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isInteractive || !active) return;
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
@@ -77,14 +68,7 @@ export function FileFind({
|
||||
},
|
||||
[model, state.readOnly, state.replacement],
|
||||
);
|
||||
if (!state.panel)
|
||||
return (
|
||||
<View style={styles.trigger}>
|
||||
<ToolbarButton label={t("paneFind.title")} compact={isCompact} onPress={open}>
|
||||
<SearchIcon size={paneContentToolbarIconSize(isCompact)} />
|
||||
</ToolbarButton>
|
||||
</View>
|
||||
);
|
||||
if (!state.open) return null;
|
||||
const total = `${state.total}${state.limited ? "+" : ""}`;
|
||||
let status = "";
|
||||
if (state.query) {
|
||||
@@ -92,37 +76,40 @@ export function FileFind({
|
||||
else if (state.current) status = t("paneFind.position", { current: state.current, total });
|
||||
else status = t("paneFind.total", { total });
|
||||
}
|
||||
return createPortal(
|
||||
<View style={styles.panel}>
|
||||
<PaneFind
|
||||
ref={widget}
|
||||
query={state.query}
|
||||
status={status}
|
||||
canNavigate={state.total > 0}
|
||||
onQueryChange={model.setSearch}
|
||||
onNext={model.next}
|
||||
onPrevious={model.previous}
|
||||
onClose={model.close}
|
||||
replace={replace}
|
||||
/>
|
||||
</View>,
|
||||
state.panel,
|
||||
return (
|
||||
<View
|
||||
style={[styles.overlay, state.placement === "top" ? styles.overlayTop : styles.overlayBottom]}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<View ref={setWidgetNode} style={styles.widget}>
|
||||
<PaneFind
|
||||
ref={widget}
|
||||
query={state.query}
|
||||
status={status}
|
||||
canNavigate={state.total > 0}
|
||||
onQueryChange={model.setSearch}
|
||||
onNext={model.next}
|
||||
onPrevious={model.previous}
|
||||
onClose={model.close}
|
||||
replace={replace}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
panel: { alignItems: "flex-end", maxWidth: "100%", padding: theme.spacing[2] },
|
||||
// Carries the open widget's chrome so the entry point reads as the same object.
|
||||
trigger: {
|
||||
// The widget floats over the content; the model flips the corner when it would
|
||||
// otherwise sit on top of the active match.
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: theme.spacing[2],
|
||||
left: theme.spacing[2],
|
||||
right: theme.spacing[2],
|
||||
alignItems: "flex-end",
|
||||
zIndex: 1,
|
||||
padding: theme.spacing[1],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
...theme.shadow.md,
|
||||
},
|
||||
// Bounds the widget to the pane; PaneFind's own maxWidth resolves against this.
|
||||
widget: { maxWidth: "100%" },
|
||||
overlayTop: { top: theme.spacing[2] },
|
||||
overlayBottom: { bottom: theme.spacing[2] },
|
||||
}));
|
||||
|
||||
@@ -16,13 +16,19 @@ import {
|
||||
|
||||
const MATCH_STATUS_LIMIT = 10_000;
|
||||
|
||||
/** Which corner of the editor the floating Find widget occupies. */
|
||||
export type FindPlacement = "top" | "bottom";
|
||||
|
||||
/** CodeMirror owns query, matching, selection, replacement, and panel lifetime. */
|
||||
export class FileFindModel {
|
||||
private view: EditorView | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
private matches: Array<{ from: number; to: number }> = [];
|
||||
private widget: HTMLElement | null = null;
|
||||
private widgetResize: ResizeObserver | null = null;
|
||||
private snapshot = {
|
||||
panel: null as HTMLElement | null,
|
||||
open: false,
|
||||
placement: "top" as FindPlacement,
|
||||
query: "",
|
||||
replacement: "",
|
||||
current: 0,
|
||||
@@ -39,22 +45,14 @@ export class FileFindModel {
|
||||
readonly getSnapshot = () => this.snapshot;
|
||||
|
||||
readonly extension: Extension = [
|
||||
EditorView.theme({
|
||||
// CodeMirror lays panels out as flex siblings of the scroller, which pushes
|
||||
// the first lines down every time Find opens. Take the panel out of flow so
|
||||
// the widget floats over the top-right of the content instead.
|
||||
".cm-panels.cm-panels-top": {
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
right: "0",
|
||||
left: "auto",
|
||||
maxWidth: "100%",
|
||||
zIndex: "4",
|
||||
border: "none",
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
// The custom panel lives inside the editor's monospace subtree.
|
||||
".paseo-file-find, .paseo-file-find *": { fontFamily: "var(--paseo-ui-font)" },
|
||||
// The widget floats over the editor as a sibling overlay, so CodeMirror's own
|
||||
// panel slot stays empty and contributes no layout or scroll margin of its own.
|
||||
EditorView.theme({ ".cm-panels": { display: "none" } }),
|
||||
// Reveal matches clear of the widget instead of underneath it.
|
||||
EditorView.scrollMargins.of((view) => {
|
||||
const clearance = this.clearance(view);
|
||||
if (!clearance) return null;
|
||||
return this.snapshot.placement === "top" ? { top: clearance } : { bottom: clearance };
|
||||
}),
|
||||
search({ literal: true, top: true, createPanel: (view) => this.createPanel(view) }),
|
||||
EditorState.transactionExtender.of((transaction) => {
|
||||
@@ -108,6 +106,21 @@ export class FileFindModel {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The widget renders outside CodeMirror, so the editor cannot see it. Handing the
|
||||
* node here lets this model — which already owns reveal — measure the obstruction.
|
||||
*/
|
||||
readonly setWidgetNode = (node: HTMLElement | null) => {
|
||||
if (this.widget === node) return;
|
||||
this.widgetResize?.disconnect();
|
||||
this.widgetResize = null;
|
||||
this.widget = node;
|
||||
if (!node) return;
|
||||
this.widgetResize = new ResizeObserver(() => this.reposition());
|
||||
this.widgetResize.observe(node);
|
||||
this.reposition();
|
||||
};
|
||||
|
||||
private createPanel(view: EditorView): Panel {
|
||||
this.view = view;
|
||||
const dom = document.createElement("div");
|
||||
@@ -116,21 +129,90 @@ export class FileFindModel {
|
||||
dom,
|
||||
top: true,
|
||||
mount: () => {
|
||||
this.snapshot = { ...this.snapshot, panel: dom };
|
||||
this.snapshot = { ...this.snapshot, open: true, placement: "top" };
|
||||
this.update(view, true);
|
||||
},
|
||||
update: (update: ViewUpdate) => {
|
||||
const queryChanged = !getSearchQuery(update.startState).eq(getSearchQuery(update.state));
|
||||
if (update.docChanged || update.selectionSet || queryChanged)
|
||||
if (
|
||||
update.docChanged ||
|
||||
update.selectionSet ||
|
||||
queryChanged ||
|
||||
update.geometryChanged ||
|
||||
update.viewportChanged
|
||||
)
|
||||
this.update(view, update.docChanged || queryChanged);
|
||||
},
|
||||
destroy: () => {
|
||||
this.snapshot = { ...this.snapshot, panel: null };
|
||||
this.snapshot = { ...this.snapshot, open: false, placement: "top" };
|
||||
this.publish();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Vertical space the widget takes out of the editor, including its inset. */
|
||||
private clearance(view: EditorView): number {
|
||||
const widget = this.widget;
|
||||
if (!widget || !this.snapshot.open) return 0;
|
||||
const box = widget.getBoundingClientRect();
|
||||
if (!box.height) return 0;
|
||||
const editor = view.scrollDOM.getBoundingClientRect();
|
||||
const clearance =
|
||||
this.snapshot.placement === "top" ? box.bottom - editor.top : editor.bottom - box.top;
|
||||
return Math.max(0, clearance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the widget to the opposite corner when it sits on top of the active match
|
||||
* and the editor has no scroll range left to reveal it. Placement only changes
|
||||
* when the current corner collides, so navigating matches never makes it bounce.
|
||||
*
|
||||
* Measurement runs through `requestMeasure` because CodeMirror refuses layout
|
||||
* reads while an update is in progress, and every caller here sits inside one.
|
||||
*/
|
||||
private reposition() {
|
||||
const view = this.view;
|
||||
if (!view || !this.widget || !this.snapshot.open) return;
|
||||
view.requestMeasure<FindPlacement>({
|
||||
key: this,
|
||||
read: () => this.measurePlacement(view),
|
||||
write: (placement) => {
|
||||
if (placement === this.snapshot.placement) return;
|
||||
this.snapshot = { ...this.snapshot, placement };
|
||||
this.publish();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private measurePlacement(view: EditorView): FindPlacement {
|
||||
const current = this.snapshot.placement;
|
||||
const widget = this.widget;
|
||||
if (!widget) return current;
|
||||
const selection = view.state.selection.main;
|
||||
if (selection.empty) return current;
|
||||
const from = view.coordsAtPos(selection.from);
|
||||
const to = view.coordsAtPos(selection.to);
|
||||
if (!from || !to) return current;
|
||||
const clearance = this.clearance(view);
|
||||
if (!clearance) return current;
|
||||
const box = widget.getBoundingClientRect();
|
||||
const editor = view.scrollDOM.getBoundingClientRect();
|
||||
const match = {
|
||||
top: Math.min(from.top, to.top),
|
||||
bottom: Math.max(from.bottom, to.bottom),
|
||||
right: Math.max(from.right, to.right),
|
||||
};
|
||||
const hits = (placement: FindPlacement) => {
|
||||
if (match.right <= box.left) return false;
|
||||
return placement === "top"
|
||||
? match.top < editor.top + clearance && match.bottom > editor.top
|
||||
: match.bottom > editor.bottom - clearance && match.top < editor.bottom;
|
||||
};
|
||||
if (!hits(current)) return current;
|
||||
const other: FindPlacement = current === "top" ? "bottom" : "top";
|
||||
return hits(other) ? current : other;
|
||||
}
|
||||
|
||||
private update(view: EditorView, recount: boolean) {
|
||||
const query = getSearchQuery(view.state);
|
||||
let limited = this.snapshot.limited;
|
||||
@@ -163,6 +245,7 @@ export class FileFindModel {
|
||||
readOnly: view.state.readOnly,
|
||||
};
|
||||
this.publish();
|
||||
this.reposition();
|
||||
}
|
||||
|
||||
private publish() {
|
||||
|
||||
Reference in New Issue
Block a user