feat: implement dynamic knowledge graph updates and state management services

This commit is contained in:
2026-04-26 14:53:48 +02:00
parent 412320980f
commit 7859c9806f
30 changed files with 668 additions and 153 deletions
@@ -25,6 +25,9 @@
case "target":
<circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="6" /><circle cx="12" cy="12" r="2" />
break;
case "trash":
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6" />
break;
default:
<!-- Fallback circle -->
<circle cx="12" cy="12" r="10" />

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -31,13 +31,10 @@
[Parameter] public List<string> Actions { get; set; } = new();
[Parameter] public EventCallback<string> OnActionTriggered { get; set; }
private bool _isQuizMode = false;
private async Task HandleActionClick(string action)
{
if (action.Contains("quiz", StringComparison.OrdinalIgnoreCase))
{
_isQuizMode = true;
QuizState.RequestQuiz(ContextBlockId);
}
@@ -1,5 +1,7 @@
@using NexusReader.UI.Shared.Services
@using NexusReader.Application.Abstractions.Services
@inject IFocusModeService FocusMode
@inject IKnowledgeService KnowledgeService
<aside class="intelligence-toolbar">
<div class="toolbar-top">
@@ -21,6 +23,9 @@
<button class="toolbar-item" title="Search">
<NexusIcon Name="search" Size="20" />
</button>
<button class="toolbar-item danger" @onclick="HandleClearCache" title="Clear AI Cache">
<NexusIcon Name="trash" Size="20" />
</button>
</div>
<div class="toolbar-bottom">
@@ -40,6 +45,17 @@
FocusMode.OnFocusModeChanged += StateHasChanged;
}
private async Task HandleClearCache()
{
// For now, a simple console log confirm or just do it
Console.WriteLine("[IntelligenceToolbar] Requesting cache clear...");
var result = await KnowledgeService.ClearCacheAsync();
if (result.IsSuccess)
{
Console.WriteLine("[IntelligenceToolbar] Cache cleared successfully!");
}
}
public void Dispose()
{
FocusMode.OnFocusModeChanged -= StateHasChanged;
@@ -65,3 +65,8 @@
.rotate-180 {
transform: rotate(180deg);
}
.toolbar-item.danger:hover {
color: #ff4d4d;
background: rgba(255, 77, 77, 0.1);
}
@@ -4,6 +4,7 @@
@using NexusReader.Application.Abstractions.Services
@inject IMediator Mediator
@inject IPlatformService PlatformService
@inject IQuizStateService QuizService
<div class="knowledge-check">
<div class="quiz-header">
@@ -11,14 +12,14 @@
<button class="expand-btn">⌵</button>
</div>
@if (_isLoading)
@if (QuizService.IsHydrating)
{
<div class="loading-state">Pobieranie pytań...</div>
<div class="loading-state shimmer">Skanowanie wiedzy przez AI...</div>
}
else if (_quiz != null)
else if (QuizService.CurrentQuiz != null)
{
<div class="quiz-body">
@foreach (var question in _quiz.Questions)
@foreach (var question in QuizService.CurrentQuiz.Questions)
{
<div class="question-container">
<p class="question-text">@question.Question</p>
@@ -50,21 +51,16 @@
@code {
[Parameter] public string ContextBlockId { get; set; } = string.Empty;
private bool _isLoading = true;
private QuizDto? _quiz;
private Dictionary<QuizQuestionDto, (int SelectedIndex, bool IsCorrect)> _states = new();
protected override async Task OnInitializedAsync()
protected override void OnInitialized()
{
_isLoading = true;
var query = new GetQuizQuestionsQuery(ContextBlockId);
var result = await Mediator.Send(query);
if (result.IsSuccess)
_quiz = result.Value;
_isLoading = false;
QuizService.OnQuizUpdated += () => InvokeAsync(StateHasChanged);
}
public void Dispose()
{
QuizService.OnQuizUpdated -= StateHasChanged;
}
private async Task SelectOptionAsync(QuizQuestionDto question, int index)
@@ -89,7 +85,7 @@
private bool AllQuestionsAnswered()
{
return _quiz != null && _states.Count == _quiz.Questions.Count;
return QuizService.CurrentQuiz != null && _states.Count == QuizService.CurrentQuiz.Questions.Count;
}
@@ -106,3 +106,18 @@
border-color: #ff4444 !important;
background: rgba(255, 68, 68, 0.1) !important;
}
.loading-state.shimmer {
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.05), transparent);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
padding: 20px;
text-align: center;
color: var(--nexus-neon);
text-shadow: 0 0 10px var(--nexus-neon);
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@@ -6,6 +6,7 @@
@inject IMediator Mediator
@inject IJSRuntime JS
@inject IFocusModeService FocusMode
@inject IKnowledgeGraphService GraphService
<div class="knowledge-graph-container" id="@ContainerId">
@if (GraphData == null)
@@ -37,6 +38,21 @@
protected override void OnInitialized()
{
FocusMode.OnFocusModeChanged += HandleFocusSimulation;
GraphService.OnGraphUpdated += HandleGraphUpdate;
GraphService.OnActiveNodeChanged += HandleActiveNodeChange;
}
private async void HandleGraphUpdate()
{
if (_module == null) return;
await _module.InvokeVoidAsync("updateData", GraphService.CurrentGraphData);
await InvokeAsync(StateHasChanged);
}
private async void HandleActiveNodeChange(string nodeId)
{
if (_module == null) return;
await _module.InvokeVoidAsync("setActiveNode", nodeId);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -8,6 +8,7 @@
@inject IThemeService ThemeService
@inject IFocusModeService FocusMode
@inject IReaderNavigationService NavigationService
@inject KnowledgeCoordinator Coordinator
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
@if (ViewModel == null)
@@ -59,6 +60,31 @@
{
await LoadChapterAsync(NavigationService.CurrentChapterIndex);
StateHasChanged();
await InitializeObserverAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await InitializeObserverAsync();
}
}
private async Task InitializeObserverAsync()
{
try
{
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/readerObserver.js");
await module.InvokeVoidAsync("initObserver", DotNetObjectReference.Create(this), ".reader-flow-container", ".block-wrapper");
}
catch { }
}
[JSInvokable]
public void HandleBlockReached(string blockId, string content)
{
Coordinator.OnBlockReached(blockId, content);
}
private async Task LoadChapterAsync(int index)
@@ -5,6 +5,8 @@
@using NexusReader.UI.Shared.Components.Organisms
@inject IPlatformService PlatformService
@inject IFocusModeService FocusMode
@inject IQuizStateService QuizService
@implements IDisposable
<div class="app-container @_platformClass @(FocusMode.IsFocusModeActive ? "focus-mode-active" : "")">
<div class="reader-pane">
@@ -18,7 +20,7 @@
<IntelligenceToolbar />
<div class="intelligence-content">
<div class="intelligence-header">
<NexusIcon Name="robot" Size="20" Class="neon-glow" />
<NexusIcon Name="robot" Size="20" Class="@($"neon-glow {(QuizService.HasNewQuiz ? "quiz-available" : "")}")" />
<span>Asystent AI i Interaktywna Mapa</span>
<button class="close-btn">×</button>
</div>
@@ -43,6 +45,7 @@
protected override void OnInitialized()
{
FocusMode.OnFocusModeChanged += StateHasChanged;
QuizService.OnQuizUpdated += StateHasChanged;
var context = PlatformService.GetDeviceContext();
if (context.IsSuccess)
@@ -58,6 +61,7 @@
public void Dispose()
{
FocusMode.OnFocusModeChanged -= StateHasChanged;
QuizService.OnQuizUpdated -= StateHasChanged;
}
}
@@ -120,4 +120,15 @@ main {
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
.quiz-available {
animation: quiz-pulse 1.5s infinite;
color: var(--nexus-neon) !important;
}
@keyframes quiz-pulse {
0% { filter: drop-shadow(0 0 2px var(--nexus-neon)); transform: scale(1); }
50% { filter: drop-shadow(0 0 10px var(--nexus-neon)); transform: scale(1.1); }
100% { filter: drop-shadow(0 0 2px var(--nexus-neon)); transform: scale(1); }
}
@@ -0,0 +1,15 @@
using NexusReader.Application.Queries.Graph;
namespace NexusReader.UI.Shared.Services;
public interface IKnowledgeGraphService
{
GraphDataDto? CurrentGraphData { get; }
string? ActiveNodeId { get; }
event Action? OnGraphUpdated;
event Action<string>? OnActiveNodeChanged;
void UpdateGraph(GraphDataDto newData);
void SetActiveNode(string nodeId);
}
@@ -1,8 +1,19 @@
using NexusReader.Application.Queries.Quiz;
namespace NexusReader.UI.Shared.Services;
public interface IQuizStateService
{
string? CurrentQuizBlockId { get; }
QuizDto? CurrentQuiz { get; }
bool IsHydrating { get; }
bool HasNewQuiz { get; }
event Action<string>? OnQuizRequested;
event Action? OnQuizUpdated;
void RequestQuiz(string blockId);
void SetQuiz(string blockId, QuizDto quiz);
void SetHydrating(bool hydrating);
void MarkQuizAsSeen();
}
@@ -0,0 +1,141 @@
using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.Queries.Graph;
using NexusReader.Application.Queries.Quiz;
using NexusReader.UI.Shared.Services;
namespace NexusReader.UI.Shared.Services;
public sealed class KnowledgeCoordinator : IDisposable
{
private readonly IKnowledgeService _knowledgeService;
private readonly IKnowledgeGraphService _graphService;
private readonly IQuizStateService _quizService;
private readonly IPlatformService _platformService;
private CancellationTokenSource? _debounceCts;
public KnowledgeCoordinator(
IKnowledgeService knowledgeService,
IKnowledgeGraphService graphService,
IQuizStateService quizService,
IPlatformService platformService)
{
_knowledgeService = knowledgeService;
_graphService = graphService;
_quizService = quizService;
_platformService = platformService;
}
public void OnBlockReached(string blockId, string content)
{
Console.WriteLine($"[KnowledgeCoordinator] Block reached: {blockId}");
// 1. Skip extraction for the title page (usually the first block or contains 'title')
if (blockId.Equals("seg-0", StringComparison.OrdinalIgnoreCase) ||
blockId.Contains("title", StringComparison.OrdinalIgnoreCase) ||
content.Length < 50) // Title pages are usually short
{
Console.WriteLine($"[KnowledgeCoordinator] Skipping extraction for title page/short block: {blockId}");
_graphService.SetActiveNode(blockId);
return;
}
// 2. Update active node immediately for "TU JESTEŚ" logic
_graphService.SetActiveNode(blockId);
// 3. Debounce the AI extraction to prevent spamming while scrolling
_debounceCts?.Cancel();
_debounceCts = new CancellationTokenSource();
var token = _debounceCts.Token;
_ = DebounceAndExtractAsync(blockId, content, token);
}
private async Task DebounceAndExtractAsync(string blockId, string content, CancellationToken token)
{
try
{
await Task.Delay(1000, token);
if (token.IsCancellationRequested) return;
Console.WriteLine($"[KnowledgeCoordinator] Triggering extraction for block: {blockId}");
await ProcessKnowledgeExtractionAsync(blockId, content, token);
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
Console.WriteLine($"[KnowledgeCoordinator] Unexpected error in task: {ex.Message}");
}
}
private async Task ProcessKnowledgeExtractionAsync(string blockId, string content, CancellationToken ct)
{
_quizService.SetHydrating(true);
var result = await _knowledgeService.GetKnowledgeAsync(content, ct);
if (result.IsSuccess && !ct.IsCancellationRequested)
{
Console.WriteLine($"[KnowledgeCoordinator] Extraction success for block: {blockId}. Updating state...");
var packet = result.Value;
// Update Quiz State
var quizQuestions = packet.Quizzes
.Select(q => new QuizQuestionDto(q.Question, q.Options, q.CorrectIndex))
.ToList();
_quizService.SetQuiz(blockId, new QuizDto(quizQuestions));
// Update Graph State
GraphDataDto graphData;
if (packet.Graph != null && packet.Graph.Nodes != null && packet.Graph.Nodes.Any())
{
// Use AI-generated graph
graphData = packet.Graph;
// Ensure current block is linked to the first concept or added if missing
if (!graphData.Nodes.Any(n => n.Id == blockId))
{
graphData.Nodes.Add(new GraphNodeDto(blockId, "TU JESTEŚ", "current"));
if (graphData.Nodes.Count > 1)
{
graphData.Links.Add(new GraphLinkDto(blockId, graphData.Nodes[0].Id, 1));
}
}
}
else
{
// Fallback: Transform Concepts to GraphData if AI didn't provide a graph
var nodes = packet.Concepts
.Select(c => new GraphNodeDto(c.Title.ToLowerInvariant(), c.Title, "concept"))
.ToList();
nodes.Add(new GraphNodeDto(blockId, "TU JESTEŚ", "current"));
var links = packet.Concepts
.Select(c => new GraphLinkDto(blockId, c.Title.ToLowerInvariant(), 1))
.ToList();
graphData = new GraphDataDto { Nodes = nodes, Links = links };
}
_graphService.UpdateGraph(graphData);
// Visual/Haptic Feedback
await _platformService.VibrateSuccessAsync();
}
else
{
if (!ct.IsCancellationRequested)
{
Console.WriteLine($"[KnowledgeCoordinator] Extraction failed or returned empty for block: {blockId}. Error: {result.Errors.FirstOrDefault()?.Message}");
}
_quizService.SetHydrating(false);
}
}
public void Dispose()
{
_debounceCts?.Cancel();
_debounceCts?.Dispose();
}
}
@@ -0,0 +1,25 @@
using NexusReader.Application.Queries.Graph;
namespace NexusReader.UI.Shared.Services;
public sealed class KnowledgeGraphService : IKnowledgeGraphService
{
public GraphDataDto? CurrentGraphData { get; private set; }
public string? ActiveNodeId { get; private set; }
public event Action? OnGraphUpdated;
public event Action<string>? OnActiveNodeChanged;
public void UpdateGraph(GraphDataDto newData)
{
CurrentGraphData = newData;
OnGraphUpdated?.Invoke();
}
public void SetActiveNode(string nodeId)
{
if (ActiveNodeId == nodeId) return;
ActiveNodeId = nodeId;
OnActiveNodeChanged?.Invoke(nodeId);
}
}
@@ -1,13 +1,42 @@
using NexusReader.Application.Queries.Quiz;
namespace NexusReader.UI.Shared.Services;
public sealed class QuizStateService : IQuizStateService
{
public string? CurrentQuizBlockId { get; private set; }
public QuizDto? CurrentQuiz { get; private set; }
public bool IsHydrating { get; private set; }
public bool HasNewQuiz { get; private set; }
public event Action<string>? OnQuizRequested;
public event Action? OnQuizUpdated;
public void RequestQuiz(string blockId)
{
CurrentQuizBlockId = blockId;
OnQuizRequested?.Invoke(blockId);
}
public void SetQuiz(string blockId, QuizDto quiz)
{
CurrentQuizBlockId = blockId;
CurrentQuiz = quiz;
IsHydrating = false;
HasNewQuiz = true;
OnQuizUpdated?.Invoke();
}
public void SetHydrating(bool hydrating)
{
IsHydrating = hydrating;
OnQuizUpdated?.Invoke();
}
public void MarkQuizAsSeen()
{
if (!HasNewQuiz) return;
HasNewQuiz = false;
OnQuizUpdated?.Invoke();
}
}
@@ -4,12 +4,15 @@ let simulation;
let zoomBehavior;
let svgElement;
let node, link, rootGroup, badge, width, height, currentDotNetHelper;
export function mount(containerId, data, dotNetHelper) {
const container = document.getElementById(containerId);
if (!container) return;
const width = container.clientWidth || 400;
const height = container.clientHeight || 400;
currentDotNetHelper = dotNetHelper;
width = container.clientWidth || 400;
height = container.clientHeight || 400;
// Create SVG
svgElement = d3.select(container).append("svg")
@@ -28,12 +31,17 @@ export function mount(containerId, data, dotNetHelper) {
radialGradient.append("stop").attr("offset", "100%").attr("stop-color", "var(--nexus-neon)").attr("stop-opacity", 0);
// Root Group for Zoom
const rootGroup = svgElement.append("g").attr("class", "zoom-containment");
rootGroup = svgElement.append("g").attr("class", "zoom-containment");
// Container groups for links and nodes to keep order (links below nodes)
rootGroup.append("g").attr("class", "links-layer");
rootGroup.append("g").attr("class", "nodes-layer");
// Badge Element (TU JESTEŚ)
const badge = rootGroup.append("g")
badge = rootGroup.append("g")
.attr("class", "active-badge")
.style("display", "none");
.style("display", "none")
.style("pointer-events", "none");
badge.append("rect")
.attr("x", -35)
@@ -53,92 +61,120 @@ export function mount(containerId, data, dotNetHelper) {
// Attach Zoom Behavior
zoomBehavior = d3.zoom()
.scaleExtent([0.5, 4])
.scaleExtent([0.3, 4])
.on("zoom", (e) => rootGroup.attr("transform", e.transform));
// Apply zoom but disable wheel interaction
svgElement.call(zoomBehavior)
.on("wheel.zoom", null);
svgElement.call(zoomBehavior).on("wheel.zoom", null);
// Subtle Link Distance & Charge
simulation = d3.forceSimulation(data.nodes)
.force("link", d3.forceLink(data.links).id(d => d.id).distance(100))
.force("charge", d3.forceManyBody().strength(-300))
simulation = d3.forceSimulation()
.force("link", d3.forceLink().id(d => d.id).distance(120))
.force("charge", d3.forceManyBody().strength(-400))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collide", d3.forceCollide().radius(40));
// Links
const link = rootGroup.append("g")
.selectAll("path")
.data(data.links)
.join("path")
.attr("stroke", "rgba(255,255,255,0.1)")
.attr("fill", "none")
.attr("stroke-width", 1.5);
// Nodes
const node = rootGroup.append("g")
.selectAll("g")
.data(data.nodes)
.join("g")
.style("cursor", "pointer")
.on("click", (e, d) => {
// Remove active state from all, add to clicked
node.selectAll(".node-pill").classed("nexus-node-active", false);
d3.select(e.currentTarget).select(".node-pill").classed("nexus-node-active", true);
// Show badge
badge.style("display", "block").datum(d);
dotNetHelper.invokeMethodAsync('OnNodeClicked', d.id);
})
.call(drag(simulation));
// Outer glow for nodes
node.append("circle")
.attr("r", 30)
.attr("fill", "url(#nebulaGlow)")
.attr("opacity", d => d.id === 'root' ? 0.6 : 0.2);
// Pill shape
node.append("rect")
.attr("class", "node-pill")
.attr("x", d => -(d.label.length * 4 + 10))
.attr("y", -12)
.attr("width", d => d.label.length * 8 + 20)
.attr("height", 24)
.attr("rx", 12)
.attr("fill", "rgba(30, 30, 30, 0.8)")
.attr("stroke", "rgba(255, 255, 255, 0.1)")
.attr("stroke-width", 1);
// Labels
node.append("text")
.text(d => d.label)
.attr("text-anchor", "middle")
.attr("y", 4)
.attr("fill", "#ccc")
.attr("font-family", "var(--nexus-font-sans)")
.attr("font-size", "0.8rem");
.force("collide", d3.forceCollide().radius(50));
simulation.on("tick", () => {
link.attr("d", d => {
const dx = d.target.x - d.source.x;
const dy = d.target.y - d.source.y;
const dr = Math.sqrt(dx * dx + dy * dy);
return `M${d.source.x},${d.source.y}A${dr},${dr} 0 0,1 ${d.target.x},${d.target.y}`;
});
if (link) {
link.attr("d", d => {
const dx = d.target.x - d.source.x;
const dy = d.target.y - d.source.y;
const dr = Math.sqrt(dx * dx + dy * dy);
return `M${d.source.x},${d.source.y}A${dr},${dr} 0 0,1 ${d.target.x},${d.target.y}`;
});
}
node.attr("transform", d => `translate(${d.x},${d.y})`);
if (node) {
node.attr("transform", d => `translate(${d.x},${d.y})`);
}
const activeData = badge.datum();
if (activeData) {
badge.attr("transform", `translate(${activeData.x},${activeData.y})`);
if (badge && badge.style("display") !== "none") {
const activeData = badge.datum();
if (activeData) {
badge.attr("transform", `translate(${activeData.x},${activeData.y})`);
}
}
});
updateData(data);
}
export function updateData(data) {
if (!simulation || !rootGroup) return;
// Keep existing node positions if they match by ID
const oldNodes = new Map(simulation.nodes().map(d => [d.id, d]));
data.nodes.forEach(d => {
if (oldNodes.has(d.id)) {
const old = oldNodes.get(d.id);
d.x = old.x;
d.y = old.y;
d.vx = old.vx;
d.vy = old.vy;
}
});
// Update Links
link = rootGroup.select(".links-layer")
.selectAll("path")
.data(data.links, d => d.source + "-" + d.target)
.join(
enter => enter.append("path")
.attr("stroke", "rgba(255,255,255,0.05)")
.attr("fill", "none")
.attr("stroke-width", 1.5)
.call(e => e.transition().duration(500).attr("stroke", "rgba(255,255,255,0.1)")),
update => update,
exit => exit.remove()
);
// Update Nodes
node = rootGroup.select(".nodes-layer")
.selectAll("g.node-group")
.data(data.nodes, d => d.id)
.join(
enter => {
const g = enter.append("g")
.attr("class", "node-group")
.style("cursor", "pointer")
.on("click", (e, d) => {
currentDotNetHelper.invokeMethodAsync('OnNodeClicked', d.id);
setActiveNode(d.id);
})
.call(drag(simulation));
g.append("circle")
.attr("r", 30)
.attr("fill", "url(#nebulaGlow)")
.attr("opacity", 0)
.transition().duration(1000).attr("opacity", d => d.group === 'current' ? 0.6 : 0.2);
g.append("rect")
.attr("class", "node-pill")
.attr("x", d => -(d.label.length * 4 + 10))
.attr("y", -12)
.attr("width", d => d.label.length * 8 + 20)
.attr("height", 24)
.attr("rx", 12)
.attr("fill", "rgba(20, 20, 20, 0.9)")
.attr("stroke", "rgba(255, 255, 255, 0.1)")
.attr("stroke-width", 1);
g.append("text")
.text(d => d.label)
.attr("text-anchor", "middle")
.attr("y", 4)
.attr("fill", "#ccc")
.attr("font-size", "0.8rem");
return g;
},
update => update,
exit => exit.remove()
);
simulation.nodes(data.nodes);
simulation.force("link").links(data.links);
simulation.alpha(0.5).restart();
}
function drag(simulation) {
function dragstarted(event) {
@@ -161,6 +197,29 @@ function drag(simulation) {
.on("end", dragended);
}
export function setActiveNode(nodeId) {
if (!svgElement || !node) return;
const targetNode = node.filter(d => d.id === nodeId);
if (targetNode.empty()) return;
const d = targetNode.datum();
// Reset all active classes
rootGroup.selectAll(".node-pill").classed("nexus-node-active", false);
targetNode.select(".node-pill").classed("nexus-node-active", true);
// Position badge
badge.style("display", "block").datum(d);
badge.attr("transform", `translate(${d.x},${d.y})`);
// Smooth transition
svgElement.transition().duration(1000).call(
zoomBehavior.transform,
d3.zoomIdentity.translate(width / 2, height / 2).scale(1.2).translate(-d.x, -d.y)
);
}
export function unmount(containerId) {
if (simulation) {
simulation.stop();
@@ -0,0 +1,22 @@
export function initObserver(dotNetHelper, containerSelector, itemSelector) {
const options = {
root: null,
rootMargin: '0px',
threshold: 0.6 // 60% of the block must be visible
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const id = entry.target.id;
const content = entry.target.innerText;
dotNetHelper.invokeMethodAsync('HandleBlockReached', id, content);
}
});
}, options);
const items = document.querySelectorAll(itemSelector);
items.forEach(item => observer.observe(item));
return observer;
}