Refining the speech-to-text conversion structure

This commit is contained in:
2026-09-22 11:02:08 +03:30
parent 17fb33b3e7
commit 4b335aa7ff
16 changed files with 634 additions and 333 deletions
@@ -0,0 +1,194 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
const props = withDefaults(
defineProps<{
open: boolean;
/** start = لبه راست تریگر در RTL؛ end = لبه چپ */
align?: 'start' | 'end';
}>(),
{ align: 'end' },
);
const emit = defineEmits<{
'update:open': [value: boolean];
}>();
const anchorRef = ref<HTMLElement | null>(null);
const panelRef = ref<HTMLElement | null>(null);
const panelStyle = ref<Record<string, string>>({});
function toggle() {
emit('update:open', !props.open);
}
function close() {
if (props.open) emit('update:open', false);
}
function onPanelClickCapture() {
// بعد از handler آیتم‌ها ببند (حتی اگر stopPropagation زده باشند)
queueMicrotask(() => close());
}
function place() {
const anchor = anchorRef.value;
const panel = panelRef.value;
if (!anchor || !panel) return;
const r = anchor.getBoundingClientRect();
const menuH = panel.offsetHeight || 140;
const menuW = panel.offsetWidth || 160;
const gap = 6;
const pad = 8;
const spaceBelow = window.innerHeight - r.bottom;
const openUp = spaceBelow < menuH + gap + pad && r.top > menuH + gap + pad;
let left =
props.align === 'start' ? r.left : r.right - menuW;
left = Math.max(pad, Math.min(left, window.innerWidth - menuW - pad));
let top = openUp ? r.top - menuH - gap : r.bottom + gap;
top = Math.max(pad, Math.min(top, window.innerHeight - menuH - pad));
panelStyle.value = {
position: 'fixed',
top: `${Math.round(top)}px`,
left: `${Math.round(left)}px`,
zIndex: 'var(--z-dropdown, 50)',
};
}
async function placeSoon() {
await nextTick();
place();
// بعد از رندر محتوا ارتفاع دقیق‌تر می‌شود
requestAnimationFrame(place);
}
watch(
() => props.open,
(open) => {
if (open) void placeSoon();
},
);
function onPointerDown(ev: PointerEvent) {
if (!props.open) return;
const t = ev.target as Node;
if (anchorRef.value?.contains(t) || panelRef.value?.contains(t)) return;
close();
}
function onKey(ev: KeyboardEvent) {
if (ev.key === 'Escape') close();
}
function onReposition() {
if (props.open) place();
}
onMounted(() => {
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('keydown', onKey);
window.addEventListener('resize', onReposition);
window.addEventListener('scroll', onReposition, true);
});
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('keydown', onKey);
window.removeEventListener('resize', onReposition);
window.removeEventListener('scroll', onReposition, true);
});
defineExpose({ toggle, close });
</script>
<template>
<span ref="anchorRef" class="ui-dd-anchor">
<slot name="trigger" :open="open" :toggle="toggle" :close="close" />
</span>
<Teleport to="body">
<div
v-if="open"
ref="panelRef"
class="ui-dd-panel"
role="menu"
:style="panelStyle"
@click.capture="onPanelClickCapture"
>
<slot :close="close" />
</div>
</Teleport>
</template>
<style scoped>
.ui-dd-anchor {
display: inline-flex;
vertical-align: middle;
}
.ui-dd-panel {
min-width: 10rem;
max-width: min(20rem, calc(100vw - 16px));
background: var(--surface, #fff);
border: 1px solid var(--line, #e2e8f0);
border-radius: var(--radius, 10px);
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.14);
padding: 0.35rem;
display: grid;
gap: 1px;
}
.ui-dd-panel :deep(button),
.ui-dd-panel :deep(a) {
display: block;
width: 100%;
box-sizing: border-box;
border: 0;
background: transparent;
text-align: right;
padding: 0.55rem 0.7rem;
border-radius: var(--radius, 10px);
font: inherit;
font-size: 0.875rem;
color: var(--ink, #0f172a);
cursor: pointer;
text-decoration: none;
line-height: 1.4;
}
.ui-dd-panel :deep(button:hover),
.ui-dd-panel :deep(a:hover),
.ui-dd-panel :deep(button.on),
.ui-dd-panel :deep(a.on) {
background: var(--accent-soft, #ecfdf8);
color: var(--accent-deep, #0f766e);
}
.ui-dd-panel :deep(button.danger),
.ui-dd-panel :deep(a.danger) {
color: #b91c1c;
}
.ui-dd-panel :deep(button.danger:hover),
.ui-dd-panel :deep(a.danger:hover) {
background: #fef2f2;
color: #991b1b;
}
.ui-dd-panel :deep(li) {
list-style: none;
margin: 0;
padding: 0;
}
.ui-dd-panel :deep(ul) {
list-style: none;
margin: 0;
padding: 0;
display: grid;
}
</style>
+1
View File
@@ -13,3 +13,4 @@ export { default as UiSearch } from './UiSearch.vue';
export { default as UiFilterBar } from './UiFilterBar.vue';
export { default as UiSegmented } from './UiSegmented.vue';
export { default as UiChart, CHART_PALETTE, type UiChartOption } from './UiChart.vue';
export { default as UiDropdownMenu } from './UiDropdownMenu.vue';