style(ui): refactor reader layout grid, fix focus mode layout collapse, fix SVG rendering dots, reorganize intelligence toolbar (#69)
Reorganized the reader toolbar and layout grid to improve visual consistency and layout robustness in Focus Mode. Fixed outline SVG rendering bugs that caused icons to show as solid dots. Closes #70 --------- Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Reviewed-on: #69 Co-authored-by: Antigravity <antigravity@google.com> Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #69.
This commit is contained in:
@@ -40,70 +40,70 @@ const getCategoryStyle = d => {
|
||||
// 1. Rule (red/coral)
|
||||
if (type === 'rule') {
|
||||
return {
|
||||
color: '#ff4646',
|
||||
fill: 'rgba(255, 70, 70, 0.1)',
|
||||
color: 'var(--nexus-node-rule, #ff4646)',
|
||||
fill: 'var(--nexus-node-rule-bg, rgba(255, 70, 70, 0.1))',
|
||||
opacity: 0.8,
|
||||
glowKey: 'rule',
|
||||
textColor: '#ff8b8b'
|
||||
textColor: 'var(--nexus-node-rule-text, #ff8b8b)'
|
||||
};
|
||||
}
|
||||
// 2. Definition (gold/amber)
|
||||
if (type === 'definition') {
|
||||
return {
|
||||
color: '#ffb03a',
|
||||
fill: 'rgba(255, 176, 58, 0.1)',
|
||||
color: 'var(--nexus-node-definition, #ffb03a)',
|
||||
fill: 'var(--nexus-node-definition-bg, rgba(255, 176, 58, 0.1))',
|
||||
opacity: 0.8,
|
||||
glowKey: 'definition',
|
||||
textColor: '#ffd18c'
|
||||
textColor: 'var(--nexus-node-definition-text, #ffd18c)'
|
||||
};
|
||||
}
|
||||
// 3. Table (purple/magenta)
|
||||
if (type === 'table') {
|
||||
return {
|
||||
color: '#d946ef',
|
||||
fill: 'rgba(217, 70, 239, 0.1)',
|
||||
color: 'var(--nexus-node-table, #d946ef)',
|
||||
fill: 'var(--nexus-node-table-bg, rgba(217, 70, 239, 0.1))',
|
||||
opacity: 0.8,
|
||||
glowKey: 'table',
|
||||
textColor: '#f5d0fe'
|
||||
textColor: 'var(--nexus-node-table-text, #f5d0fe)'
|
||||
};
|
||||
}
|
||||
// 4. Section (blue/indigo)
|
||||
if (type === 'section') {
|
||||
return {
|
||||
color: '#3b82f6',
|
||||
fill: 'rgba(59, 130, 246, 0.1)',
|
||||
color: 'var(--nexus-node-section, #3b82f6)',
|
||||
fill: 'var(--nexus-node-section-bg, rgba(59, 130, 246, 0.1))',
|
||||
opacity: 0.8,
|
||||
glowKey: 'section',
|
||||
textColor: '#93c5fd'
|
||||
textColor: 'var(--nexus-node-section-text, #93c5fd)'
|
||||
};
|
||||
}
|
||||
// 5. Bridge (cyan/comparison)
|
||||
if (group === 'bridge') {
|
||||
return {
|
||||
color: '#06b6d4',
|
||||
fill: 'rgba(6, 182, 212, 0.1)',
|
||||
color: 'var(--nexus-node-bridge, #06b6d4)',
|
||||
fill: 'var(--nexus-node-bridge-bg, rgba(6, 182, 212, 0.1))',
|
||||
opacity: 0.7,
|
||||
glowKey: 'bridge',
|
||||
textColor: '#67e8f9'
|
||||
textColor: 'var(--nexus-node-bridge-text, #67e8f9)'
|
||||
};
|
||||
}
|
||||
// 6. Current (active/focus landmark - neon green)
|
||||
if (group === 'current') {
|
||||
return {
|
||||
color: 'var(--nexus-neon)',
|
||||
fill: 'rgba(0, 255, 153, 0.15)',
|
||||
color: 'var(--nexus-node-current, var(--nexus-neon))',
|
||||
fill: 'var(--nexus-node-current-bg, rgba(0, 255, 153, 0.15))',
|
||||
opacity: 0.9,
|
||||
glowKey: 'current',
|
||||
textColor: '#ffffff'
|
||||
textColor: 'var(--nexus-node-current-text, #ffffff)'
|
||||
};
|
||||
}
|
||||
// 7. Concept / Default (subtle cool steel blue/teal)
|
||||
return {
|
||||
color: '#00d2c4',
|
||||
fill: 'rgba(0, 210, 196, 0.05)',
|
||||
color: 'var(--nexus-node-concept, #00d2c4)',
|
||||
fill: 'var(--nexus-node-concept-bg, rgba(0, 210, 196, 0.05))',
|
||||
opacity: 0.4,
|
||||
glowKey: 'concept',
|
||||
textColor: '#e0e0e0'
|
||||
textColor: 'var(--nexus-node-concept-text, #e0e0e0)'
|
||||
};
|
||||
};
|
||||
|
||||
@@ -131,16 +131,16 @@ const getNodeGlyph = d => {
|
||||
|
||||
function updateNodeAppearances() {
|
||||
if (!node) return;
|
||||
|
||||
node.each(function(d) {
|
||||
|
||||
node.each(function (d) {
|
||||
const g = d3.select(this);
|
||||
const rect = g.select(".node-pill");
|
||||
const text = g.select("text");
|
||||
|
||||
|
||||
const isCurrent = getNodeGroup(d) === 'current';
|
||||
const isSelected = activeNodeId && d.id === activeNodeId;
|
||||
const showFull = !isMobileMode || isSelected || isCurrent;
|
||||
|
||||
|
||||
if (showFull) {
|
||||
rect.transition().duration(250)
|
||||
.attr("x", -getPillWidth(d) / 2)
|
||||
@@ -148,7 +148,7 @@ function updateNodeAppearances() {
|
||||
.attr("height", 30)
|
||||
.attr("rx", 15)
|
||||
.attr("y", -15);
|
||||
|
||||
|
||||
text.text(getDisplayLabel(d))
|
||||
.attr("font-size", isCurrent || isSelected ? "0.85rem" : "0.8rem")
|
||||
.attr("font-weight", isCurrent || isSelected ? "600" : "normal");
|
||||
@@ -159,7 +159,7 @@ function updateNodeAppearances() {
|
||||
.attr("height", 30)
|
||||
.attr("rx", 15)
|
||||
.attr("y", -15);
|
||||
|
||||
|
||||
text.text(getNodeGlyph(d))
|
||||
.attr("font-size", "0.9rem")
|
||||
.attr("font-weight", "bold");
|
||||
@@ -170,7 +170,7 @@ function updateNodeAppearances() {
|
||||
export function setMobileMode(isMobile) {
|
||||
isMobileMode = isMobile;
|
||||
if (!simulation) return;
|
||||
|
||||
|
||||
if (isMobile) {
|
||||
simulation.force("charge", d3.forceManyBody().strength(-60));
|
||||
simulation.force("link").distance(180);
|
||||
@@ -187,7 +187,7 @@ export function setMobileMode(isMobile) {
|
||||
simulation.force("link").distance(120);
|
||||
simulation.force("collide", d3.forceCollide().radius(d => (getPillWidth(d) / 2) + 20));
|
||||
}
|
||||
|
||||
|
||||
updateNodeAppearances();
|
||||
simulation.alpha(0.3).restart();
|
||||
}
|
||||
@@ -208,11 +208,11 @@ export function mount(containerId, data, dotNetHelper) {
|
||||
.attr("viewBox", [0, 0, width, height])
|
||||
.attr("width", "100%")
|
||||
.attr("height", "100%")
|
||||
.style("background", "radial-gradient(circle, #1a1a1a 0%, #121212 100%)");
|
||||
.style("background", "var(--nexus-graph-bg, radial-gradient(circle, #1a1a1a 0%, #121212 100%))");
|
||||
|
||||
// Radial gradients for Nebula effects
|
||||
const defs = svgElement.append("defs");
|
||||
|
||||
|
||||
// Fallback radial gradient for legacy nebulaGlow
|
||||
const radialGradient = defs.append("radialGradient")
|
||||
.attr("id", "nebulaGlow")
|
||||
@@ -223,13 +223,13 @@ export function mount(containerId, data, dotNetHelper) {
|
||||
radialGradient.append("stop").attr("offset", "100%").attr("stop-color", "var(--nexus-neon)").attr("stop-opacity", 0);
|
||||
|
||||
const colors = {
|
||||
'rule': '#ff4646',
|
||||
'definition': '#ffb03a',
|
||||
'table': '#d946ef',
|
||||
'section': '#3b82f6',
|
||||
'bridge': '#06b6d4',
|
||||
'current': 'var(--nexus-neon)',
|
||||
'concept': '#00d2c4'
|
||||
'rule': 'var(--nexus-node-rule, #ff4646)',
|
||||
'definition': 'var(--nexus-node-definition, #ffb03a)',
|
||||
'table': 'var(--nexus-node-table, #d946ef)',
|
||||
'section': 'var(--nexus-node-section, #3b82f6)',
|
||||
'bridge': 'var(--nexus-node-bridge, #06b6d4)',
|
||||
'current': 'var(--nexus-node-current, var(--nexus-neon))',
|
||||
'concept': 'var(--nexus-node-concept, #00d2c4)'
|
||||
};
|
||||
|
||||
Object.entries(colors).forEach(([key, color]) => {
|
||||
@@ -275,7 +275,7 @@ export function mount(containerId, data, dotNetHelper) {
|
||||
zoomBehavior = d3.zoom()
|
||||
.scaleExtent([0.3, 4])
|
||||
.on("zoom", (e) => rootGroup.attr("transform", e.transform));
|
||||
|
||||
|
||||
svgElement.call(zoomBehavior).on("wheel.zoom", null);
|
||||
|
||||
// Use ResizeObserver for more reliable container size tracking
|
||||
@@ -324,7 +324,7 @@ export function mount(containerId, data, dotNetHelper) {
|
||||
return `translate(${d.x},${d.y})`;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (badge && badge.style("display") !== "none") {
|
||||
const activeData = badge.datum();
|
||||
if (activeData) {
|
||||
@@ -377,9 +377,9 @@ export function updateData(data) {
|
||||
enter => enter.append("path")
|
||||
.attr("stroke", d => {
|
||||
if (d.type === 'Defines' || d.type === 'maps_to') return 'var(--nexus-accent, #00ffaa)';
|
||||
if (d.type === 'Next' || d.type === 'relates_to') return 'rgba(255,255,255,0.2)';
|
||||
if (d.type === 'Next' || d.type === 'relates_to') return 'var(--nexus-graph-link-secondary, rgba(255,255,255,0.2))';
|
||||
if (d.type === 'Contains' || d.type === 'contains') return 'var(--nexus-neon)';
|
||||
return 'rgba(255,255,255,0.1)';
|
||||
return 'var(--nexus-graph-link-default, rgba(255,255,255,0.1))';
|
||||
})
|
||||
.attr("fill", "none")
|
||||
.attr("stroke-width", d => (d.type === 'Defines' || d.type === 'maps_to') ? 2 : 1)
|
||||
@@ -413,7 +413,7 @@ export function updateData(data) {
|
||||
|
||||
g.append("rect")
|
||||
.attr("class", "node-pill")
|
||||
.attr("fill", "rgba(20, 20, 20, 0.95)")
|
||||
.attr("fill", "var(--nexus-node-pill-bg, rgba(20, 20, 20, 0.95))")
|
||||
.attr("stroke", d => getCategoryStyle(d).color)
|
||||
.attr("stroke-width", d => getNodeGroup(d) === 'current' ? 2 : 1.2);
|
||||
|
||||
@@ -424,9 +424,9 @@ export function updateData(data) {
|
||||
|
||||
g.append("title")
|
||||
.text(d => d.description ? `${d.label}\n\n${d.description}` : d.label);
|
||||
|
||||
|
||||
g.transition().duration(500).style("opacity", 1);
|
||||
|
||||
|
||||
return g;
|
||||
},
|
||||
update => update.classed("neon-flash-node", false),
|
||||
@@ -466,7 +466,7 @@ function drag(simulation) {
|
||||
|
||||
export function setActiveNode(nodeId) {
|
||||
if (!svgElement || !node) return;
|
||||
|
||||
|
||||
activeNodeId = nodeId;
|
||||
// Safety check: ensure we only target the first occurrence if IDs are duplicated
|
||||
const targetNode = node.filter(d => d.id === nodeId);
|
||||
@@ -479,7 +479,7 @@ export function setActiveNode(nodeId) {
|
||||
const firstMatch = targetNode.filter((d, i) => i === 0);
|
||||
const d = firstMatch.datum();
|
||||
if (!d || d.x === undefined || d.y === undefined || isNaN(d.x) || !isFinite(d.x) || isNaN(d.y) || !isFinite(d.y)) return;
|
||||
|
||||
|
||||
// Reset all active classes
|
||||
rootGroup.selectAll(".node-pill").classed("nexus-node-active", false);
|
||||
firstMatch.select(".node-pill").classed("nexus-node-active", true);
|
||||
@@ -502,7 +502,7 @@ export function setActiveNode(nodeId) {
|
||||
return 20;
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
updateNodeAppearances();
|
||||
|
||||
// Smooth transition to the first matching node
|
||||
@@ -514,10 +514,10 @@ export function setActiveNode(nodeId) {
|
||||
|
||||
export function dimNodes(activeNodeId) {
|
||||
if (!node) return;
|
||||
|
||||
|
||||
node.transition().duration(500)
|
||||
.style("opacity", d => (activeNodeId === null || d.id === activeNodeId) ? 1 : 0.4);
|
||||
|
||||
|
||||
if (link) {
|
||||
link.transition().duration(500)
|
||||
.style("opacity", d => {
|
||||
@@ -558,7 +558,7 @@ export function handleResize(containerId) {
|
||||
|
||||
svgElement.attr("viewBox", [0, 0, width, height]);
|
||||
simulation.force("center", d3.forceCenter(width / 2, height / 2));
|
||||
|
||||
|
||||
const prevMobileMode = isMobileMode;
|
||||
isMobileMode = window.innerWidth < 768;
|
||||
if (isMobileMode !== prevMobileMode) {
|
||||
|
||||
@@ -1,7 +1,70 @@
|
||||
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;
|
||||
let below = false;
|
||||
if (relativeTop < 0) {
|
||||
// Pozwól wskoczyć POD zaznaczony tekst
|
||||
top = (combinedRect.bottom - canvasRect.top) + scrollTop + 12;
|
||||
below = true;
|
||||
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`;
|
||||
|
||||
return {
|
||||
left: left,
|
||||
top: top,
|
||||
below: below
|
||||
};
|
||||
}
|
||||
let currentHandleSelection = null;
|
||||
let currentMouseUpHandler = null;
|
||||
let currentContainer = null;
|
||||
|
||||
export function initSelectionListener(dotNetHelper, container) {
|
||||
if (!container) return;
|
||||
|
||||
console.log("[SelectionHandler] Initializing...");
|
||||
|
||||
// Clean up any existing listeners first
|
||||
destroySelectionListener();
|
||||
|
||||
const handleSelection = () => {
|
||||
const selection = window.getSelection();
|
||||
@@ -16,26 +79,60 @@ export function initSelectionListener(dotNetHelper, container) {
|
||||
|
||||
const blockNode = node.closest('[id]');
|
||||
|
||||
if (blockNode) {
|
||||
const rect = range.getBoundingClientRect();
|
||||
if (blockNode) {
|
||||
const rects = range.getClientRects();
|
||||
const firstRect = rects && rects.length > 0 ? rects[0] : null;
|
||||
const lastRect = rects && rects.length > 0 ? rects[rects.length - 1] : null;
|
||||
const combinedRect = range.getBoundingClientRect();
|
||||
|
||||
console.log("[SelectionHandler] Selection at screen coords:", rect.top, rect.left);
|
||||
const topVal = firstRect ? firstRect.top : combinedRect.top;
|
||||
const bottomVal = lastRect ? lastRect.bottom : combinedRect.bottom;
|
||||
|
||||
dotNetHelper.invokeMethodAsync('HandleTextSelected',
|
||||
text,
|
||||
blockNode.id,
|
||||
{
|
||||
Top: rect.top,
|
||||
Left: rect.left,
|
||||
Width: rect.width
|
||||
});
|
||||
}
|
||||
console.log("[SelectionHandler] Selection coords (first/last/combined):", topVal, bottomVal, combinedRect.left);
|
||||
|
||||
dotNetHelper.invokeMethodAsync('HandleTextSelected',
|
||||
text,
|
||||
blockNode.id,
|
||||
{
|
||||
Top: topVal,
|
||||
Left: combinedRect.left,
|
||||
Width: combinedRect.width,
|
||||
Height: combinedRect.height,
|
||||
Bottom: bottomVal,
|
||||
ViewportWidth: window.innerWidth
|
||||
});
|
||||
|
||||
// Reposition the toolbar if already present
|
||||
setTimeout(positionToolbar, 0);
|
||||
}
|
||||
} else {
|
||||
dotNetHelper.invokeMethodAsync('HandleSelectionCleared');
|
||||
}
|
||||
};
|
||||
|
||||
// Use multiple triggers for maximum reliability
|
||||
document.addEventListener('selectionchange', handleSelection);
|
||||
container.addEventListener('mouseup', () => setTimeout(handleSelection, 10));
|
||||
const mouseUpHandler = () => setTimeout(handleSelection, 10);
|
||||
|
||||
currentHandleSelection = handleSelection;
|
||||
currentMouseUpHandler = mouseUpHandler;
|
||||
currentContainer = container;
|
||||
|
||||
document.addEventListener('selectionchange', currentHandleSelection);
|
||||
currentContainer.addEventListener('mouseup', currentMouseUpHandler);
|
||||
}
|
||||
|
||||
export function destroySelectionListener() {
|
||||
if (currentHandleSelection) {
|
||||
document.removeEventListener('selectionchange', currentHandleSelection);
|
||||
currentHandleSelection = null;
|
||||
}
|
||||
if (currentMouseUpHandler && currentContainer) {
|
||||
currentContainer.removeEventListener('mouseup', currentMouseUpHandler);
|
||||
currentMouseUpHandler = null;
|
||||
currentContainer = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getSelectionText() {
|
||||
return window.getSelection().toString();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user