From a8aa4d5d43d2df36c4a8fe40b02b6f54b4e8298a Mon Sep 17 00:00:00 2001 From: Qijia Liu Date: Fri, 28 Jun 2024 13:24:04 -0400 Subject: [PATCH] =?UTF-8?q?scroll=20mode=20=F0=9F=93=9C=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- include/candidate_window.hpp | 53 +++++- include/webview_candidate_window.hpp | 6 +- page/api.ts | 61 ++++++- page/customize.ts | 12 ++ page/generic.scss | 81 +++++++-- page/global.d.ts | 12 +- page/macos.scss | 55 +++++- page/scroll.ts | 253 +++++++++++++++++++++++++++ page/ux.ts | 52 ++++-- preview/preview.mm | 9 +- src/webview_candidate_window.mm | 37 +++- tests/util.ts | 2 +- 12 files changed, 567 insertions(+), 66 deletions(-) create mode 100644 page/scroll.ts diff --git a/include/candidate_window.hpp b/include/candidate_window.hpp index 8864623..e64fe98 100644 --- a/include/candidate_window.hpp +++ b/include/candidate_window.hpp @@ -25,6 +25,28 @@ enum theme_t { system = 0, light = 1, dark = 2 }; enum writing_mode_t { horizontal_tb = 0, vertical_rl = 1, vertical_lr = 2 }; +enum scroll_state_t { none = 0, ready = 1, scrolling = 2 }; + +enum scroll_key_action_t { + one = 1, + two = 2, + three = 3, + four = 4, + five = 5, + six = 6, + up = 10, + down = 11, + left = 12, + right = 13, + home = 14, + end = 15, + page_up = 16, + page_down = 17, + expand = 18, + collapse = 19, + commit = 20 +}; + struct CandidateAction { int id; std::string text; @@ -47,8 +69,11 @@ class CandidateWindow { const formatted &auxUp, const formatted &auxDown) = 0; virtual void set_candidates(const std::vector &candidates, - int highlighted) = 0; - virtual void set_highlight_callback(std::function) = 0; + int highlighted, scroll_state_t scroll_state, + bool scroll_start, bool scroll_end) = 0; + virtual void scroll_key_action(scroll_key_action_t action) = 0; + virtual void + answer_actions(const std::vector &actions) = 0; virtual void set_theme(theme_t theme) = 0; virtual void set_writing_mode(writing_mode_t mode) = 0; virtual void set_style(const void *style) = 0; @@ -59,10 +84,14 @@ class CandidateWindow { init_callback = callback; } - void set_select_callback(std::function callback) { + void set_select_callback(std::function callback) { select_callback = callback; } + void set_highlight_callback(std::function callback) { + highlight_callback = callback; + } + void set_cursor_text(const std::string &text) { cursor_text_ = text; } void set_highlight_mark_text(const std::string &text) { highlight_mark_text_ = text; @@ -72,22 +101,32 @@ class CandidateWindow { page_callback = callback; } + void set_scroll_callback(std::function callback) { + scroll_callback = callback; + } + void set_paging_buttons(bool pageable, bool has_prev, bool has_next) { pageable_ = pageable; has_prev_ = has_prev; has_next_ = has_next; } - void - set_action_callback(std::function callback) { + void set_ask_actions_callback(std::function callback) { + ask_actions_callback = callback; + } + + void set_action_callback(std::function callback) { action_callback = callback; } protected: std::function init_callback = []() {}; - std::function select_callback = [](size_t) {}; + std::function select_callback = [](int) {}; + std::function highlight_callback = [](int) {}; std::function page_callback = [](bool) {}; - std::function action_callback = [](int, int) {}; + std::function scroll_callback = [](int, int) {}; + std::function ask_actions_callback = [](int) {}; + std::function action_callback = [](int, int) {}; std::string cursor_text_ = ""; std::string highlight_mark_text_ = ""; bool pageable_ = false; diff --git a/include/webview_candidate_window.hpp b/include/webview_candidate_window.hpp index fb0c811..e556a0e 100644 --- a/include/webview_candidate_window.hpp +++ b/include/webview_candidate_window.hpp @@ -20,8 +20,10 @@ class WebviewCandidateWindow : public CandidateWindow { const formatted &auxUp, const formatted &auxDown) override; void set_candidates(const std::vector &candidates, - int highlighted) override; - void set_highlight_callback(std::function) override {} + int highlighted, scroll_state_t scroll_state, + bool scroll_start, bool scroll_end) override; + void scroll_key_action(scroll_key_action_t action) override; + void answer_actions(const std::vector &actions) override; void set_theme(theme_t theme) override; void set_writing_mode(writing_mode_t mode) override; void set_style(const void *style) override; diff --git a/page/api.ts b/page/api.ts index 5407d30..c324b01 100644 --- a/page/api.ts +++ b/page/api.ts @@ -8,6 +8,7 @@ import { import { div, setActions, + answerActions, hideContextmenu, getHoverBehavior, getPagingButtonsStyle, @@ -19,6 +20,14 @@ import { } from './theme' import { setStyle } from './customize' import { fcitxLog } from './log' +import { + getScrollState, + setScrollState, + setScrollEnd, + recalculateScroll, + scrollKeyAction, + fetchComplete +} from './scroll' window.fcitxLog = fcitxLog window._onload && window._onload() @@ -83,19 +92,30 @@ const caretRight = common.replace('{}', '0 0 192 512').replace('{}', 'M0 384.662 const arrowBack = common.replace('{}', '0 0 24 24').replace('{}', 'M16.62 2.99a1.25 1.25 0 0 0-1.77 0L6.54 11.3a.996.996 0 0 0 0 1.41l8.31 8.31c.49.49 1.28.49 1.77 0s.49-1.28 0-1.77L9.38 12l7.25-7.25c.48-.48.48-1.28-.01-1.76z') const arrowForward = common.replace('{}', '0 0 24 24').replace('{}', 'M7.38 21.01c.49.49 1.28.49 1.77 0l8.31-8.31a.996.996 0 0 0 0-1.41L9.15 2.98c-.49-.49-1.28-.49-1.77 0s-.49 1.28 0 1.77L14.62 12l-7.25 7.25c-.48.48-.48 1.28.01 1.76z') -function setCandidates (cands: Candidate[], highlighted: number, markText: string, pageable: boolean, hasPrev: boolean, hasNext: boolean) { - hoverables.innerHTML = '' +function setCandidates (cands: Candidate[], highlighted: number, markText: string, pageable: boolean, hasPrev: boolean, hasNext: boolean, scrollState: SCROLL_STATE, scrollStart: boolean, scrollEnd: boolean) { + setScrollState(scrollState) + // Clear existing candidates when scroll continues. + if (scrollState !== 2 || scrollStart) { + hoverables.innerHTML = '' + hoverables.scrollTop = 0 // Otherwise last scroll position will be kept. + } else { + fetchComplete() + } + if (scrollState === 2) { + hoverables.classList.add('horizontal-scroll') + setScrollEnd(scrollEnd) + } else { + hoverables.classList.remove('horizontal-scroll') + } for (let i = 0; i < cands.length; ++i) { const candidate = div('candidate', 'hoverable') - if (i === 0) { + if (i === 0 && scrollState !== 2) { candidate.classList.add('candidate-first') - } else { - hoverables.append(divider()) } if (i === highlighted) { candidate.classList.add('highlighted', 'highlighted-original') } - if (i === cands.length - 1) { + if (i === cands.length - 1 && scrollState !== 2) { candidate.classList.add('candidate-last') } @@ -112,9 +132,9 @@ function setCandidates (cands: Candidate[], highlighted: number, markText: strin candidateInner.append(mark) } - if (cands[i].label) { + if (cands[i].label || scrollState === 2) { const label = div('label') - label.innerHTML = escapeWS(cands[i].label) + label.innerHTML = escapeWS(cands[i].label || '0') candidateInner.append(label) } @@ -130,11 +150,25 @@ function setCandidates (cands: Candidate[], highlighted: number, markText: strin candidate.append(candidateInner) hoverables.append(candidate) + + // No divider after last element in non-scroll mode, + // but for scroll mode it needs to fill the row when + // candidates are not enough. + if (scrollState === 2 || i !== cands.length - 1) { + hoverables.append(divider()) + } } setActions(cands.map(c => c.actions)) - if (pageable) { + if (scrollState === 1) { + hoverables.append(divider(true)) + const expand = div('expand', 'hoverable-inner') + expand.innerHTML = arrowForward + const paging = div('paging', 'scroll', 'hoverable') + paging.append(expand) + hoverables.append(paging) + } else if (scrollState === 0 && pageable) { const isArrow = getPagingButtonsStyle() === 'Arrow' hoverables.append(divider(true)) @@ -163,6 +197,10 @@ function setCandidates (cands: Candidate[], highlighted: number, markText: strin paging.appendChild(prev) paging.appendChild(next) hoverables.appendChild(paging) + } else if (scrollState === 2) { + window.requestAnimationFrame(() => { + recalculateScroll(scrollStart) + }) } for (const hoverable of hoverables.querySelectorAll('.hoverable')) { @@ -207,6 +245,9 @@ hoverables.addEventListener('mouseleave', () => { }) hoverables.addEventListener('wheel', e => { + if (getScrollState() === 2) { + return + } window._page((e).deltaY > 0) }) @@ -222,3 +263,5 @@ window.setAccentColor = setAccentColor window.setStyle = setStyle window.setWritingMode = setWritingMode window.copyHTML = copyHTML +window.scrollKeyAction = scrollKeyAction +window.answerActions = answerActions diff --git a/page/customize.ts b/page/customize.ts index 2892d58..154962d 100644 --- a/page/customize.ts +++ b/page/customize.ts @@ -125,6 +125,8 @@ const HEADER_LIGHT_BACKGROUND = `${PANEL_LIGHT} .header` const HOVERABLES_LIGHT_BACKGROUND = `${PANEL_LIGHT} .hoverables :is(.candidate, .paging)` const PANEL_LIGHT_DIVIDER_MIDDLE = `${PANEL_LIGHT} .hoverables .divider .divider-middle` const PANEL_LIGHT_DIVIDER_SIDE = `${PANEL_LIGHT} .hoverables .divider .divider-side` +const PANEL_LIGHT_SCROLL_DIVIDER = `${PANEL_LIGHT} .hoverables.horizontal-scroll .divider-middle` +const PANEL_LIGHT_SCROLL_TRACK = `${PANEL_LIGHT} .hoverables.horizontal-scroll::-webkit-scrollbar-track` const CURSOR_NO_TEXT_LIGHT = `${PANEL_LIGHT} .cursor.no-text` const HIGHLIGHT_MARK_LIGHT = `${PANEL_LIGHT} .highlighted .mark` @@ -148,6 +150,8 @@ const HEADER_DARK_BACKGROUND = lightToDark(HEADER_LIGHT_BACKGROUND) const HOVERABLES_DARK_BACKGROUND = lightToDark(HOVERABLES_LIGHT_BACKGROUND) const PANEL_DARK_DIVIDER_MIDDLE = lightToDark(PANEL_LIGHT_DIVIDER_MIDDLE) const PANEL_DARK_DIVIDER_SIDE = lightToDark(PANEL_LIGHT_DIVIDER_SIDE) +const PANEL_DARK_SCROLL_DIVIDER = lightToDark(PANEL_LIGHT_SCROLL_DIVIDER) +const PANEL_DARK_SCROLL_TRACK = lightToDark(PANEL_LIGHT_SCROLL_TRACK) const CURSOR_NO_TEXT_DARK = lightToDark(CURSOR_NO_TEXT_LIGHT) const HIGHLIGHT_MARK_DARK = lightToDark(HIGHLIGHT_MARK_LIGHT) @@ -241,6 +245,9 @@ export function setStyle (style: string) { rules[PANEL_LIGHT_DIVIDER_SIDE] = { 'background-color': lightBackgroundColor } + rules[PANEL_LIGHT_SCROLL_DIVIDER] = rules[PANEL_LIGHT_SCROLL_TRACK] = { + 'background-color': lightBackgroundColor + } rules[HIGHLIGHT_MARK_LIGHT] = { [markKey]: j.LightMode.HighlightMarkColor } @@ -276,6 +283,8 @@ export function setStyle (style: string) { PANEL_LIGHT, PANEL_LIGHT_DIVIDER_MIDDLE, PANEL_LIGHT_DIVIDER_SIDE, + PANEL_LIGHT_SCROLL_DIVIDER, + PANEL_LIGHT_SCROLL_TRACK, HIGHLIGHT_MARK_LIGHT ] if (j.Highlight.HoverBehavior === 'Add') { @@ -345,6 +354,9 @@ export function setStyle (style: string) { rules[PANEL_DARK_DIVIDER_SIDE] = { 'background-color': darkBackgroundColor } + rules[PANEL_DARK_SCROLL_DIVIDER] = rules[PANEL_DARK_SCROLL_TRACK] = { + 'background-color': darkBackgroundColor + } rules[HIGHLIGHT_MARK_DARK] = { [markKey]: j.DarkMode.HighlightMarkColor } diff --git a/page/generic.scss b/page/generic.scss index e28efa9..594e1be 100644 --- a/page/generic.scss +++ b/page/generic.scss @@ -32,6 +32,19 @@ body { } } +.candidate-inner { + display: flex; + gap: 6px; + align-items: center; /* English words have lower height */ + line-height: 1em; /* align label and candidates */ + position: relative; /* for absolute position of mark */ +} + +.label { + /* Label is usually a single number. Will look ugly when all parts have vertical writing mode. */ + writing-mode: horizontal-tb; +} + .hoverables { display: flex; @@ -50,30 +63,49 @@ body { &.horizontal { flex-direction: row; + .candidate { + /* When horizontal and there is multi-line candidate, + make sure other candidates are vertical centered. + Don't enable it for vertical. It will shrink highlight. */ + display: flex; + } + .divider { flex-direction: column; } } -} -.horizontal .candidate { - /* When horizontal and there is multi-line candidate, - make sure other candidates are vertical centered. - Don't enable it for vertical. It will shrink highlight. */ - display: flex; -} + &.horizontal-scroll { + max-block-size: 180px; /* If block-size, 2 rows will have 90px each. */ + inline-size: 400px; + flex-wrap: wrap; + overflow-y: auto; + overscroll-behavior: none; -.candidate-inner { - display: flex; - gap: 6px; - align-items: center; /* English words have lower height */ - line-height: 1em; /* align label and candidates */ - position: relative; /* for absolute position of mark */ + .candidate { + min-inline-size: 60px; + } + + .candidate-inner { + width: 100%; + } + + .label { + opacity: 0; + } + + .highlighted-row .label { + opacity: 1; + } + + .divider { + flex-grow: 1; + } + } } -.label { - /* Label is usually a single number. Will look ugly when all parts have vertical writing mode. */ - writing-mode: horizontal-tb; +:is(.vertical-rl, .vertical-lr) .paging svg { + transform: rotate(90deg); } .paging { @@ -88,10 +120,21 @@ body { block-size: 16px; inline-size: 16px; } -} -:is(.vertical-rl, .vertical-lr) .paging svg { - transform: rotate(90deg); + &.scroll { + .expand { + block-size: 18px; + inline-size: 18px; + display: flex; + justify-content: center; + align-items: center; + + svg { + transform: rotate(90deg); + width: 16px; + } + } + } } /* When horizontal, paging is shorter than candidates, so need to centralize them. */ diff --git a/page/global.d.ts b/page/global.d.ts index 4b3ec9a..b2658ba 100644 --- a/page/global.d.ts +++ b/page/global.d.ts @@ -11,18 +11,26 @@ declare global { actions: CandidateAction[] } + type SCROLL_STATE = 0 | 1 | 2 + type SCROLL_SELECT = 1 | 2 | 3 | 4 | 5 | 6 + type SCROLL_MOVE_HIGHLIGHT = 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 + type SCROLL_KEY_ACTION = SCROLL_SELECT | SCROLL_MOVE_HIGHLIGHT | 20 + interface Window { // C++ APIs that api.ts calls _onload?: () => void _log: (s: string) => void _copyHTML: (html: string) => void _select: (index: number) => void + _highlight: (index: number) => void _page: (next: boolean) => void + _scroll: (start: number, length: number) => void + _askActions: (index: number) => void _action: (index: number, id: number) => void _resize: (dx: number, dy: number, shadowTop: number, shadowRight: number, shadowBottom: number, shadowLeft: number, fullWidth: number, fullHeight: number, enlargedWidth: number, enlargedHeight: number, dragging: boolean) => void // JavaScript APIs that webview_candidate_window.mm calls - setCandidates: (cands: Candidate[], highlighted: number, markText: string, pageable: boolean, hasPrev: boolean, hasNext: boolean) => void + setCandidates: (cands: Candidate[], highlighted: number, markText: string, pageable: boolean, hasPrev: boolean, hasNext: boolean, scrollState: SCROLL_STATE, scrollStart: boolean, scrollEnd: boolean) => void setLayout: (layout: 0 | 1) => void updateInputPanel: (preeditHTML: string, auxUpHTML: string, auxDownHTML: string) => void resize: (dx: number, dy: number, dragging: boolean, hasContextmenu: boolean) => void @@ -31,6 +39,8 @@ declare global { setStyle: (style: string) => void setWritingMode: (mode: 0 | 1 | 2) => void copyHTML: () => void + scrollKeyAction: (action: SCROLL_KEY_ACTION) => void + answerActions: (actions: CandidateAction[]) => void // Utility functions globally available fcitxLog: (...args: unknown[]) => void diff --git a/page/macos.scss b/page/macos.scss index fe54e5f..93c63ef 100644 --- a/page/macos.scss +++ b/page/macos.scss @@ -7,6 +7,8 @@ $panel-border-color-light: rgb(0 0 0 / 17.5%); $vertical-border-color-light: rgba(224, 224, 224, $panel-alpha); $text-color-light: rgb(0 0 0 / 70%); $label-color-light: rgb(128 128 128); +$thumb-color-light: rgb(81 81 81 / 33.3%); +$thumb-hover-color-light: rgb(45 45 45 / 38.9%); /* dark */ $panel-color-dark: rgba(60, 60, 60, $panel-alpha); @@ -14,6 +16,8 @@ $panel-border-color-dark: rgb(0 0 0 / 47.5%); $vertical-border-color-dark: rgba(85, 85, 85, $panel-alpha); $text-color-dark: rgb(255 255 255 / 90%); $label-color-dark: rgb(124 124 124); +$thumb-color-dark: rgba(153, 153, 153, $panel-alpha); /* Not perfect. It shouldn't be blended with background. Same below. */ +$thumb-hover-color-dark: rgba(214, 214, 214, $panel-alpha); /* accent color */ $light-blue: rgb(0 90 216); @@ -46,7 +50,7 @@ $dark-graphite: rgb(105 105 105); .panel { transform: translate(25px, 25px); /* leave top and left for shadow */ - &:has(.horizontal .paging.arrow) { + &:has(.horizontal .paging:is(.arrow, .scroll)) { border-start-end-radius: 15px; border-end-end-radius: 15px; } @@ -62,6 +66,12 @@ $dark-graphite: rgb(105 105 105); } } + .paging.scroll { + inline-size: 28px; + justify-content: center; + align-items: center; + } + .contextmenu { backdrop-filter: blur(16px); } @@ -110,6 +120,15 @@ $dark-graphite: rgb(105 105 105); } } + .hoverables.horizontal-scroll::-webkit-scrollbar { + width: 8px; + } + + .hoverables.horizontal-scroll::-webkit-scrollbar-thumb { + border-radius: 4px; + } + + .label, .comment { font-size: 12px; } @@ -220,6 +239,22 @@ $dark-graphite: rgb(105 105 105); background-color: $vertical-border-color-light; } } + + .hoverables.horizontal-scroll .divider .divider-middle { + background-color: $panel-color-light; + } + + .hoverables.horizontal-scroll::-webkit-scrollbar-thumb { + background-color: $thumb-color-light; + } + + .hoverables.horizontal-scroll::-webkit-scrollbar-track { + background-color: $panel-color-light; + } + + .hoverables.horizontal-scroll::-webkit-scrollbar-thumb:hover { + background-color: $thumb-hover-color-light; + } } .macos.dark { @@ -312,10 +347,28 @@ $dark-graphite: rgb(105 105 105); background-color: $panel-color-dark; } + /* stylelint-disable-next-line no-descending-specificity */ .divider-middle { background-color: $vertical-border-color-dark; } } + + .hoverables.horizontal-scroll .divider .divider-middle { + background-color: $panel-color-dark; + } + + /* stylelint-disable-next-line no-descending-specificity */ + .hoverables.horizontal-scroll::-webkit-scrollbar-thumb { + background-color: $thumb-color-dark; + } + + .hoverables.horizontal-scroll::-webkit-scrollbar-track { + background-color: $panel-color-dark; + } + + .hoverables.horizontal-scroll::-webkit-scrollbar-thumb:hover { + background-color: $thumb-hover-color-dark; + } } .macos.light, .macos.dark { diff --git a/page/scroll.ts b/page/scroll.ts new file mode 100644 index 0000000..38dd9f6 --- /dev/null +++ b/page/scroll.ts @@ -0,0 +1,253 @@ +import { + hoverables +} from './selector' +import { + hideContextmenu +} from './ux' + +const MAX_ROW = 6 +const MAX_COLUMN = 6 + +let scrollState: SCROLL_STATE = 0 + +export function getScrollState () { + return scrollState +} + +export function setScrollState (state: SCROLL_STATE) { + scrollState = state +} + +let scrollEnd = false + +export function setScrollEnd (end: boolean) { + scrollEnd = end +} + +// A lock that prevents fetching same candidates simultaneously. +let fetching = false + +export function fetchComplete () { + fetching = false +} + +export function expand () { + window._scroll(0, (MAX_ROW + 1) * MAX_COLUMN) // visible rows plus 1 hidden row +} + +function collapse () { + window._scroll(-1, 0) +} + +let rowItemCount: number[] = [] +let highlighted = 0 + +function itemCountInFirstNRows (n: number): number { + return rowItemCount.slice(0, n).reduce((sum, count) => sum + count, 0) +} + +function getRowOf (index: number): number { + let skipped = 0 + for (let i = 0; i < rowItemCount.length - 1; ++i) { + const end = skipped + rowItemCount[i] + if (index < end) { + return i + } + skipped = end + } + return rowItemCount.length - 1 +} + +function getHighlightedRow (): number { + return getRowOf(highlighted) +} + +function distanceToTop (element: Element, basis: 'top' | 'bottom') { + return element.getBoundingClientRect()[basis] - hoverables.getBoundingClientRect().top +} + +function scrollForHighlight () { + const candidates = hoverables.querySelectorAll('.candidate') + + const bottomOffset = distanceToTop(candidates[highlighted], 'bottom') - hoverables.clientHeight + // Highlighted candidate below bottom of panel + if (bottomOffset > 0) { + hoverables.scrollTop += bottomOffset + } + + const topOffset = distanceToTop(candidates[highlighted], 'top') + // Highlighted candidate above top of panel + if (topOffset < 0) { + hoverables.scrollTop += topOffset + } +} + +function renderHighlightAndLabels (newHighlighted: number, clearOld: boolean) { + const candidates = hoverables.querySelectorAll('.candidate') + if (clearOld) { + const highlightedRow = getHighlightedRow() + const skipped = itemCountInFirstNRows(highlightedRow) + for (let i = skipped; i < skipped + rowItemCount[highlightedRow]; ++i) { + const candidate = candidates[i] + candidate.classList.remove('highlighted-row') + candidate.querySelector('.label')!.innerHTML = '0' + } + candidates[highlighted].classList.remove('highlighted') + } + + highlighted = newHighlighted + + const highlightedRow = getHighlightedRow() + const skipped = itemCountInFirstNRows(highlightedRow) + for (let i = skipped; i < skipped + rowItemCount[highlightedRow]; ++i) { + const candidate = candidates[i] + candidate.classList.add('highlighted-row') + candidate.querySelector('.label')!.innerHTML = `${i - skipped + 1}` + } + candidates[highlighted].classList.add('highlighted') +} + +export function recalculateScroll (scrollStart: boolean) { + const candidates = hoverables.querySelectorAll('.candidate') + let currentY = candidates[0].getBoundingClientRect().y + rowItemCount = [] + let itemCount = 0 + for (const candidate of candidates) { + candidate.classList.remove('highlighted-row') + const { y } = candidate.getBoundingClientRect() + if (y === currentY) { + ++itemCount + } else { + rowItemCount.push(itemCount) + itemCount = 1 + currentY = y + } + } + rowItemCount.push(itemCount) + renderHighlightAndLabels(scrollStart ? 0 : highlighted, !scrollStart) + + // Manually adjust last row so that candidates align left. + if (scrollEnd) { + const dividers = hoverables.querySelectorAll('.divider') + const skipped = itemCountInFirstNRows(rowItemCount.length - 1) + const { width } = dividers[skipped - 1].getBoundingClientRect() // Don't use clientWidth as it rounds to integer. + for (let i = skipped; i < skipped + rowItemCount[rowItemCount.length - 1] - 1; ++i) { + dividers[i].setAttribute('style', `flex-grow: 0; flex-basis: ${width}px`) + } + } +} + +function getNeighborCandidate (index: number, direction: SCROLL_MOVE_HIGHLIGHT): number { + const row = getRowOf(index) + const candidates = hoverables.querySelectorAll('.candidate') + const { left, right } = candidates[index].getBoundingClientRect() + const mid = (left + right) / 2 + + function helper (row: number) { + if (row < 0 || row === rowItemCount.length) { + return -1 + } + const skipped = itemCountInFirstNRows(row) + const last = skipped + rowItemCount[row] - 1 + for (let i = skipped; i < last; ++i) { + const rect = candidates[i].getBoundingClientRect() + if (rect.right <= left) { + continue + } + return rect.right > mid || rect.right - left > left - rect.left ? i : i + 1 + } + return last + } + + switch (direction) { + case 10: { + return helper(row - 1) + } + case 11: { + return helper(row + 1) + } + case 12: + return index - 1 + case 13: + if (index + 1 < itemCountInFirstNRows(rowItemCount.length + 1)) { + return index + 1 + } + return -1 + case 14: + case 15: { + const skipped = itemCountInFirstNRows(row) + return direction === 14 ? skipped : skipped + rowItemCount[row] - 1 + } + case 16: + case 17: { + const d = direction === 16 ? 10 : 11 + let step = MAX_ROW + let intermediateIndex = index + let newIndex: number + do { + newIndex = intermediateIndex + intermediateIndex = getNeighborCandidate(intermediateIndex, d) // execute at most MAX_ROW times, but the last result is not assigned to newIndex + } while (intermediateIndex >= 0 && --step) + if (newIndex === index) { + return -1 + } + return newIndex + } + } +} + +export function scrollKeyAction (action: SCROLL_KEY_ACTION) { + hideContextmenu() + if (action >= 1 && action <= 6) { + const highlightedRow = getHighlightedRow() + const n = rowItemCount[highlightedRow] + if (action > n) { + return + } + return window._select(itemCountInFirstNRows(highlightedRow) + action - 1) + } + switch (action) { + case 10: + case 11: + case 12: + case 13: + case 14: + case 15: + case 16: + case 17: { + const newHighlighted = getNeighborCandidate(highlighted, action) + if (newHighlighted >= 0) { + window._highlight(newHighlighted) + renderHighlightAndLabels(newHighlighted, true) + scrollForHighlight() + if (!scrollEnd && !fetching) { + const newHighlightedRow = getHighlightedRow() + if (rowItemCount.length - newHighlightedRow <= MAX_ROW) { + fetching = true + window._scroll(itemCountInFirstNRows(rowItemCount.length), MAX_ROW * MAX_COLUMN) + } + } + } else if ([10, 16].includes(action) && getHighlightedRow() === 0) { + collapse() + } + break + } + case 20: + window._select(highlighted) + break + } +} + +hoverables.addEventListener('scroll', () => { + if (scrollEnd || fetching) { + return + } + // This is safe since there are at least 7 lines. + const bottomRightIndex = itemCountInFirstNRows(rowItemCount.length - 1) - 1 + const candidates = hoverables.querySelectorAll('.candidate') + const bottomRight = candidates[bottomRightIndex] + if (distanceToTop(bottomRight, 'top') < hoverables.clientHeight) { + fetching = true + window._scroll(candidates.length, MAX_ROW * MAX_COLUMN) + } +}) diff --git a/page/ux.ts b/page/ux.ts index 5f6a3cc..244ab53 100644 --- a/page/ux.ts +++ b/page/ux.ts @@ -4,6 +4,10 @@ import { contextmenu, hoverables } from './selector' +import { + getScrollState, + expand +} from './scroll' let pressed = false let dragging = false @@ -116,6 +120,23 @@ function getCandidateIndex (target: Element) { return -1 } +export function showContextmenu (x: number, y: number, index: number, actions: CandidateAction[]) { + contextmenu.innerHTML = '' + for (const action of actions) { + const item = div('menu-item') + item.innerHTML = action.text + item.addEventListener('click', () => { + window._action(index, action.id) + hideContextmenu() + }) + contextmenu.appendChild(item) + } + contextmenu.style.top = `${y}px` + contextmenu.style.left = `${x}px` + contextmenu.style.display = 'block' + resize(0, 0, false, true) +} + export function hideContextmenu () { contextmenu.innerHTML = '' contextmenu.style.display = 'none' @@ -158,6 +179,8 @@ document.addEventListener('mouseup', e => { return window._page(false) } else if (target.classList.contains('next')) { return window._page(true) + } else if (target.classList.contains('expand')) { + return expand() } target = target.parentElement! } @@ -172,6 +195,14 @@ export function setActions (newActions: CandidateAction[][]) { actions = newActions } +let actionX = 0 +let actionY = 0 +let actionIndex = 0 + +export function answerActions (actions: CandidateAction[]) { + showContextmenu(actionX, actionY, actionIndex, actions) +} + document.addEventListener('contextmenu', e => { e.preventDefault() let target = e.target as Element @@ -182,21 +213,14 @@ document.addEventListener('contextmenu', e => { target = target.parentElement! } const i = getCandidateIndex(target) + if (i >= 0 && getScrollState() === 2) { + actionX = e.clientX + actionY = e.clientY + actionIndex = i + return window._askActions(i) + } if (i >= 0 && actions[i].length > 0) { - contextmenu.innerHTML = '' - for (const action of actions[i]) { - const item = div('menu-item') - item.innerHTML = action.text - item.addEventListener('click', () => { - window._action(i, action.id) - hideContextmenu() - }) - contextmenu.appendChild(item) - } - contextmenu.style.top = `${e.clientY}px` - contextmenu.style.left = `${e.clientX}px` - contextmenu.style.display = 'block' - resize(0, 0, false, true) + showContextmenu(e.clientX, e.clientY, i, actions[i]) } else { hideContextmenu() } diff --git a/preview/preview.mm b/preview/preview.mm index 4d45921..28844ff 100644 --- a/preview/preview.mm +++ b/preview/preview.mm @@ -11,15 +11,14 @@ int main(int argc, const char *argv[]) { std::unique_ptr candidateWindow = std::make_unique(); - candidateWindow->set_select_callback([](size_t index) { - std::cout << "selected " << index << std::endl; - }); + candidateWindow->set_select_callback( + [](int index) { std::cout << "selected " << index << std::endl; }); candidateWindow->set_init_callback( []() { std::cout << "Window loaded" << std::endl; }); candidateWindow->set_page_callback([](bool next) { std::cout << (next ? "next" : "prev") << " page" << std::endl; }); - candidateWindow->set_action_callback([](size_t index, int id) { + candidateWindow->set_action_callback([](int index, int id) { std::cout << "action " << id << " on " << index << std::endl; }); auto t = std::thread([&] { @@ -30,7 +29,7 @@ int main(int argc, const char *argv[]) { {{"

防注入

", "1", "注释", {{0, "

防注入

"}}}, {"候选词", "2", "", {{1, "删词"}, {2, "置顶"}}}, {"制\t表\t符\n多 空 格", "2", ""}}, - 0); + 0, candidate_window::scroll_state_t::none, false, false); candidateWindow->set_theme(candidate_window::theme_t::light); candidateWindow->show(100, 200); }); diff --git a/src/webview_candidate_window.mm b/src/webview_candidate_window.mm index dad463f..1eb0f70 100644 --- a/src/webview_candidate_window.mm +++ b/src/webview_candidate_window.mm @@ -49,13 +49,15 @@ void to_json(nlohmann::json &j, const Candidate &c) { {"actions", c.actions}}; } +CandidateAction escape_action(const CandidateAction &a) { + return CandidateAction{a.id, escape_html(a.text)}; +} + Candidate escape_candidate(const Candidate &c) { std::vector escaped_actions; escaped_actions.reserve(c.actions.size()); std::transform(c.actions.begin(), c.actions.end(), - std::back_inserter(escaped_actions), [](const auto &a) { - return CandidateAction{a.id, escape_html(a.text)}; - }); + std::back_inserter(escaped_actions), escape_action); return Candidate{escape_html(c.text), escape_html(c.label), escape_html(c.comment), std::move(escaped_actions)}; } @@ -163,11 +165,18 @@ NSRect getNearestScreenFrame(double x, double y) { [window setIsVisible:YES]; }); - bind("_select", [this](size_t i) { select_callback(i); }); + bind("_select", [this](int i) { select_callback(i); }); + + bind("_highlight", [this](int i) { highlight_callback(i); }); bind("_page", [this](bool next) { page_callback(next); }); - bind("_action", [this](size_t i, int id) { action_callback(i, id); }); + bind("_scroll", + [this](int start, int length) { scroll_callback(start, length); }); + + bind("_askActions", [this](int i) { ask_actions_callback(i); }); + + bind("_action", [this](int i, int id) { action_callback(i, id); }); bind("_onload", [this]() { init_callback(); }); @@ -225,14 +234,28 @@ NSRect getNearestScreenFrame(double x, double y) { } void WebviewCandidateWindow::set_candidates( - const std::vector &candidates, int highlighted) { + const std::vector &candidates, int highlighted, + scroll_state_t scroll_state, bool scroll_start, bool scroll_end) { std::vector escaped_candidates; escaped_candidates.reserve(candidates.size()); std::transform(candidates.begin(), candidates.end(), std::back_inserter(escaped_candidates), escape_candidate); invoke_js("setCandidates", escaped_candidates, highlighted, escape_html(highlight_mark_text_), pageable_, has_prev_, - has_next_); + has_next_, scroll_state, scroll_start, scroll_end); +} + +void WebviewCandidateWindow::scroll_key_action(scroll_key_action_t action) { + invoke_js("scrollKeyAction", action); +} + +void WebviewCandidateWindow::answer_actions( + const std::vector &actions) { + std::vector escaped_actions; + escaped_actions.reserve(actions.size()); + std::transform(actions.begin(), actions.end(), + std::back_inserter(escaped_actions), escape_action); + invoke_js("answerActions", escaped_actions); } void WebviewCandidateWindow::set_theme(theme_t theme) { diff --git a/tests/util.ts b/tests/util.ts index bf65ab2..56ef9ad 100644 --- a/tests/util.ts +++ b/tests/util.ts @@ -31,7 +31,7 @@ export async function init (page: Page) { export function setCandidates (page: Page, cands: Candidate[], highlighted: number) { return page.evaluate(({ cands, highlighted }) => - window.setCandidates(cands, highlighted, '', false, false, false), { cands, highlighted }) + window.setCandidates(cands, highlighted, '', false, false, false, 0, false, false), { cands, highlighted }) } export function setLayout (page: Page, layout: 0 | 1) {