Popover Selection
문장 안에서 선택한 텍스트 근처에 Popover를 띄워 문맥 작업을 이어갑니다.
문장 선택
정기 점검 공지는 고객이 바로 이해할 수 있도록 영향 시간, 대체 경로, 담당 팀을 같은 문단에서 설명해요. 필요한 문구만 선택하면 문맥을 유지한 채 후속 작업을 붙일 수 있어요.
<script lang="ts">
import * as Popover from '@odbd/svelte/popover'
interface AnchorRect {
x: number
y: number
width: number
height: number
}
let selectionElement = $state<HTMLElement | null>(null)
let popoverOpen = $state(false)
let selectedText = $state('')
let anchorRect = $state<AnchorRect>({ x: 0, y: 0, width: 1, height: 1 })
const positioning = $derived({
placement: 'top' as const,
strategy: 'fixed' as const,
gutter: 8,
getAnchorRect: () => anchorRect,
})
const selectionBelongsToText = (selection: Selection) => {
if (!selectionElement || !selection.anchorNode || !selection.focusNode) return false
return (
selectionElement.contains(selection.anchorNode) &&
selectionElement.contains(selection.focusNode)
)
}
const clearSelection = () => {
// 남은 선택 영역 위에서 다시 드래그하면 브라우저가 새 선택 대신 선택
// 텍스트의 네이티브 드래그를 시작해 popover가 다시는 열리지 않는다 —
// 닫을 때 선택을 비워야 다음 드래그가 선택으로 동작한다.
window.getSelection()?.removeAllRanges()
}
const closePopover = () => {
popoverOpen = false
selectedText = ''
clearSelection()
}
const syncSelection = () => {
const selection = window.getSelection()
if (!selection || selection.isCollapsed || !selectionBelongsToText(selection)) {
closePopover()
return
}
const text = selection.toString().trim()
if (!text) {
closePopover()
return
}
const range = selection.getRangeAt(0)
const rect =
Array.from(range.getClientRects()).find((candidate) => candidate.width > 0) ??
range.getBoundingClientRect()
if (rect.width <= 0 && rect.height <= 0) {
closePopover()
return
}
anchorRect = {
x: rect.x,
y: rect.y,
width: Math.max(rect.width, 1),
height: Math.max(rect.height, 1),
}
selectedText = text.length > 32 ? `${text.slice(0, 32)}...` : text
popoverOpen = true
}
const updateOpen = (details: { open: boolean }) => {
popoverOpen = details.open
if (!details.open) {
selectedText = ''
clearSelection()
}
}
</script>
<div class="selection-popover">
<p class="selection-popover__eyebrow">문장 선택</p>
<Popover.Root
open={popoverOpen}
autoFocus={false}
restoreFocus={false}
{positioning}
onOpenChange={updateOpen}
>
<div
bind:this={selectionElement}
class="selection-popover__text"
role="textbox"
aria-label="공지 문단"
aria-multiline="true"
aria-readonly="true"
tabindex="0"
onpointerup={syncSelection}
onkeyup={syncSelection}
>
<p>
정기 점검 공지는 고객이 바로 이해할 수 있도록 영향 시간, 대체 경로, 담당 팀을 같은 문단에서
설명해요. 필요한 문구만 선택하면 문맥을 유지한 채 후속 작업을 붙일 수 있어요.
</p>
</div>
<Popover.Positioner>
<Popover.Content class="selection-popover__content">
<Popover.Arrow>
<Popover.ArrowTip />
</Popover.Arrow>
<Popover.Title>선택한 문구</Popover.Title>
<Popover.Description class="selection-popover__quote">
“{selectedText}”
</Popover.Description>
<div class="selection-popover__actions" aria-label="선택 문구 작업">
<Popover.CloseTrigger class="odbd-button" data-variant="ghost" data-size="sm">
메모
</Popover.CloseTrigger>
<Popover.CloseTrigger class="odbd-button" data-variant="ghost" data-size="sm">
하이라이트
</Popover.CloseTrigger>
<Popover.CloseTrigger class="odbd-button" data-variant="solid" data-size="sm">
작업 만들기
</Popover.CloseTrigger>
</div>
</Popover.Content>
</Popover.Positioner>
</Popover.Root>
</div>
<style>
.selection-popover {
display: grid;
width: min(100%, 30rem);
gap: var(--odbd-space-3);
}
.selection-popover__eyebrow {
margin: 0;
color: var(--odbd-color-muted-foreground);
font-size: var(--odbd-font-size-sm);
font-weight: 650;
}
.selection-popover__text {
padding: var(--odbd-space-4);
color: var(--odbd-color-foreground);
background: var(--odbd-color-surface-raised);
border: 1px solid var(--odbd-color-border);
border-radius: var(--odbd-radius-md);
cursor: text;
line-height: 1.75;
outline: none;
user-select: text;
}
.selection-popover__text p {
margin: 0;
}
.selection-popover__text:focus-visible {
outline: 2px solid var(--odbd-color-focus);
outline-offset: 2px;
}
.selection-popover__text::selection {
color: var(--odbd-color-accent-foreground);
background: var(--odbd-color-accent);
}
:global(.selection-popover__content) {
width: min(18rem, calc(100vw - var(--odbd-space-8)));
}
:global(.selection-popover__quote) {
padding: var(--odbd-space-3);
color: var(--odbd-color-foreground);
background: var(--odbd-color-muted);
border-inline-start: 3px solid var(--odbd-color-accent);
border-radius: var(--odbd-radius-sm);
}
.selection-popover__actions {
display: flex;
flex-wrap: wrap;
gap: var(--odbd-space-2);
}
</style>