233 lines
7.3 KiB
Vue
233 lines
7.3 KiB
Vue
<script setup lang="ts">
|
|
import { nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
|
import { ChevronDown, ChevronUp, GripVertical, X } from "@lucide/vue";
|
|
import { useI18n } from "vue-i18n";
|
|
import { isTextContentSearchDragSource } from "@/lib/common/textContentSearch";
|
|
|
|
/**
|
|
* Floating find panel (EditorSearchPanel look).
|
|
* Uses absolute + translate (not fixed) so it works inside Reka Dialog focus traps
|
|
* and is not clipped by dialog overflow / transform containing blocks.
|
|
*/
|
|
defineOptions({ name: "TextContentSearchBar" });
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
modelValue: string;
|
|
status: string;
|
|
matchCount: number;
|
|
showNavigation?: boolean;
|
|
placeholder?: string;
|
|
}>(),
|
|
{
|
|
showNavigation: true,
|
|
placeholder: undefined,
|
|
},
|
|
);
|
|
|
|
const emit = defineEmits<{
|
|
"update:modelValue": [value: string];
|
|
activate: [delta: -1 | 1];
|
|
prev: [];
|
|
next: [];
|
|
close: [];
|
|
}>();
|
|
|
|
const { t } = useI18n();
|
|
|
|
const inputRef = ref<HTMLInputElement | null>(null);
|
|
/** Drag offset from the default top-right anchor (absolute right/top). */
|
|
const offsetX = ref(0);
|
|
const offsetY = ref(0);
|
|
const dragging = ref(false);
|
|
|
|
let dragPointerId: number | null = null;
|
|
let startClientX = 0;
|
|
let startClientY = 0;
|
|
let originX = 0;
|
|
let originY = 0;
|
|
|
|
function resolvedPlaceholder() {
|
|
return props.placeholder ?? t("editor.search.find");
|
|
}
|
|
|
|
function onEnter(event: KeyboardEvent) {
|
|
emit("activate", event.shiftKey ? -1 : 1);
|
|
}
|
|
|
|
function detachDragListeners() {
|
|
window.removeEventListener("pointermove", onDragMove, true);
|
|
window.removeEventListener("pointerup", onDragEnd, true);
|
|
window.removeEventListener("pointercancel", onDragEnd, true);
|
|
}
|
|
|
|
function onDragMove(event: PointerEvent) {
|
|
if (!dragging.value) return;
|
|
if (dragPointerId != null && event.pointerId !== dragPointerId) return;
|
|
event.preventDefault();
|
|
offsetX.value = originX + (event.clientX - startClientX);
|
|
offsetY.value = originY + (event.clientY - startClientY);
|
|
}
|
|
|
|
function onDragEnd(event: PointerEvent) {
|
|
if (dragPointerId != null && event.pointerId !== dragPointerId) return;
|
|
event.preventDefault();
|
|
dragging.value = false;
|
|
dragPointerId = null;
|
|
document.body.classList.remove("dbx-search-panel-dragging");
|
|
detachDragListeners();
|
|
}
|
|
|
|
function startDrag(event: PointerEvent) {
|
|
if (event.pointerType === "mouse" && event.button !== 0) return;
|
|
if (!isTextContentSearchDragSource(event.target)) return;
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
dragging.value = true;
|
|
dragPointerId = event.pointerId;
|
|
startClientX = event.clientX;
|
|
startClientY = event.clientY;
|
|
originX = offsetX.value;
|
|
originY = offsetY.value;
|
|
document.body.classList.add("dbx-search-panel-dragging");
|
|
|
|
window.addEventListener("pointermove", onDragMove, true);
|
|
window.addEventListener("pointerup", onDragEnd, true);
|
|
window.addEventListener("pointercancel", onDragEnd, true);
|
|
|
|
try {
|
|
(event.currentTarget as HTMLElement | null)?.setPointerCapture?.(event.pointerId);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
function focusInput(select = true) {
|
|
void nextTick(() => {
|
|
const el = inputRef.value;
|
|
if (!el) return;
|
|
el.focus({ preventScroll: true });
|
|
if (select) el.select();
|
|
});
|
|
}
|
|
|
|
onMounted(() => {
|
|
offsetX.value = 0;
|
|
offsetY.value = 0;
|
|
void nextTick(() => focusInput(true));
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
document.body.classList.remove("dbx-search-panel-dragging");
|
|
detachDragListeners();
|
|
});
|
|
|
|
defineExpose({ focusInput, inputEl: inputRef });
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
data-text-content-search
|
|
data-redis-value-search
|
|
data-draggable-search-panel
|
|
data-search-drag-chrome
|
|
class="dbx-text-search-panel absolute right-3 top-3 z-50 isolate flex flex-col gap-1 rounded-lg border border-border bg-popover p-1.5 text-popover-foreground shadow-xl ring-1 ring-border/60"
|
|
:class="{ 'is-dragging': dragging }"
|
|
:style="{ transform: `translate(${offsetX}px, ${offsetY}px)` }"
|
|
@pointerdown="startDrag"
|
|
>
|
|
<div class="flex items-center gap-1" data-search-drag-chrome>
|
|
<button type="button" data-drag-handle class="dbx-search-drag-handle flex h-8 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" :title="t('editor.search.find')" :aria-label="t('editor.search.find')" @pointerdown="startDrag">
|
|
<GripVertical class="pointer-events-none h-4 w-4" />
|
|
</button>
|
|
|
|
<div class="flex h-8 w-64 items-center rounded-md border border-input bg-background focus-within:border-ring focus-within:ring-1 focus-within:ring-ring" data-no-drag @pointerdown.stop>
|
|
<input
|
|
ref="inputRef"
|
|
data-text-content-search-input
|
|
data-redis-value-search-input
|
|
:value="modelValue"
|
|
autocapitalize="off"
|
|
autocomplete="off"
|
|
autocorrect="off"
|
|
spellcheck="false"
|
|
class="dbx-search-input h-full min-w-0 flex-1 bg-transparent px-2 text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
|
:placeholder="resolvedPlaceholder()"
|
|
@mousedown.stop
|
|
@pointerdown.stop
|
|
@click.stop
|
|
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
|
@keydown.enter.prevent="onEnter"
|
|
@keydown.escape.prevent.stop="emit('close')"
|
|
/>
|
|
</div>
|
|
|
|
<span data-search-drag-chrome class="min-w-[3.4rem] shrink-0 select-none text-center text-xs tabular-nums" :class="modelValue && matchCount === 0 ? 'text-destructive' : 'text-muted-foreground'">
|
|
{{ status }}
|
|
</span>
|
|
|
|
<template v-if="showNavigation">
|
|
<button
|
|
type="button"
|
|
data-no-drag
|
|
class="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
|
|
:title="t('editor.search.prevMatch')"
|
|
:disabled="matchCount === 0"
|
|
@pointerdown.stop
|
|
@click="emit('prev')"
|
|
>
|
|
<ChevronUp class="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-no-drag
|
|
class="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
|
|
:title="t('editor.search.nextMatch')"
|
|
:disabled="matchCount === 0"
|
|
@pointerdown.stop
|
|
@click="emit('next')"
|
|
>
|
|
<ChevronDown class="h-4 w-4" />
|
|
</button>
|
|
</template>
|
|
|
|
<button type="button" data-no-drag class="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" :title="t('editor.search.close')" @pointerdown.stop @click="emit('close')">
|
|
<X class="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.dbx-text-search-panel {
|
|
max-width: min(calc(100vw - 2rem), 620px);
|
|
}
|
|
|
|
.dbx-search-drag-handle {
|
|
cursor: grab;
|
|
touch-action: none;
|
|
user-select: none;
|
|
}
|
|
|
|
.dbx-search-input {
|
|
user-select: text;
|
|
touch-action: manipulation;
|
|
cursor: text;
|
|
}
|
|
|
|
.dbx-text-search-panel.is-dragging,
|
|
.dbx-text-search-panel.is-dragging .dbx-search-drag-handle {
|
|
cursor: grabbing;
|
|
user-select: none;
|
|
}
|
|
</style>
|
|
|
|
<style>
|
|
body.dbx-search-panel-dragging {
|
|
cursor: grabbing !important;
|
|
user-select: none !important;
|
|
}
|
|
</style>
|