15 changed files with 2530 additions and 2240 deletions
@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
|
||||
# Index Section Feature Implementation |
||||
|
||||
## Overview |
||||
This implementation adds a tab area with an index section for navigating large Excalidraw canvases, as requested in feature request #10465. |
||||
|
||||
## Features Implemented |
||||
|
||||
### 1. Index Section Component (`IndexSection.tsx`) |
||||
- **Location**: `packages/excalidraw/components/IndexSection/` |
||||
- **Functionality**: |
||||
- Display list of saved navigation points (pins) |
||||
- Add new pins with custom names |
||||
- Navigate to saved locations |
||||
- Delete existing pins |
||||
- Show coordinates for each pin |
||||
|
||||
### 2. Index Button (`IndexButton.tsx`) |
||||
- **Location**: `packages/excalidraw/components/IndexSection/` |
||||
- **Functionality**: |
||||
- Trigger button to open/close the index section |
||||
- Positioned in the top-right UI area |
||||
- Uses MapPin icon for visual clarity |
||||
|
||||
### 3. Navigation Features |
||||
- **Pin Creation**: |
||||
- Click "Add Pin" to create a navigation point |
||||
- If elements are selected, pin will be associated with the first selected element |
||||
- If no elements selected, pin will be created at current viewport center |
||||
- **Navigation**: |
||||
- Click the eye icon to navigate to a pin location |
||||
- If pin is associated with an element, it will be selected automatically |
||||
- Smooth animated navigation to the target location |
||||
|
||||
### 4. UI Integration |
||||
- **LayerUI Integration**: Added IndexButton to the top-right UI area |
||||
- **Responsive Design**: Component adapts to different screen sizes |
||||
- **Theme Support**: Supports both light and dark themes |
||||
|
||||
### 5. Localization |
||||
- **English Strings**: Added to `locales/en.json` |
||||
- **Keys Added**: |
||||
- `indexSection.title`: "Index" |
||||
- `indexSection.addPin`: "Add Pin" |
||||
- `indexSection.enterName`: "Enter pin name..." |
||||
- `indexSection.empty`: "No pins added yet" |
||||
- `indexSection.help`: "Select elements or click 'Add Pin' to create navigation points" |
||||
- `indexSection.goTo`: "Go to location" |
||||
|
||||
### 6. Icons |
||||
- **New Icons Added**: MapPin, Eye, Trash icons for the interface |
||||
- **Location**: Added to `components/icons.tsx` |
||||
|
||||
## Files Created/Modified |
||||
|
||||
### New Files: |
||||
1. `components/IndexSection/IndexSection.tsx` - Main component |
||||
2. `components/IndexSection/IndexSection.scss` - Styles |
||||
3. `components/IndexSection/IndexButton.tsx` - Trigger button |
||||
4. `components/IndexSection/index.ts` - Exports |
||||
|
||||
### Modified Files: |
||||
1. `components/LayerUI.tsx` - Added IndexButton integration |
||||
2. `components/icons.tsx` - Added new icons |
||||
3. `locales/en.json` - Added localization strings |
||||
|
||||
## Usage Instructions |
||||
|
||||
1. **Opening the Index**: Click the MapPin icon in the top-right area of the interface |
||||
2. **Adding Pins**: |
||||
- Select elements you want to bookmark (optional) |
||||
- Click "Add Pin" button |
||||
- Enter a descriptive name for the pin |
||||
- Press Enter or click "Add" to save |
||||
3. **Navigating**: Click the eye icon next to any pin to navigate to that location |
||||
4. **Managing Pins**: Click the trash icon to delete unwanted pins |
||||
|
||||
## Technical Implementation Details |
||||
|
||||
### State Management |
||||
- Uses React hooks (useState, useCallback) for local state management |
||||
- Integrates with Excalidraw's app state for navigation and element selection |
||||
|
||||
### Navigation Logic |
||||
- For element-associated pins: Uses `scrollToContent()` with element reference |
||||
- For coordinate-based pins: Updates viewport scroll position directly |
||||
- Smooth animations provided by Excalidraw's built-in navigation system |
||||
|
||||
### Styling |
||||
- Follows Excalidraw's design system and CSS variable conventions |
||||
- Responsive design with proper overflow handling |
||||
- Theme-aware styling for light/dark mode compatibility |
||||
|
||||
## Future Enhancements (Not Implemented) |
||||
- Persistence across sessions (would require integration with Excalidraw's data layer) |
||||
- Pin categories/grouping |
||||
- Import/export pin collections |
||||
- Keyboard shortcuts for quick navigation |
||||
- Pin thumbnails/previews |
||||
|
||||
## Testing Recommendations |
||||
1. Test pin creation with and without selected elements |
||||
2. Verify navigation works correctly for both element-based and coordinate-based pins |
||||
3. Test UI responsiveness on different screen sizes |
||||
4. Verify theme compatibility (light/dark modes) |
||||
5. Test localization with different languages (when translations are added) |
||||
@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
|
||||
import { CaptureUpdateAction } from "@excalidraw/element"; |
||||
import { register } from "./register"; |
||||
import type { IndexItem } from "../types"; |
||||
|
||||
export const actionAddIndexItem = register({ |
||||
name: "addIndexItem", |
||||
label: "Add index item", |
||||
trackEvent: { category: "canvas" }, |
||||
predicate: () => true, |
||||
perform: (elements, appState, formData: IndexItem) => { |
||||
return { |
||||
appState: { |
||||
indexItems: [...appState.indexItems, formData], |
||||
}, |
||||
captureUpdate: CaptureUpdateAction.UPDATE, |
||||
}; |
||||
}, |
||||
}); |
||||
|
||||
export const actionRemoveIndexItem = register({ |
||||
name: "removeIndexItem",
|
||||
label: "Remove index item", |
||||
trackEvent: { category: "canvas" }, |
||||
predicate: () => true, |
||||
perform: (elements, appState, formData: { id: string }) => { |
||||
return { |
||||
appState: { |
||||
indexItems: appState.indexItems.filter(item => item.id !== formData.id), |
||||
}, |
||||
captureUpdate: CaptureUpdateAction.UPDATE, |
||||
}; |
||||
}, |
||||
}); |
||||
|
||||
export const actionUpdateIndexItem = register({ |
||||
name: "updateIndexItem", |
||||
label: "Update index item",
|
||||
trackEvent: { category: "canvas" }, |
||||
predicate: () => true, |
||||
perform: (elements, appState, formData: { id: string; updates: Partial<IndexItem> }) => { |
||||
return { |
||||
appState: { |
||||
indexItems: appState.indexItems.map(item =>
|
||||
item.id === formData.id ? { ...item, ...formData.updates } : item |
||||
), |
||||
}, |
||||
captureUpdate: CaptureUpdateAction.UPDATE, |
||||
}; |
||||
}, |
||||
}); |
||||
@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
|
||||
import React, { useState } from "react"; |
||||
import { Button } from "../Button"; |
||||
import { MapPin } from "../icons"; |
||||
import { IndexSection } from "./IndexSection"; |
||||
import { t } from "../../i18n"; |
||||
import type { AppClassProperties, AppState } from "../../types"; |
||||
|
||||
interface IndexButtonProps { |
||||
app: AppClassProperties; |
||||
appState: AppState; |
||||
} |
||||
|
||||
export const IndexButton: React.FC<IndexButtonProps> = ({ app, appState }) => { |
||||
const [showIndex, setShowIndex] = useState(false); |
||||
|
||||
return ( |
||||
<> |
||||
<Button |
||||
type="button" |
||||
size="small" |
||||
onClick={() => setShowIndex(true)} |
||||
title={t("indexSection.title")} |
||||
> |
||||
<MapPin size={16} /> |
||||
</Button> |
||||
{showIndex && ( |
||||
<div |
||||
style={{ |
||||
position: "fixed", |
||||
top: "80px", |
||||
right: "20px", |
||||
zIndex: 1000, |
||||
}} |
||||
> |
||||
<IndexSection |
||||
app={app} |
||||
appState={appState} |
||||
onClose={() => setShowIndex(false)} |
||||
/> |
||||
</div> |
||||
)} |
||||
</> |
||||
); |
||||
}; |
||||
@ -0,0 +1,198 @@
@@ -0,0 +1,198 @@
|
||||
.index-section { |
||||
width: 280px; |
||||
max-height: 400px; |
||||
background: var(--island-bg-color); |
||||
border: 1px solid var(--default-border-color); |
||||
border-radius: var(--space-factor); |
||||
box-shadow: var(--shadow-island); |
||||
display: flex; |
||||
flex-direction: column; |
||||
overflow: hidden; |
||||
|
||||
&__header { |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: space-between; |
||||
padding: var(--space-factor) calc(var(--space-factor) * 1.5); |
||||
border-bottom: 1px solid var(--default-border-color); |
||||
background: var(--button-gray-1); |
||||
|
||||
h3 { |
||||
margin: 0; |
||||
font-size: 14px; |
||||
font-weight: 600; |
||||
color: var(--text-color-primary); |
||||
} |
||||
|
||||
button { |
||||
padding: 2px 6px; |
||||
font-size: 16px; |
||||
line-height: 1; |
||||
color: var(--text-color-primary); |
||||
|
||||
&:hover { |
||||
background: var(--button-gray-2); |
||||
} |
||||
} |
||||
} |
||||
|
||||
&__content { |
||||
display: flex; |
||||
flex-direction: column; |
||||
flex: 1; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
&__add-item { |
||||
padding: var(--space-factor); |
||||
border-bottom: 1px solid var(--default-border-color); |
||||
} |
||||
|
||||
&__add-form { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: calc(var(--space-factor) * 0.5); |
||||
|
||||
input { |
||||
width: 100%; |
||||
padding: 6px 8px; |
||||
border: 1px solid var(--default-border-color); |
||||
border-radius: 4px; |
||||
font-size: 12px; |
||||
background: var(--input-bg); |
||||
color: var(--text-color-primary); |
||||
|
||||
&:focus { |
||||
outline: none; |
||||
border-color: var(--color-primary); |
||||
} |
||||
} |
||||
} |
||||
|
||||
&__add-buttons { |
||||
display: flex; |
||||
gap: calc(var(--space-factor) * 0.5); |
||||
|
||||
button { |
||||
flex: 1; |
||||
padding: 4px 8px; |
||||
font-size: 12px; |
||||
} |
||||
} |
||||
|
||||
&__items { |
||||
flex: 1; |
||||
overflow-y: auto; |
||||
padding: calc(var(--space-factor) * 0.5); |
||||
} |
||||
|
||||
&__empty { |
||||
padding: calc(var(--space-factor) * 2); |
||||
text-align: center; |
||||
color: var(--text-color-primary); |
||||
|
||||
p { |
||||
margin: 0 0 calc(var(--space-factor) * 0.5) 0; |
||||
font-size: 12px; |
||||
|
||||
&.index-section__help { |
||||
color: var(--text-color-secondary); |
||||
font-size: 11px; |
||||
} |
||||
} |
||||
} |
||||
|
||||
&__list { |
||||
list-style: none; |
||||
margin: 0; |
||||
padding: 0; |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 2px; |
||||
} |
||||
|
||||
&__item { |
||||
border-radius: 4px; |
||||
transition: background-color 0.1s ease; |
||||
|
||||
&:hover { |
||||
background: var(--button-gray-1); |
||||
} |
||||
} |
||||
|
||||
&__item-content { |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: space-between; |
||||
padding: 6px 8px; |
||||
gap: 8px; |
||||
} |
||||
|
||||
&__item-info { |
||||
flex: 1; |
||||
min-width: 0; |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 2px; |
||||
} |
||||
|
||||
&__item-name { |
||||
font-size: 12px; |
||||
font-weight: 500; |
||||
color: var(--text-color-primary); |
||||
white-space: nowrap; |
||||
overflow: hidden; |
||||
text-overflow: ellipsis; |
||||
cursor: pointer; |
||||
|
||||
&:hover { |
||||
background: var(--button-gray-1); |
||||
border-radius: 2px; |
||||
padding: 1px 2px; |
||||
margin: -1px -2px; |
||||
} |
||||
} |
||||
|
||||
&__item-coords { |
||||
font-size: 10px; |
||||
color: var(--text-color-secondary); |
||||
font-family: var(--font-family-code); |
||||
} |
||||
|
||||
&__item-actions { |
||||
display: flex; |
||||
gap: 2px; |
||||
flex-shrink: 0; |
||||
|
||||
button { |
||||
padding: 4px; |
||||
border-radius: 3px; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
color: var(--text-color-secondary); |
||||
|
||||
&:hover { |
||||
background: var(--button-gray-2); |
||||
color: var(--text-color-primary); |
||||
} |
||||
|
||||
svg { |
||||
display: block; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Dark theme adjustments |
||||
.theme--dark { |
||||
.index-section { |
||||
&__header { |
||||
background: var(--button-gray-2); |
||||
} |
||||
|
||||
&__item:hover { |
||||
background: var(--button-gray-2); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
|
||||
import React from "react"; |
||||
import { render, screen, fireEvent } from "@testing-library/react"; |
||||
import { IndexSection } from "./IndexSection"; |
||||
import { getDefaultAppState } from "../../appState"; |
||||
import type { AppClassProperties, IndexItem } from "../../types"; |
||||
|
||||
// Mock the actions
|
||||
jest.mock("../../actions/actionIndex", () => ({ |
||||
actionAddIndexItem: { |
||||
perform: jest.fn(() => ({ captureUpdate: "UPDATE" })), |
||||
}, |
||||
actionRemoveIndexItem: { |
||||
perform: jest.fn(() => ({ captureUpdate: "UPDATE" })), |
||||
}, |
||||
actionUpdateIndexItem: { |
||||
perform: jest.fn(() => ({ captureUpdate: "UPDATE" })), |
||||
}, |
||||
})); |
||||
|
||||
const mockApp = { |
||||
scene: { |
||||
getElements: jest.fn(() => []), |
||||
getSelectedElements: jest.fn(() => []), |
||||
getNonDeletedElementsMap: jest.fn(() => new Map()), |
||||
}, |
||||
syncActionResult: jest.fn(), |
||||
} as unknown as AppClassProperties; |
||||
|
||||
const mockAppState = { |
||||
...getDefaultAppState(), |
||||
width: 800, |
||||
height: 600, |
||||
scrollX: 0, |
||||
scrollY: 0, |
||||
indexItems: [] as IndexItem[], |
||||
}; |
||||
|
||||
describe("IndexSection", () => { |
||||
it("renders empty state correctly", () => { |
||||
render( |
||||
<IndexSection |
||||
app={mockApp} |
||||
appState={mockAppState} |
||||
onClose={jest.fn()} |
||||
/> |
||||
); |
||||
|
||||
expect(screen.getByText("No pins added yet")).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it("shows add pin form when button is clicked", () => { |
||||
render( |
||||
<IndexSection |
||||
app={mockApp} |
||||
appState={mockAppState} |
||||
onClose={jest.fn()} |
||||
/> |
||||
); |
||||
|
||||
fireEvent.click(screen.getByText("Add Pin")); |
||||
expect(screen.getByPlaceholderText("Enter pin name...")).toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,311 @@
@@ -0,0 +1,311 @@
|
||||
import React, { useState, useCallback, useEffect } from "react"; |
||||
import clsx from "clsx"; |
||||
import { t } from "../../i18n"; |
||||
import { Island } from "../Island"; |
||||
import { Button } from "../Button"; |
||||
import { TextField } from "../TextField"; |
||||
import { Trash, MapPin, Eye } from "../icons"; |
||||
import type { AppClassProperties, AppState, IndexItem } from "../../types"; |
||||
import type { ExcalidrawElement } from "@excalidraw/element/types"; |
||||
import { sceneCoordsToViewportCoords } from "@excalidraw/common"; |
||||
import { CaptureUpdateAction } from "@excalidraw/element"; |
||||
import { actionAddIndexItem, actionRemoveIndexItem, actionUpdateIndexItem } from "../../actions/actionIndex"; |
||||
|
||||
import "./IndexSection.scss"; |
||||
|
||||
interface IndexSectionProps { |
||||
app: AppClassProperties; |
||||
appState: AppState; |
||||
onClose: () => void; |
||||
} |
||||
|
||||
export const IndexSection: React.FC<IndexSectionProps> = ({ |
||||
app, |
||||
appState, |
||||
onClose, |
||||
}) => { |
||||
const [newItemName, setNewItemName] = useState(""); |
||||
const [isAddingItem, setIsAddingItem] = useState(false); |
||||
const [editingId, setEditingId] = useState<string | null>(null); |
||||
const [editingName, setEditingName] = useState(""); |
||||
|
||||
// Clean up orphaned pins when elements are deleted
|
||||
useEffect(() => { |
||||
const elementsMap = app.scene.getNonDeletedElementsMap(); |
||||
const orphanedItems = appState.indexItems.filter( |
||||
item => item.elementId && !elementsMap.has(item.elementId) |
||||
); |
||||
|
||||
orphanedItems.forEach(item => { |
||||
app.syncActionResult(actionRemoveIndexItem.perform( |
||||
app.scene.getElements(), |
||||
appState, |
||||
{ id: item.id }, |
||||
app |
||||
)); |
||||
}); |
||||
}, [app.scene.getElements(), appState.indexItems, app]); |
||||
|
||||
const addIndexItem = useCallback(() => { |
||||
if (!newItemName.trim()) return; |
||||
|
||||
const selectedElements = app.scene.getSelectedElements(appState); |
||||
let x = appState.scrollX + appState.width / 2; |
||||
let y = appState.scrollY + appState.height / 2; |
||||
let elementId: string | undefined; |
||||
|
||||
// If an element is selected, use its position
|
||||
if (selectedElements.length > 0) { |
||||
const element = selectedElements[0]; |
||||
x = element.x + element.width / 2; |
||||
y = element.y + element.height / 2; |
||||
elementId = element.id; |
||||
} |
||||
|
||||
const newItem: IndexItem = { |
||||
id: Date.now().toString(), |
||||
name: newItemName.trim(), |
||||
x, |
||||
y, |
||||
elementId, |
||||
timestamp: Date.now(), |
||||
}; |
||||
|
||||
app.syncActionResult(actionAddIndexItem.perform( |
||||
app.scene.getElements(), |
||||
appState, |
||||
newItem, |
||||
app |
||||
)); |
||||
setNewItemName(""); |
||||
setIsAddingItem(false); |
||||
}, [newItemName, app, appState]); |
||||
|
||||
const removeIndexItem = useCallback((id: string) => { |
||||
app.syncActionResult(actionRemoveIndexItem.perform( |
||||
app.scene.getElements(), |
||||
appState, |
||||
{ id }, |
||||
app |
||||
)); |
||||
}, [app, appState]); |
||||
|
||||
const startEditing = useCallback((item: IndexItem) => { |
||||
setEditingId(item.id); |
||||
setEditingName(item.name); |
||||
}, []); |
||||
|
||||
const saveEdit = useCallback(() => { |
||||
if (!editingId || !editingName.trim()) return; |
||||
|
||||
app.syncActionResult(actionUpdateIndexItem.perform( |
||||
app.scene.getElements(), |
||||
appState, |
||||
{ id: editingId, updates: { name: editingName.trim() } }, |
||||
app |
||||
)); |
||||
setEditingId(null); |
||||
setEditingName(""); |
||||
}, [editingId, editingName, app, appState]); |
||||
|
||||
const cancelEdit = useCallback(() => { |
||||
setEditingId(null); |
||||
setEditingName(""); |
||||
}, []); |
||||
|
||||
const navigateToItem = useCallback((item: IndexItem) => { |
||||
// If the item is associated with an element, navigate to it and select it
|
||||
if (item.elementId) { |
||||
const element = app.scene.getNonDeletedElementsMap().get(item.elementId); |
||||
if (element) { |
||||
app.scrollToContent([element], { |
||||
animate: true, |
||||
fitToContent: true, |
||||
}); |
||||
app.syncActionResult({ |
||||
appState: { |
||||
selectedElementIds: { [item.elementId]: true }, |
||||
}, |
||||
captureUpdate: CaptureUpdateAction.NEVER, |
||||
}); |
||||
return; |
||||
} |
||||
} |
||||
|
||||
// Navigate to the coordinates by updating the viewport
|
||||
const centerX = item.x - appState.width / 2; |
||||
const centerY = item.y - appState.height / 2; |
||||
|
||||
app.syncActionResult({ |
||||
appState: { |
||||
scrollX: centerX, |
||||
scrollY: centerY, |
||||
}, |
||||
captureUpdate: CaptureUpdateAction.NEVER, |
||||
}); |
||||
}, [app, appState.width, appState.height]); |
||||
|
||||
const getCurrentViewportCenter = () => { |
||||
return { |
||||
x: appState.scrollX + appState.width / 2, |
||||
y: appState.scrollY + appState.height / 2, |
||||
}; |
||||
}; |
||||
|
||||
return ( |
||||
<div className="index-section"> |
||||
<div className="index-section__header"> |
||||
<h3>{t("indexSection.title")}</h3> |
||||
<Button |
||||
type="button" |
||||
size="small" |
||||
onClick={onClose} |
||||
title={t("buttons.close")} |
||||
> |
||||
× |
||||
</Button> |
||||
</div> |
||||
|
||||
<div className="index-section__content"> |
||||
<div className="index-section__add-item"> |
||||
{!isAddingItem ? ( |
||||
<Button |
||||
type="button" |
||||
size="small" |
||||
onClick={() => setIsAddingItem(true)} |
||||
title={t("indexSection.addItem")} |
||||
> |
||||
<MapPin size={16} /> |
||||
{t("indexSection.addPin")} |
||||
</Button> |
||||
) : ( |
||||
<div className="index-section__add-form"> |
||||
<TextField |
||||
value={newItemName} |
||||
onChange={(value) => setNewItemName(value)} |
||||
placeholder={t("indexSection.enterName")} |
||||
onKeyDown={(e) => { |
||||
if (e.key === "Enter") { |
||||
addIndexItem(); |
||||
} else if (e.key === "Escape") { |
||||
setIsAddingItem(false); |
||||
setNewItemName(""); |
||||
} |
||||
}} |
||||
autoFocus |
||||
/> |
||||
<div className="index-section__add-buttons"> |
||||
<Button |
||||
type="button" |
||||
size="small" |
||||
onClick={addIndexItem} |
||||
disabled={!newItemName.trim()} |
||||
> |
||||
{t("buttons.add")} |
||||
</Button> |
||||
<Button |
||||
type="button" |
||||
size="small" |
||||
onClick={() => { |
||||
setIsAddingItem(false); |
||||
setNewItemName(""); |
||||
}} |
||||
> |
||||
{t("buttons.cancel")} |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
)} |
||||
</div> |
||||
|
||||
<div className="index-section__items"> |
||||
{appState.indexItems.length === 0 ? ( |
||||
<div className="index-section__empty"> |
||||
<p>{t("indexSection.empty")}</p> |
||||
<p className="index-section__help"> |
||||
{t("indexSection.help")} |
||||
</p> |
||||
</div> |
||||
) : ( |
||||
<ul className="index-section__list"> |
||||
{appState.indexItems.map((item) => ( |
||||
<li key={item.id} className="index-section__item"> |
||||
<div className="index-section__item-content"> |
||||
<div className="index-section__item-info"> |
||||
{editingId === item.id ? ( |
||||
<TextField |
||||
value={editingName} |
||||
onChange={setEditingName} |
||||
onKeyDown={(e) => { |
||||
if (e.key === "Enter") { |
||||
saveEdit(); |
||||
} else if (e.key === "Escape") { |
||||
cancelEdit(); |
||||
} |
||||
}} |
||||
autoFocus |
||||
/> |
||||
) : ( |
||||
<> |
||||
<span
|
||||
className="index-section__item-name" |
||||
onDoubleClick={() => startEditing(item)} |
||||
> |
||||
{item.name} |
||||
</span> |
||||
<span className="index-section__item-coords"> |
||||
({Math.round(item.x)}, {Math.round(item.y)}) |
||||
</span> |
||||
</> |
||||
)} |
||||
</div> |
||||
<div className="index-section__item-actions"> |
||||
{editingId === item.id ? ( |
||||
<> |
||||
<Button |
||||
type="button" |
||||
size="small" |
||||
onClick={saveEdit} |
||||
disabled={!editingName.trim()} |
||||
> |
||||
✓ |
||||
</Button> |
||||
<Button |
||||
type="button" |
||||
size="small" |
||||
onClick={cancelEdit} |
||||
> |
||||
✕ |
||||
</Button> |
||||
</> |
||||
) : ( |
||||
<> |
||||
<Button |
||||
type="button" |
||||
size="small" |
||||
onClick={() => navigateToItem(item)} |
||||
title={t("indexSection.goTo")} |
||||
> |
||||
<Eye size={14} /> |
||||
</Button> |
||||
<Button |
||||
type="button" |
||||
size="small" |
||||
onClick={() => removeIndexItem(item.id)} |
||||
title={t("buttons.delete")} |
||||
> |
||||
<Trash size={14} /> |
||||
</Button> |
||||
</> |
||||
)} |
||||
</div> |
||||
</div> |
||||
</li> |
||||
))} |
||||
</ul> |
||||
)} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
}; |
||||
@ -0,0 +1,2 @@
@@ -0,0 +1,2 @@
|
||||
export { IndexSection } from "./IndexSection"; |
||||
export { IndexButton } from "./IndexButton"; |
||||
Loading…
Reference in new issue