feat: implement dynamic absolute positioning for the AI selection toolbar via JS to handle scroll and boundary collisions
This commit is contained in:
@@ -4,10 +4,11 @@
|
|||||||
@inject KnowledgeCoordinator Coordinator
|
@inject KnowledgeCoordinator Coordinator
|
||||||
@inject IReaderInteractionService InteractionService
|
@inject IReaderInteractionService InteractionService
|
||||||
@inject IQuizStateService QuizService
|
@inject IQuizStateService QuizService
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
@if (IsVisible)
|
@if (IsVisible)
|
||||||
{
|
{
|
||||||
<div class="selection-ai-panel @(PositionBelow ? "below" : "")" style="@PanelStyle">
|
<div class="selection-ai-panel @(_positionBelow ? "below" : "")" style="@_style">
|
||||||
<button class="toolbar-btn primary @(IsLoadingSummary ? "loading" : "") @(IsAnyLoading ? "disabled" : "")"
|
<button class="toolbar-btn primary @(IsLoadingSummary ? "loading" : "") @(IsAnyLoading ? "disabled" : "")"
|
||||||
disabled="@IsAnyLoading"
|
disabled="@IsAnyLoading"
|
||||||
@onclick="RequestSummaryAsync">
|
@onclick="RequestSummaryAsync">
|
||||||
@@ -50,22 +51,53 @@
|
|||||||
private bool IsLoadingSummary = false;
|
private bool IsLoadingSummary = false;
|
||||||
private bool IsLoadingQuiz = false;
|
private bool IsLoadingQuiz = false;
|
||||||
private bool IsAnyLoading => IsLoadingSummary || IsLoadingQuiz;
|
private bool IsAnyLoading => IsLoadingSummary || IsLoadingQuiz;
|
||||||
private bool PositionBelow => Coordinates != null && Coordinates.Top < 250;
|
|
||||||
|
private string _style = "visibility: hidden; opacity: 0; pointer-events: none;";
|
||||||
|
private bool _positionBelow = false;
|
||||||
|
private SelectionCoordinates? _lastCoordinates;
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
protected override void OnParametersSet()
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[SelectionAiPanel] Parameters set. SelectedText: {SelectedText.Length} chars, Coordinates: {Coordinates?.Top}, PositionBelow: {PositionBelow}");
|
Console.WriteLine($"[SelectionAiPanel] Parameters set. SelectedText: {SelectedText.Length} chars, Coordinates: {Coordinates?.Top}");
|
||||||
|
|
||||||
|
if (Coordinates != _lastCoordinates)
|
||||||
|
{
|
||||||
|
_lastCoordinates = Coordinates;
|
||||||
|
_style = "visibility: hidden; opacity: 0; pointer-events: none;";
|
||||||
|
_positionBelow = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Reset loading states when parameters change
|
// Reset loading states when parameters change
|
||||||
IsLoadingSummary = false;
|
IsLoadingSummary = false;
|
||||||
IsLoadingQuiz = false;
|
IsLoadingQuiz = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string PanelStyle => Coordinates != null
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
? string.Create(System.Globalization.CultureInfo.InvariantCulture,
|
{
|
||||||
$"top: {(PositionBelow ? Coordinates.Bottom + 16 : Coordinates.Top - 16):F1}px !important; " +
|
if (IsVisible && _style.Contains("visibility: hidden"))
|
||||||
$"left: {Math.Max(140.0, Math.Min(Coordinates.ViewportWidth - 140.0, Coordinates.Left + Coordinates.Width / 2.0)):F1}px !important; " +
|
{
|
||||||
$"transform: translate(-50%, {(PositionBelow ? "0" : "-100%")}) !important;")
|
try
|
||||||
: "";
|
{
|
||||||
|
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
|
||||||
|
var result = await module.InvokeAsync<PositionResult>("positionToolbar");
|
||||||
|
if (result != null)
|
||||||
|
{
|
||||||
|
_style = string.Create(System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
$"left: {result.Left:F1}px !important; " +
|
||||||
|
$"top: {result.Top:F1}px !important; " +
|
||||||
|
$"visibility: visible !important; " +
|
||||||
|
$"opacity: 1 !important; " +
|
||||||
|
$"pointer-events: auto !important;");
|
||||||
|
_positionBelow = result.Below;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[SelectionAiPanel] Error positioning toolbar: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task RequestSummaryAsync()
|
private async Task RequestSummaryAsync()
|
||||||
{
|
{
|
||||||
@@ -131,5 +163,13 @@
|
|||||||
{
|
{
|
||||||
await InteractionService.NotifyTextSelected(string.Empty, string.Empty, null!);
|
await InteractionService.NotifyTextSelected(string.Empty, string.Empty, null!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class PositionResult
|
||||||
|
{
|
||||||
|
public double Left { get; set; }
|
||||||
|
public double Top { get; set; }
|
||||||
|
public bool Below { get; set; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.selection-ai-panel {
|
.selection-ai-panel {
|
||||||
position: fixed;
|
position: absolute;
|
||||||
z-index: 9999;
|
z-index: 10000;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: rgba(24, 24, 28, 0.85);
|
background: rgba(24, 24, 28, 0.85);
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5), 0 12px 28px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5), 0 12px 28px rgba(0, 0, 0, 0.4);
|
||||||
padding: 4px 6px;
|
padding: 4px 6px;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
pointer-events: auto;
|
pointer-events: none; /* Controlled by inline styles */
|
||||||
user-select: none;
|
user-select: none;
|
||||||
animation: fadeInScale 0.18s cubic-bezier(0.16, 1, 0.3, 1);
|
animation: fadeInScale 0.18s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
@@ -19,11 +19,11 @@
|
|||||||
@keyframes fadeInScale {
|
@keyframes fadeInScale {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate(-50%, -80%) scale(0.96);
|
transform: scale(0.96);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate(-50%, -100%) scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,11 +34,11 @@
|
|||||||
@keyframes fadeInScaleBelow {
|
@keyframes fadeInScaleBelow {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate(-50%, -20%) scale(0.96);
|
transform: scale(0.96);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate(-50%, 0) scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,52 @@
|
|||||||
|
export function positionToolbar() {
|
||||||
|
const toolbarElement = document.querySelector('.selection-ai-panel');
|
||||||
|
if (!toolbarElement) return;
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection.isCollapsed) return;
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const rects = range.getClientRects();
|
||||||
|
if (!rects || rects.length === 0) return;
|
||||||
|
|
||||||
|
const firstRect = rects[0];
|
||||||
|
const combinedRect = range.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Find the canvas container (which is the positioned parent)
|
||||||
|
const canvasElement = document.querySelector('.reader-canvas');
|
||||||
|
let canvasRect = { top: 0, left: 0 };
|
||||||
|
let scrollTop = 0;
|
||||||
|
let scrollLeft = 0;
|
||||||
|
|
||||||
|
if (canvasElement) {
|
||||||
|
canvasRect = canvasElement.getBoundingClientRect();
|
||||||
|
scrollTop = canvasElement.scrollTop;
|
||||||
|
scrollLeft = canvasElement.scrollLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolbarWidth = toolbarElement.offsetWidth;
|
||||||
|
const toolbarHeight = toolbarElement.offsetHeight;
|
||||||
|
|
||||||
|
// Oblicz środek zaznaczenia w poziomie
|
||||||
|
const left = (combinedRect.left - canvasRect.left) + scrollLeft + (combinedRect.width / 2) - (toolbarWidth / 2);
|
||||||
|
|
||||||
|
// Warunek brzegowy (Top Screen Fallback)
|
||||||
|
const relativeTop = firstRect.top - toolbarHeight - 14;
|
||||||
|
|
||||||
|
let top;
|
||||||
|
if (relativeTop < 0) {
|
||||||
|
// Pozwól wskoczyć POD zaznaczony tekst
|
||||||
|
top = (combinedRect.bottom - canvasRect.top) + scrollTop + 12;
|
||||||
|
toolbarElement.classList.add('below');
|
||||||
|
} else {
|
||||||
|
top = (firstRect.top - canvasRect.top) + scrollTop - toolbarHeight - 14;
|
||||||
|
toolbarElement.classList.remove('below');
|
||||||
|
}
|
||||||
|
|
||||||
|
toolbarElement.style.left = `${left}px`;
|
||||||
|
toolbarElement.style.top = `${top}px`;
|
||||||
|
}
|
||||||
|
|
||||||
export function initSelectionListener(dotNetHelper, container) {
|
export function initSelectionListener(dotNetHelper, container) {
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
@@ -16,29 +65,32 @@ export function initSelectionListener(dotNetHelper, container) {
|
|||||||
|
|
||||||
const blockNode = node.closest('[id]');
|
const blockNode = node.closest('[id]');
|
||||||
|
|
||||||
if (blockNode) {
|
if (blockNode) {
|
||||||
const rects = range.getClientRects();
|
const rects = range.getClientRects();
|
||||||
const firstRect = rects && rects.length > 0 ? rects[0] : null;
|
const firstRect = rects && rects.length > 0 ? rects[0] : null;
|
||||||
const lastRect = rects && rects.length > 0 ? rects[rects.length - 1] : null;
|
const lastRect = rects && rects.length > 0 ? rects[rects.length - 1] : null;
|
||||||
const combinedRect = range.getBoundingClientRect();
|
const combinedRect = range.getBoundingClientRect();
|
||||||
|
|
||||||
const topVal = firstRect ? firstRect.top : combinedRect.top;
|
const topVal = firstRect ? firstRect.top : combinedRect.top;
|
||||||
const bottomVal = lastRect ? lastRect.bottom : combinedRect.bottom;
|
const bottomVal = lastRect ? lastRect.bottom : combinedRect.bottom;
|
||||||
|
|
||||||
console.log("[SelectionHandler] Selection coords (first/last/combined):", topVal, bottomVal, combinedRect.left);
|
console.log("[SelectionHandler] Selection coords (first/last/combined):", topVal, bottomVal, combinedRect.left);
|
||||||
|
|
||||||
dotNetHelper.invokeMethodAsync('HandleTextSelected',
|
dotNetHelper.invokeMethodAsync('HandleTextSelected',
|
||||||
text,
|
text,
|
||||||
blockNode.id,
|
blockNode.id,
|
||||||
{
|
{
|
||||||
Top: topVal,
|
Top: topVal,
|
||||||
Left: combinedRect.left,
|
Left: combinedRect.left,
|
||||||
Width: combinedRect.width,
|
Width: combinedRect.width,
|
||||||
Height: combinedRect.height,
|
Height: combinedRect.height,
|
||||||
Bottom: bottomVal,
|
Bottom: bottomVal,
|
||||||
ViewportWidth: window.innerWidth
|
ViewportWidth: window.innerWidth
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
// Reposition the toolbar if already present
|
||||||
|
setTimeout(positionToolbar, 0);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
dotNetHelper.invokeMethodAsync('HandleSelectionCleared');
|
dotNetHelper.invokeMethodAsync('HandleSelectionCleared');
|
||||||
}
|
}
|
||||||
@@ -48,3 +100,4 @@ export function initSelectionListener(dotNetHelper, container) {
|
|||||||
document.addEventListener('selectionchange', handleSelection);
|
document.addEventListener('selectionchange', handleSelection);
|
||||||
container.addEventListener('mouseup', () => setTimeout(handleSelection, 10));
|
container.addEventListener('mouseup', () => setTimeout(handleSelection, 10));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user