fix(reasoning): re-scroll PDF when re-clicking the active iteration
The watch-based plumbing from iteration click to PDF scroll relied on a "flip via null" pattern (assign null then the value) to coerce Vue into re-firing the watcher. Vue 3 collapses synchronous mutations of the same ref and only delivers the final value, so the trick was a no-op: a second click on the same iteration left the document view stuck on the previous page. The bug only showed when the trace had a single iteration — with several, the user naturally clicks different ones and the value really changes. Replace the watch chain with imperative dispatch. ReasoningPanel now just emits iterationFocus; ReasoningWorkspace handles it by calling the graph focus and the new StructureViewer.scrollToFocused method directly. Both side effects fire on every click regardless of state.
This commit is contained in:
parent
9ec64961fc
commit
77fcb32e7f
5 changed files with 67 additions and 57 deletions
|
|
@ -284,29 +284,33 @@ watch(
|
||||||
// When the caller sets a focused self_ref (e.g. the user clicked a node in
|
// When the caller sets a focused self_ref (e.g. the user clicked a node in
|
||||||
// the graph), find which page that element lives on and jump to it. The
|
// the graph), find which page that element lives on and jump to it. The
|
||||||
// overlay redraw will then show the dashed focus ring around its bbox.
|
// overlay redraw will then show the dashed focus ring around its bbox.
|
||||||
watch(
|
function scrollToFocused(ref: string | null): void {
|
||||||
() => props.focusedSelfRef,
|
if (!ref) {
|
||||||
(ref) => {
|
nextTick(drawOverlay)
|
||||||
if (!ref) {
|
return
|
||||||
nextTick(drawOverlay)
|
}
|
||||||
|
for (const page of props.pages) {
|
||||||
|
if (page.elements.some((e) => e.self_ref === ref)) {
|
||||||
|
if (selectedPage.value !== page.page_number) {
|
||||||
|
selectedPage.value = page.page_number
|
||||||
|
// Let <img> reload before drawing — drawOverlay runs on @load.
|
||||||
|
} else {
|
||||||
|
nextTick(drawOverlay)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for (const page of props.pages) {
|
}
|
||||||
if (page.elements.some((e) => e.self_ref === ref)) {
|
// Ref not on any page (e.g. a #/body node) — just redraw to clear the
|
||||||
if (selectedPage.value !== page.page_number) {
|
// previous focus ring.
|
||||||
selectedPage.value = page.page_number
|
nextTick(drawOverlay)
|
||||||
// Let <img> reload before drawing — drawOverlay runs on @load.
|
}
|
||||||
} else {
|
|
||||||
nextTick(drawOverlay)
|
watch(() => props.focusedSelfRef, scrollToFocused)
|
||||||
}
|
|
||||||
return
|
// Imperative entry point so callers can re-trigger a scroll on the same
|
||||||
}
|
// self_ref (the watch above only fires on value change). Used by the
|
||||||
}
|
// reasoning workspace when the user re-clicks the active iteration card.
|
||||||
// Ref not on any page (e.g. a #/body node) — just redraw to clear the
|
defineExpose({ scrollToFocused })
|
||||||
// previous focus ring.
|
|
||||||
nextTick(drawOverlay)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -96,11 +96,10 @@ export const useReasoningStore = defineStore('reasoning', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function setActiveIteration(n: number | null): void {
|
function setActiveIteration(n: number | null): void {
|
||||||
// Flip via null first so a click on the *same* iteration still fires
|
// Pure state — drives the active-card highlight in the iteration list.
|
||||||
// downstream watchers (PDF scroll, graph pan). Otherwise Vue sees
|
// Side effects (graph pan, PDF scroll) are dispatched imperatively from
|
||||||
// "no change" and the second click is a silent no-op — painful UX when
|
// ReasoningWorkspace.onIterationFocus so re-clicking the same iteration
|
||||||
// only one iteration exists.
|
// still re-focuses both views (a watch here would no-op on same value).
|
||||||
activeIteration.value = null
|
|
||||||
activeIteration.value = n
|
activeIteration.value = n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
</div>
|
</div>
|
||||||
<StructureViewer
|
<StructureViewer
|
||||||
v-else
|
v-else
|
||||||
|
ref="structureViewerRef"
|
||||||
:pages="pages"
|
:pages="pages"
|
||||||
:document-id="docId"
|
:document-id="docId"
|
||||||
:visited-by-self-ref="visitedBySelfRef"
|
:visited-by-self-ref="visitedBySelfRef"
|
||||||
|
|
@ -28,7 +29,7 @@
|
||||||
* - clicking a bbox emits `elementFocus` so the graph can mirror the
|
* - clicking a bbox emits `elementFocus` so the graph can mirror the
|
||||||
* selection (bidirectional sync handled in ReasoningWorkspace).
|
* selection (bidirectional sync handled in ReasoningWorkspace).
|
||||||
*/
|
*/
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import StructureViewer from '../../analysis/ui/StructureViewer.vue'
|
import StructureViewer from '../../analysis/ui/StructureViewer.vue'
|
||||||
import { useAnalysisStore } from '../../analysis/store'
|
import { useAnalysisStore } from '../../analysis/store'
|
||||||
|
|
@ -42,6 +43,17 @@ const props = defineProps<{
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits<{ elementFocus: [selfRef: string] }>()
|
const emit = defineEmits<{ elementFocus: [selfRef: string] }>()
|
||||||
|
|
||||||
|
const structureViewerRef = ref<InstanceType<typeof StructureViewer> | null>(null)
|
||||||
|
|
||||||
|
// Imperative passthrough so the reasoning workspace can re-trigger a scroll
|
||||||
|
// on the same self_ref (e.g. user re-clicks the active iteration card —
|
||||||
|
// the prop hasn't changed so the watch wouldn't fire).
|
||||||
|
function scrollToFocused(selfRef: string | null): void {
|
||||||
|
structureViewerRef.value?.scrollToFocused(selfRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ scrollToFocused })
|
||||||
|
|
||||||
const analysisStore = useAnalysisStore()
|
const analysisStore = useAnalysisStore()
|
||||||
const reasoningStore = useReasoningStore()
|
const reasoningStore = useReasoningStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@
|
||||||
:key="it.iteration"
|
:key="it.iteration"
|
||||||
:iteration="it"
|
:iteration="it"
|
||||||
:active="store.activeIteration === it.iteration"
|
:active="store.activeIteration === it.iteration"
|
||||||
@focus="onFocus"
|
@focus="(n) => emit('iterationFocus', n)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -99,8 +99,6 @@ import {
|
||||||
applyReasoningOverlay,
|
applyReasoningOverlay,
|
||||||
buildDegradedOverlay,
|
buildDegradedOverlay,
|
||||||
clearReasoningOverlay,
|
clearReasoningOverlay,
|
||||||
focusIteration,
|
|
||||||
nodeIdForSectionRef,
|
|
||||||
} from '../graphReasoningOverlay'
|
} from '../graphReasoningOverlay'
|
||||||
import { useReasoningStore } from '../store'
|
import { useReasoningStore } from '../store'
|
||||||
import IterationCard from './IterationCard.vue'
|
import IterationCard from './IterationCard.vue'
|
||||||
|
|
@ -115,6 +113,11 @@ const props = defineProps<{
|
||||||
cy: Core | null
|
cy: Core | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// Iteration clicks bubble up to the workspace, which dispatches focus to
|
||||||
|
// both the graph and the PDF directly — keeping the panel ignorant of its
|
||||||
|
// siblings and avoiding watch-based plumbing that misfires on repeat clicks.
|
||||||
|
const emit = defineEmits<{ iterationFocus: [iteration: number] }>()
|
||||||
|
|
||||||
const store = useReasoningStore()
|
const store = useReasoningStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
|
@ -186,14 +189,6 @@ watch(
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
function onFocus(iteration: number): void {
|
|
||||||
store.setActiveIteration(iteration)
|
|
||||||
if (!props.cy) return
|
|
||||||
const hit = store.iterations.find((i) => i.iteration === iteration)
|
|
||||||
if (!hit || !hit.present) return
|
|
||||||
focusIteration(props.cy, nodeIdForSectionRef(hit.sectionRef))
|
|
||||||
}
|
|
||||||
|
|
||||||
function onClear(): void {
|
function onClear(): void {
|
||||||
if (props.cy) clearReasoningOverlay(props.cy)
|
if (props.cy) clearReasoningOverlay(props.cy)
|
||||||
store.reset()
|
store.reset()
|
||||||
|
|
|
||||||
|
|
@ -69,11 +69,12 @@
|
||||||
and the initial scroll never fires. -->
|
and the initial scroll never fires. -->
|
||||||
<DocumentView
|
<DocumentView
|
||||||
v-show="mode === 'document'"
|
v-show="mode === 'document'"
|
||||||
|
ref="documentViewRef"
|
||||||
:doc-id="docId"
|
:doc-id="docId"
|
||||||
:focused-self-ref="focusedSelfRef"
|
:focused-self-ref="focusedSelfRef"
|
||||||
@element-focus="onPdfElementFocus"
|
@element-focus="onPdfElementFocus"
|
||||||
/>
|
/>
|
||||||
<ReasoningPanel :cy="graphCy" />
|
<ReasoningPanel :cy="graphCy" @iteration-focus="onIterationFocus" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RunReasoningDialog :doc-id="docId" :doc-filename="docFilename" />
|
<RunReasoningDialog :doc-id="docId" :doc-filename="docFilename" />
|
||||||
|
|
@ -86,6 +87,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import GraphView from '../../analysis/ui/GraphView.vue'
|
import GraphView from '../../analysis/ui/GraphView.vue'
|
||||||
import { useI18n } from '../../../shared/i18n'
|
import { useI18n } from '../../../shared/i18n'
|
||||||
import { fetchReasoningGraph } from '../api'
|
import { fetchReasoningGraph } from '../api'
|
||||||
|
import { focusIteration, nodeIdForSectionRef } from '../graphReasoningOverlay'
|
||||||
import { useReasoningStore } from '../store'
|
import { useReasoningStore } from '../store'
|
||||||
import DocumentView from './DocumentView.vue'
|
import DocumentView from './DocumentView.vue'
|
||||||
import ReasoningPanel from './ReasoningPanel.vue'
|
import ReasoningPanel from './ReasoningPanel.vue'
|
||||||
|
|
@ -105,6 +107,7 @@ const reasoningStore = useReasoningStore()
|
||||||
|
|
||||||
const graphViewRef = ref<InstanceType<typeof GraphView> | null>(null)
|
const graphViewRef = ref<InstanceType<typeof GraphView> | null>(null)
|
||||||
const graphCy = computed(() => graphViewRef.value?.cy ?? null)
|
const graphCy = computed(() => graphViewRef.value?.cy ?? null)
|
||||||
|
const documentViewRef = ref<InstanceType<typeof DocumentView> | null>(null)
|
||||||
|
|
||||||
const mode = ref<WorkspaceMode>('graph')
|
const mode = ref<WorkspaceMode>('graph')
|
||||||
|
|
||||||
|
|
@ -126,24 +129,21 @@ function onPdfElementFocus(selfRef: string): void {
|
||||||
graphViewRef.value?.selectBySelfRef(selfRef)
|
graphViewRef.value?.selectBySelfRef(selfRef)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click on an iteration card in the reasoning panel flows through
|
// Click on an iteration card in the reasoning panel. We dispatch focus to
|
||||||
// `reasoningStore.setActiveIteration(n)`. That path already focuses the
|
// both the graph and the PDF imperatively (rather than via watches on
|
||||||
// cytoscape node (in ReasoningPanel.onFocus); we mirror it into the PDF
|
// `activeIteration` / `focusedSelfRef`). Watches misfire when the user
|
||||||
// viewer by resolving the active iteration's section_ref and piping it
|
// re-clicks the same iteration: Vue collapses synchronous "flip via null"
|
||||||
// into our shared focus. Done at the workspace level — not inside the
|
// mutations and only sees the final value, equal to the previous one — so
|
||||||
// panel — because the panel doesn't know it has a PDF sibling.
|
// nothing scrolls. Calling `focusIteration` and `scrollToFocused` directly
|
||||||
watch(
|
// works on every click regardless of state.
|
||||||
() => reasoningStore.activeIteration,
|
function onIterationFocus(iteration: number): void {
|
||||||
(n) => {
|
reasoningStore.setActiveIteration(iteration)
|
||||||
if (n === null) return
|
const hit = reasoningStore.iterations.find((i) => i.iteration === iteration)
|
||||||
const hit = reasoningStore.iterations.find((i) => i.iteration === n)
|
if (!hit?.present || !hit.sectionRef) return
|
||||||
if (!hit?.present || !hit.sectionRef) return
|
if (graphCy.value) focusIteration(graphCy.value, nodeIdForSectionRef(hit.sectionRef))
|
||||||
// Flip via null so StructureViewer's watch on `focusedSelfRef` re-fires
|
focusedSelfRef.value = hit.sectionRef
|
||||||
// even when clicking the same iteration twice (same sectionRef).
|
documentViewRef.value?.scrollToFocused(hit.sectionRef)
|
||||||
focusedSelfRef.value = null
|
}
|
||||||
focusedSelfRef.value = hit.sectionRef
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Reset the reasoning store when switching docs — a trace imported for one
|
// Reset the reasoning store when switching docs — a trace imported for one
|
||||||
// document is meaningless on another. The main-pane mode resets too so a
|
// document is meaningless on another. The main-pane mode resets too so a
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue