diff --git a/static/feedback-ui.js b/static/feedback-ui.js index 96e433a..47dd292 100644 --- a/static/feedback-ui.js +++ b/static/feedback-ui.js @@ -1,4 +1,4 @@ -// No content is persisted in the browser. Only a local pathname (no search or +// No feedback content is persisted in the browser. Only a local pathname (no search or // fragment) accompanies a plain-text report. The server enforces every limit. export const safeFeedbackPage = pathname => typeof pathname === 'string' && pathname.startsWith('/') && !pathname.startsWith('//') && @@ -6,14 +6,14 @@ export const safeFeedbackPage = pathname => typeof pathname === 'string' && const translations = { zh: { - open:'快速反馈', title:'快速反馈', + open:'快速反馈', title:'快速反馈', move:'轻点反馈,拖动调整位置', category:'反馈类型', bug:'遇到问题', idea:'改进建议', other:'其他', message:'详细描述', placeholder:'你遇到了什么?希望我们如何改进?', hint:'仅填写问题或建议。', submit:'提交反馈', sending:'提交中…', close:'关闭', success:'已收到反馈', limit:'反馈提交过于频繁,请稍后再试。', duplicate:'这条反馈已收到。', unavailable:'反馈暂不可用,请稍后重试。', error:'提交失败,请稍后重试。', count:'字', }, en: { - open:'Quick feedback', title:'Quick feedback', + open:'Quick feedback', title:'Quick feedback', move:'Click for feedback, drag to move', category:'Category', bug:'Something is broken', idea:'Suggestion', other:'Other', message:'Describe it', placeholder:'What happened? What would you like us to change?', hint:'Describe the problem or suggestion.', submit:'Send feedback', sending:'Sending…', close:'Close', success:'Feedback received', limit:'Too many reports. Please try again later.', @@ -31,6 +31,80 @@ export function mountQuickFeedback(doc = document, win = window) { const $ = selector => root.querySelector(selector); const launch = $('#feedback-launch'), panel = $('#feedback-panel'), form = $('#feedback-form'); const message = $('#feedback-message'), result = $('#feedback-result'), send = $('.feedback-send'); + const positionKey = 'hireferry-feedback-position'; + const clamp = (value, min, max) => Math.max(min, Math.min(value, Math.max(min, max))); + let position = null, drag = null, dragged = false; + try { + const saved = JSON.parse(win.localStorage.getItem(positionKey)); + if (saved && Number.isFinite(saved.x) && Number.isFinite(saved.y)) { + position = {x:clamp(saved.x, 0, 1), y:clamp(saved.y, 0, 1)}; + } + } catch { /* Moving the button also works without browser storage. */ } + const viewport = () => { + const view = win.visualViewport; + const left = (view?.offsetLeft ?? 0) + 16, top = (view?.offsetTop ?? 0) + 16; + return {left, top, width:Math.max(0, (view?.width ?? win.innerWidth) - 32), + height:Math.max(0, (view?.height ?? win.innerHeight) - 32)}; + }; + function layout() { + const area = viewport(); + if (position) { + Object.assign(launch.style, {right:'auto', bottom:'auto', + left:`${area.left + position.x * Math.max(0, area.width - launch.offsetWidth)}px`, + top:`${area.top + position.y * Math.max(0, area.height - launch.offsetHeight)}px`}); + } + if (panel.hidden) return; + const anchor = launch.getBoundingClientRect(); + const above = clamp(anchor.top - area.top - 12, 0, area.height); + const below = clamp(area.top + area.height - anchor.bottom - 12, 0, area.height); + const openAbove = above >= below; + panel.style.maxHeight = `${Math.min(640, openAbove ? above : below)}px`; + panel.style.maxWidth = `${area.width}px`; + const size = panel.getBoundingClientRect(); + const top = openAbove ? anchor.top - size.height - 12 : anchor.bottom + 12; + Object.assign(panel.style, {right:'auto', bottom:'auto', + left:`${clamp(anchor.right - size.width, area.left, area.left + area.width - size.width)}px`, + top:`${clamp(top, area.top, area.top + area.height - size.height)}px`}); + } + launch.addEventListener('pointerdown', event => { + if (!event.isPrimary || event.button !== 0 || drag) return; + const rect = launch.getBoundingClientRect(); + dragged = false; + drag = {id:event.pointerId, x:event.clientX, y:event.clientY, left:rect.left, top:rect.top}; + launch.setPointerCapture(event.pointerId); + }); + launch.addEventListener('pointermove', event => { + if (!drag || event.pointerId !== drag.id) return; + const dx = event.clientX - drag.x, dy = event.clientY - drag.y; + if (!dragged && Math.hypot(dx, dy) < 6) return; + dragged = true; + event.preventDefault(); + launch.classList.add('is-dragging'); + const area = viewport(); + const width = Math.max(0, area.width - launch.offsetWidth), height = Math.max(0, area.height - launch.offsetHeight); + position = {x:width ? clamp((drag.left + dx - area.left) / width, 0, 1) : 0, + y:height ? clamp((drag.top + dy - area.top) / height, 0, 1) : 0}; + layout(); + }); + function finishDrag(event) { + if (!drag || event.pointerId !== drag.id) return; + drag = null; + launch.classList.remove('is-dragging'); + if (launch.hasPointerCapture(event.pointerId)) launch.releasePointerCapture(event.pointerId); + if (dragged) { + // Store only relative button coordinates, so resizing keeps it in view. + try { win.localStorage.setItem(positionKey, JSON.stringify(position)); } catch { /* Optional preference. */ } + } + } + for (const type of ['pointerup','pointercancel','lostpointercapture']) launch.addEventListener(type, finishDrag); + win.addEventListener('resize', layout); + win.visualViewport?.addEventListener('resize', layout); + win.visualViewport?.addEventListener('scroll', layout); + if (win.ResizeObserver) { + const observer = new win.ResizeObserver(layout); + observer.observe(launch); + observer.observe(panel); + } let pending = false; const words = () => { try { return translations[win.localStorage.getItem('hireferry-language') === 'en' ? 'en' : 'zh']; } @@ -40,16 +114,23 @@ export function mountQuickFeedback(doc = document, win = window) { const t = words(); root.querySelectorAll('[data-feedback]').forEach(el => { el.textContent = t[el.dataset.feedback]; }); message.placeholder = t.placeholder; + launch.title = t.move; $('[data-feedback-close]').ariaLabel = t.close; } function toggle(open) { panel.hidden = !open; launch.setAttribute('aria-expanded', String(open)); - if (open) { translate(); message.focus(); } + if (open) { translate(); layout(); message.focus(); } else launch.focus(); } translate(); - launch.addEventListener('click', () => toggle(panel.hidden)); + layout(); + launch.addEventListener('click', event => { + // Keyboard and assistive-technology clicks have no pointer click count. + if (dragged && event.detail !== 0) { dragged = false; event.preventDefault(); return; } + dragged = false; + toggle(panel.hidden); + }); $('[data-feedback-close]').addEventListener('click', () => toggle(false)); doc.addEventListener('keydown', event => { if (event.key === 'Escape' && !panel.hidden) toggle(false); }); doc.addEventListener('pointerdown', event => { diff --git a/static/feedback.css b/static/feedback.css index 38a3b98..eae540d 100644 --- a/static/feedback.css +++ b/static/feedback.css @@ -1,6 +1,8 @@ /* One small, consistent feedback entrypoint on both SPA and OAuth pages. */ -.feedback-launch {position:fixed;z-index:80;right:24px;bottom:22px;display:inline-flex;align-items:center;gap:9px;min-height:45px;padding:11px 18px;border:0;border-radius:99px;background:var(--accent);color:var(--on-accent);box-shadow:0 7px 26px #0002;font:550 12px var(--font);cursor:pointer;} -.feedback-launch:hover {filter:brightness(.94);transform:translateY(-1px);} +.feedback-launch {position:fixed;z-index:80;right:24px;bottom:22px;display:inline-flex;align-items:center;gap:9px;width:max-content;min-height:45px;padding:11px 18px;border:0;border-radius:99px;background:var(--accent);color:var(--on-accent);box-shadow:0 7px 26px #0002;font:550 12px var(--font);cursor:grab;touch-action:none;user-select:none;-webkit-user-select:none;} +.feedback-launch:hover {filter:brightness(.94);} +.feedback-launch.is-dragging {cursor:grabbing;} +.feedback-launch:focus-visible {outline:2px solid var(--accent);outline-offset:4px;} .feedback-launch svg {width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:1.7;} .feedback-panel {position:fixed;z-index:81;right:24px;bottom:80px;width:min(380px,calc(100vw - 32px));max-height:min(640px,calc(100dvh - 100px));overflow-y:auto;padding:25px;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:18px;box-shadow:0 16px 70px #0003;} .feedback-panel-head {display:flex;justify-content:space-between;align-items:start;gap:12px;margin-bottom:12px;} @@ -17,7 +19,9 @@ .feedback-result {margin:12px 0 0;font-size:12px;line-height:1.8;color:var(--accent);overflow-wrap:anywhere;} .feedback-result.error {color:var(--danger);} @media(max-width:560px) { - .feedback-launch {right:16px;bottom:16px;min-height:44px;} + .feedback-launch {right:16px;bottom:calc(16px + env(safe-area-inset-bottom));min-height:44px;} .feedback-panel {right:16px;bottom:74px;padding:20px;max-height:calc(100dvh - 95px);} } -@media(prefers-reduced-motion:reduce) {.feedback-launch:hover {transform:none;}} +@media(max-width:760px) { + body:has(.workspace-layout) .feedback-launch {bottom:calc(78px + env(safe-area-inset-bottom));} +}