diff --git a/docs/architecture.md b/docs/architecture.md index bf40259..962774e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,7 +52,7 @@ While the selection cover is active and no gesture or panel is open, pointer mov The tooltip is clamped to the viewport and belongs to the existing review host, excluded by DOM capture and hidden with the host during tab capture. Starting a gesture, leaving selection or the cover, window blur and observed page mutations/scroll/resize clear it. A hovered target also follows rendered ancestry (`assignedSlot`, then `parentElement`, then a shadow host or frame owner), bounded to 200 nodes and eight unique document/open-shadow roots, to watch mutations, scrolling, document resize and frame load. This includes shadow-local changes surrounding a slotted leaf. These events clear the preview rather than retain stale target geometry or names. A subsequent pointer movement recreates it. This observation does not guarantee detection of every visual change, such as animation without an observed event. -**コメント** is one tool whose gesture chooses the target: a click selects an element, a drag selects a free area, Shift adds elements to a multiple selection, Ctrl/Cmd+click picks a covered element, and Alt/Option+drag records a partial text range, including inline text and reverse drags within one DOM root. The hint takes the place of the toolbar's buttons while selecting: the toolbar keeps its position, handle and height, eases to the hint's width on a single row (a narrow viewport truncates the hint text before the buttons) and fades the hint in, then returns to its buttons when selection ends; reduced motion switches without animation. The hint is one line (**クリック・ドラッグで選択**, or **N件を選択** in multiple selection) followed by contextual buttons, a **使い方** icon link that opens the getting-started guide's comment section for the current language in a new tab (toolbar dragging captures the pointer on the pressed link, like on a button, so the link keeps its click), and an icon-only **終了** button. These two icons show toolbar tips on hover only (the end button's tip includes Esc), because selection start moves focus to the end button; starting selection also closes any tip that was open or pending for the hidden buttons. Multiple selection is entered only by a Shift gesture; the gesture list lives in the guide so the toolbar fits narrow viewports. Different iframe/shadow roots cannot form one text range. A drag records its starting point in page coordinates, so scrolling during the drag (wheel, keyboard or automatic) extends the rectangle beyond the viewport while its start stays on the same page content. While a free-area or multiple drag holds the pointer within 40 CSS pixels of a viewport edge, the page scrolls toward that edge each frame, faster nearer the edge; Alt/Option text drags do not scroll automatically. During an Alt/Option text drag, the preview highlights the line boxes of the text range that releasing at the current pointer would record, instead of a rectangle; when no text lies under the pointer or the range would cross DOM roots, no highlight is shown. The cover shows a text cursor while Alt/Option is held outside multiple selection and throughout a text drag. The free-area rectangle is saved in viewport coordinates of the release position, so its parts outside the viewport have negative or beyond-viewport coordinates. Multiple selection toggles individual elements on click and adds the meaningful elements whose visible boxes intersect a dragged rectangle with positive area. Boundary-only contact and empty rectangles add nothing and have no center-point fallback. Button/media inline descendants belong to their semantic owner; paragraphs and headings retain inline emphasis. Within each new batch, matched ancestors of other matched semantic elements are removed, and every remaining target is retained. Existing explicit selections are retained, and repeated drags do not toggle them off. The bounded traversal visits at most 5,000 nodes across open shadow roots and accessible same-origin frames, with at most eight root crossings per branch, excludes the NeedMark UI, and reports incomplete search when its node or root budget is reached. This flag remains with the selection through additions and undo; completion saves it as `target.code.truncated`, and the composer and exports warn that discovery is incomplete. Eligibility uses axis-aligned viewport/frame content and applicable overflow clipping with hidden/opacity checks; retained context and capture geometry remain the original full element boxes. Numbered outlines follow the selected originals. Enter or its completion button finishes; Backspace removes the last selected element. Shift+click or Shift+drag enters multiple selection; later plain clicks keep toggling targets until selection completes. Ctrl/Cmd+click also enters multiple selection and opts into deeper hit testing. Alt/Option is ignored during multiple selection. The picker skips the frontmost hit and its ancestor/descendant branch, preferring an independent lower hit inside the frontmost open shadow root or accessible same-origin frame before an outer-root alternative. It scans through additional empty container covers and accessible child roots, rejects invisible or fully clipped candidates, and prefers content-bearing targets. Empty visual candidates fall back to the smallest positive visible area. Content classification uses controls, non-container tags or direct non-whitespace text. The generic containers are `div`, `span`, `section`, `article`, `main`, `aside`, `header`, `footer`, `nav` and `slot`; an ID alone does not make one content-bearing. Deep branch ancestry follows assigned slots with a 200-node bound, without changing selector/source ownership. Lower roots retain meaningful frontmost content, so an inner labelled overlay remains selectable after an outer branch was skipped. This is a hit-stack heuristic rather than certainty about the reviewer’s intent. Root traversal retains the padded/scaled frame transform and eight-level bound. If no lower branch exists, the normal frontmost target remains selected. It never hides or changes application overlays; saved screenshots retain the actual visible screen. Ordinary clicks and Alt/Option text drags keep their frontmost behavior. Ctrl-click suppresses the native context menu only on the active selection cover. Mode, Shift and Ctrl/Cmd are recorded at primary pointer-down, so releasing or pressing modifiers during a gesture does not change its selection type or target depth. Releasing Ctrl/Cmd does not finish selection; Enter or the completion button does. Additional pointers cannot replace or commit that gesture. Pointer cancellation or lost capture clears only that gesture and releases capture; the first Escape during a drag does the same, preserving prior selections, while the next Escape ends selection. Enter and Backspace are ignored during an active multiple gesture, including the first Shift gesture. Explicit multiple selections have no fixed target-count cap. Each selected original keeps its individual context through capture, local save/read and all copy/export paths. HTTP feedback writes retain their existing eight-megabyte body and five-megabyte decoded-PNG limits; a rejected write retains the typed comment for retry. +**コメント** is one tool whose gesture chooses the target: a click selects an element, a drag selects a free area, Shift adds elements to a multiple selection, Ctrl/Cmd+click picks a covered element, and Alt/Option+drag records a partial text range, including inline text and reverse drags within one DOM root. The hint takes the place of the toolbar's buttons while selecting: the toolbar keeps its position, handle and height, eases to the hint's width on a single row (a narrow viewport truncates the hint text before the buttons) and fades the hint in, then returns to its buttons when selection ends; reduced motion switches without animation. The hint is one line (**クリック・ドラッグで選択**, or **N件を選択** in multiple selection) followed by contextual buttons, a **使い方** icon link that opens the getting-started guide's comment section for the current language in a new tab (toolbar dragging captures the pointer on the pressed link, like on a button, so the link keeps its click), and an icon-only **終了** button. These two icons show toolbar tips on hover only (the end button's tip includes Esc), because selection start moves focus to the end button; starting selection also closes any tip that was open or pending for the hidden buttons. Multiple selection is entered only by a Shift gesture; the gesture list lives in the guide so the toolbar fits narrow viewports. Different iframe/shadow roots cannot form one text range. A drag records its starting point in page coordinates, so scrolling during the drag (wheel, keyboard or automatic) extends the rectangle beyond the viewport while its start stays on the same page content. While a free-area or multiple drag holds the pointer within 40 CSS pixels of a viewport edge, the page scrolls toward that edge each frame, faster nearer the edge; Alt/Option text drags do not scroll automatically. A wheel over the cover scrolls the nearest element under the pointer that can still scroll in that direction, through open shadow roots and accessible same-origin frames, so pages that scroll inside an element (an app shell under a body with hidden overflow, a scrolling table) stay scrollable while commenting; the top document's own scrolling and Ctrl+wheel zoom stay with the browser. During an Alt/Option text drag, the preview highlights the line boxes of the text range that releasing at the current pointer would record, instead of a rectangle; when no text lies under the pointer or the range would cross DOM roots, no highlight is shown. The cover shows a text cursor while Alt/Option is held outside multiple selection and throughout a text drag. The free-area rectangle is saved in viewport coordinates of the release position, so its parts outside the viewport have negative or beyond-viewport coordinates. Multiple selection toggles individual elements on click and adds the meaningful elements whose visible boxes intersect a dragged rectangle with positive area. Boundary-only contact and empty rectangles add nothing and have no center-point fallback. Button/media inline descendants belong to their semantic owner; paragraphs and headings retain inline emphasis. Within each new batch, matched ancestors of other matched semantic elements are removed, and every remaining target is retained. Existing explicit selections are retained, and repeated drags do not toggle them off. The bounded traversal visits at most 5,000 nodes across open shadow roots and accessible same-origin frames, with at most eight root crossings per branch, excludes the NeedMark UI, and reports incomplete search when its node or root budget is reached. This flag remains with the selection through additions and undo; completion saves it as `target.code.truncated`, and the composer and exports warn that discovery is incomplete. Eligibility uses axis-aligned viewport/frame content and applicable overflow clipping with hidden/opacity checks; retained context and capture geometry remain the original full element boxes. Numbered outlines follow the selected originals. Enter or its completion button finishes; Backspace removes the last selected element. Shift+click or Shift+drag enters multiple selection; later plain clicks keep toggling targets until selection completes. Ctrl/Cmd+click also enters multiple selection and opts into deeper hit testing. Alt/Option is ignored during multiple selection. The picker skips the frontmost hit and its ancestor/descendant branch, preferring an independent lower hit inside the frontmost open shadow root or accessible same-origin frame before an outer-root alternative. It scans through additional empty container covers and accessible child roots, rejects invisible or fully clipped candidates, and prefers content-bearing targets. Empty visual candidates fall back to the smallest positive visible area. Content classification uses controls, non-container tags or direct non-whitespace text. The generic containers are `div`, `span`, `section`, `article`, `main`, `aside`, `header`, `footer`, `nav` and `slot`; an ID alone does not make one content-bearing. Deep branch ancestry follows assigned slots with a 200-node bound, without changing selector/source ownership. Lower roots retain meaningful frontmost content, so an inner labelled overlay remains selectable after an outer branch was skipped. This is a hit-stack heuristic rather than certainty about the reviewer’s intent. Root traversal retains the padded/scaled frame transform and eight-level bound. If no lower branch exists, the normal frontmost target remains selected. It never hides or changes application overlays; saved screenshots retain the actual visible screen. Ordinary clicks and Alt/Option text drags keep their frontmost behavior. Ctrl-click suppresses the native context menu only on the active selection cover. Mode, Shift and Ctrl/Cmd are recorded at primary pointer-down, so releasing or pressing modifiers during a gesture does not change its selection type or target depth. Releasing Ctrl/Cmd does not finish selection; Enter or the completion button does. Additional pointers cannot replace or commit that gesture. Pointer cancellation or lost capture clears only that gesture and releases capture; the first Escape during a drag does the same, preserving prior selections, while the next Escape ends selection. Enter and Backspace are ignored during an active multiple gesture, including the first Shift gesture. Explicit multiple selections have no fixed target-count cap. Each selected original keeps its individual context through capture, local save/read and all copy/export paths. HTTP feedback writes retain their existing eight-megabyte body and five-megabyte decoded-PNG limits; a rejected write retains the typed comment for retry. Selected elements retain their original Node, root, document and bounded element evidence when each selection is added. Validation checks their current document/frame connection, recorded child page URLs, identifiers, text/accessibility name, source evidence and closest known component. When frame URL evidence is present, every recorded iframe must retain its exact URL, including query and fragment; a route change invalidates even a still-connected node with identical text and source. Older records without this optional evidence retain their previous identity checks. It does not resolve a replacement through the old selector. Disconnected, replaced or changed targets are removed with a notice; if no targets remain, selection can restart empty. Source line/column movement, class/style changes and geometry changes alone do not remove a target. Validation remains bounded by captured text/name/attribute evidence: a virtual item reusing the same observed information is indistinguishable. Stable row identifiers can be included through `contextAttributes`. Hidden and offscreen original elements stay selected; image acquisition still has the existing visibility and viewport checks. diff --git a/src/client/index.ts b/src/client/index.ts index 91d3c27..3730ac7 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -786,6 +786,24 @@ function mount(endpoint: string, token: string) { } addEventListener('scroll', () => { if (pointerStart?.mode === 'text') previewText(pointerStart); else if (pointerStart) { const rect = dragRectangle(pointerStart); if (Math.hypot(rect.width, rect.height) > 6) highlight(rect); } }, { passive: true }); cover.addEventListener('contextmenu', (event) => { if (!cover.hidden && event.ctrlKey) event.preventDefault(); }); + // A wheel over the fixed cover scrolls only the cover's ancestors, so a page that scrolls inside an element (an app shell under a body with overflow hidden, a table with overflow auto) froze while commenting. The wheel is handed to the nearest element under the pointer that can still scroll that way, and the top document is left to the browser so its native smooth scrolling is kept. + // 固定されたカバー上のホイールはカバーの祖先しかスクロールしないため、要素の中でスクロールするページ(bodyがoverflow:hiddenのアプリ枠や、overflow:autoの表)はコメント中に動かなくなっていた。ポインター下でまだその向きへスクロールできる最も近い要素へホイールを渡し、最上位の文書はブラウザ本来のなめらかなスクロールを保つためブラウザに任せる。 + cover.addEventListener('wheel', (event) => { + // Ctrl+wheel is a trackpad pinch or page zoom, which stays with the browser. / Ctrl+ホイールはトラックパッドのピンチやページ拡大なので、ブラウザに任せる。 + if (cover.hidden || event.ctrlKey) return; + const unit = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 16 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? innerHeight : 1, left = event.deltaX * unit, top = event.deltaY * unit; + const room = (delta: number, position: number, client: number, size: number) => delta < 0 ? position > 0 : delta > 0 && Math.ceil(position + client) < size; + const scrolls = (element: Element) => { + const viewport = element === element.ownerDocument.scrollingElement, style = getComputedStyle(element); + const open = (overflow: string) => viewport ? overflow !== 'hidden' && overflow !== 'clip' : /auto|scroll|overlay/.test(overflow); + return open(style.overflowX) && room(left, element.scrollLeft, element.clientWidth, element.scrollWidth) || open(style.overflowY) && room(top, element.scrollTop, element.clientHeight, element.scrollHeight); + }; + for (let element = elementAtPoint(event.clientX, event.clientY, host) ?? null; element && element !== document.scrollingElement;) { + if (scrolls(element)) { event.preventDefault(); element.scrollBy({ left, top, behavior: 'instant' }); return; } + const root = element.getRootNode() as Document | ShadowRoot; + element = element.assignedSlot ?? element.parentElement ?? ('host' in root ? root.host : root.defaultView?.frameElement ?? null); + } + }, { passive: false }); const cancelPointer = (event: PointerEvent) => { if (pointerStart?.id === event.pointerId) { clearSelectionGesture(); outline.hidden = true; } }; cover.addEventListener('pointercancel', cancelPointer); cover.addEventListener('lostpointercapture', cancelPointer); cover.addEventListener('pointerleave', clearHover); window.addEventListener('blur', clearHover); diff --git a/tests/review-flow.spec.ts b/tests/review-flow.spec.ts index a49c0bf..5111548 100644 --- a/tests/review-flow.spec.ts +++ b/tests/review-flow.spec.ts @@ -221,6 +221,18 @@ test('Enter completes a multiple selection into a focused composer whose draft o await expect(outlines).toHaveCount(0); }); +test('the wheel in comment mode scrolls a page whose content scrolls inside an app shell instead of the document', { tag: '@cross-engine' }, async ({ page }) => { + await page.goto('/'); await configure(page); + await page.evaluate(() => { + const shell = document.createElement('div'); shell.id = 'app-shell'; shell.style.cssText = 'height:100vh;overflow:auto'; + shell.append(...Array.from(document.body.children).filter((element) => !element.matches('[data-needmark], script'))); + document.body.prepend(shell); document.documentElement.style.overflow = document.body.style.overflow = 'hidden'; + }); + await page.getByRole('button', { name: 'コメント', exact: true }).click(); + await page.mouse.move(600, 400); await page.mouse.wheel(0, 300); + await expect.poll(() => page.locator('#app-shell').evaluate((shell) => shell.scrollTop)).toBeGreaterThan(0); +}); + test('Ctrl/Cmd+Shift+F toggles selection and Shift+L is not a NeedMark shortcut', async ({ page }) => { await page.goto('/'); await configure(page); await page.keyboard.press('ControlOrMeta+Shift+F');