diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 76c75a26..5072da6e 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -149,6 +149,16 @@ export default defineConfig({ { text: "Text Styles", link: "/typescript/styling/text-styles" } ] }, + { + text: "UI Components", + collapsed: false, + items: [ + { + text: "Ready-made Style Panel", + link: "/typescript/ui-components/style-panel" + } + ] + }, { text: "Managing Annotations", collapsed: false, @@ -215,6 +225,10 @@ export default defineConfig({ text: "Building UI Components", collapsed: false, items: [ + { + text: "Ready-made UI", + link: "/react/ui-components/ready-made" + }, { text: "Toolbar", link: "/react/ui-components/toolbar" }, { text: "Style Panel", diff --git a/docs/public/ui/.DS_Store b/docs/public/ui/.DS_Store new file mode 100644 index 00000000..5008ddfc Binary files /dev/null and b/docs/public/ui/.DS_Store differ diff --git a/docs/public/ui/README.md b/docs/public/ui/README.md new file mode 100644 index 00000000..8b0308db --- /dev/null +++ b/docs/public/ui/README.md @@ -0,0 +1,17 @@ +# UI control-panel screenshots + +Drop the screenshots here. They are referenced from the docs with absolute +paths (VitePress serves `docs/public/` at the site root), e.g. `/ui/add-menu.png`. + +Expected files (PNG, retina / 2x where possible, trimmed to the control with a +little padding): + +| File | Shot | Used in | +| --- | --- | --- | +| `react-editor.png` | Full React editor: `AddMenu` toolbar + `ViewControls` + style panel over an annotated graph | `react/ui-components/ready-made.md` | +| `add-menu.png` | The `AddMenu` toolbar on its own | `react/ui-components/ready-made.md` | +| `panel-arrow.png` | Style panel with an **arrow** selected (color, extremities, stroke width, line type) | `react/ui-components/ready-made.md` | +| `panel-text.png` | Style panel with a **text** annotation selected (color, background, font, font size, stroke width, line type) | `react/ui-components/ready-made.md` | +| `vanilla-panel.png` | The vanilla `AnnotationPanel` (any annotation type) | `typescript/ui-components/style-panel.md` | + +Once added, delete this README if you like — it isn't referenced anywhere. diff --git a/docs/public/ui/add-menu.png b/docs/public/ui/add-menu.png new file mode 100644 index 00000000..8e6046b0 Binary files /dev/null and b/docs/public/ui/add-menu.png differ diff --git a/docs/public/ui/panel-arrow.png b/docs/public/ui/panel-arrow.png new file mode 100644 index 00000000..097ca541 Binary files /dev/null and b/docs/public/ui/panel-arrow.png differ diff --git a/docs/public/ui/panel-text.png b/docs/public/ui/panel-text.png new file mode 100644 index 00000000..097ca541 Binary files /dev/null and b/docs/public/ui/panel-text.png differ diff --git a/docs/public/ui/react-editor.png b/docs/public/ui/react-editor.png new file mode 100644 index 00000000..097ca541 Binary files /dev/null and b/docs/public/ui/react-editor.png differ diff --git a/docs/public/ui/vanilla-panel.png b/docs/public/ui/vanilla-panel.png new file mode 100644 index 00000000..097ca541 Binary files /dev/null and b/docs/public/ui/vanilla-panel.png differ diff --git a/docs/react/ui-components/ready-made.md b/docs/react/ui-components/ready-made.md new file mode 100644 index 00000000..97be0579 --- /dev/null +++ b/docs/react/ui-components/ready-made.md @@ -0,0 +1,180 @@ +# Ready-made UI Components + +The React package ships a set of styled, themeable UI components so you don't +have to build a toolbar and style panel from scratch. They live under the +`@linkurious/ogma-annotations-react/ui` subpath and are completely optional — +importing the main package entry stays headless and pulls in none of this code +or CSS. + +> Prefer to build your own? See [Building a Toolbar](./toolbar), +> [Building a Style Panel](./style-panel) and +> [Annotation List](./annotation-list). The ready-made components are built on +> the same `useAnnotationsContext` hook, so you can mix and match. + +## Installation + +The components are part of the package — no extra install. Import them from the +`/ui` subpath and load the stylesheet once in your app: + +```tsx +import { + AnnotationPanelController, + AddMenu, + ViewControls +} from "@linkurious/ogma-annotations-react/ui"; + +// Load once, e.g. in your entry file: +import "@linkurious/ogma-annotations-react/ui/styles.css"; +``` + +::: tip Why a separate subpath? +Keeping the UI on `/ui` means consumers who only want the headless editor pay +nothing for the styled components or their CSS. The icons are inline SVG, so +there is no icon font or extra icon dependency to wire up. +::: + +## Putting it together + +Drop the components inside an `AnnotationsContextProvider` (see +[the provider docs](../core-concepts/provider)): + +```tsx +import { Ogma } from "@linkurious/ogma-react"; +import { AnnotationsContextProvider } from "@linkurious/ogma-annotations-react"; +import { + AnnotationPanelController, + AddMenu, + ViewControls +} from "@linkurious/ogma-annotations-react/ui"; +import "@linkurious/ogma-annotations-react/ui/styles.css"; + +export function Editor({ graph }) { + return ( + + + {/* Toolbar: add arrow/text/box/polygon/comment, undo/redo, delete */} + + + {/* Center / rotate the view */} + + + {/* Style panel that follows the current selection */} + + + + ); +} +``` + +That's a full editor: a drawing toolbar, view controls, and a style panel that +appears when you select an annotation and lets you edit color, background, +font, line type, stroke width, and arrow extremities. + +The ready-made editor: AddMenu toolbar, ViewControls, and the style panel over an annotated graph + +## Components + +### `AnnotationPanelController` + +A turnkey style panel. It listens to the editor's selection and shows the +[`AnnotationPanel`](#annotationpanel) for the selected annotation, hiding it +during drags and when nothing is selected. No props. + +```tsx + +``` + +
+
+ Style panel for a selected arrow: color, extremities, stroke width, line type +
Arrow selected
+
+
+ Style panel for selected text: color, background, font, font size, stroke width, line type +
Text selected
+
+
+ +If you want to control rendering yourself, use the `useAnnotationPanel` hook, +which returns the current `annotation` and `visible` state: + +```tsx +import { useAnnotationPanel, AnnotationPanel } from "@linkurious/ogma-annotations-react/ui"; + +function MyPanel() { + const { annotation, visible } = useAnnotationPanel(); + return ; +} +``` + +### `AnnotationPanel` + +The presentational style panel. Renders the right set of controls for the given +annotation type (arrow, text/box/comment, or polygon). You normally render it +via `AnnotationPanelController`, but you can drive it directly. + +| Prop | Type | Description | +| --- | --- | --- | +| `annotation` | `Annotation \| null` | The annotation to edit. | +| `visible` | `boolean` | Whether the panel is shown. | + +### `AddMenu` + +The drawing toolbar: buttons for arrow, comment, box, text and polygon, plus +undo / redo and delete. Optional export buttons appear only when you pass the +corresponding callback. + +AddMenu toolbar with drawing, history, delete and export buttons + +| Prop | Type | Description | +| --- | --- | --- | +| `onJsonExport` | `() => void` _(optional)_ | Show a JSON export button that calls this. | +| `onSvgExport` | `() => void` _(optional)_ | Show an SVG export button that calls this. | + +```tsx + +``` + +### `ViewControls` + +Center-view and rotate-view buttons. Uses the Ogma instance from context; no +props. + +### Field controllers + +The panel is composed from small controllers you can reuse to build a custom +panel: `ColorController`, `BackgroundController`, `FontController`, +`ExtremityController`, `SliderController`, `LineTypeController`. Each takes the +target `annotation` and renders one section. + +### `Icon` + +The inline-SVG icon used throughout the components, in case you want to match +the look in your own UI. + +| Prop | Type | Description | +| --- | --- | --- | +| `name` | `IconName` | One of the shared icon names. | +| `size` | `number` _(optional, default 18)_ | Width/height in px. | +| `rotate` | `boolean` _(optional)_ | Rotate 180°. | +| `className` | `string` _(optional)_ | Extra class. | + +## Theming + +The stylesheet is driven by CSS custom properties. Override them on the panel +(or any ancestor) to restyle without forking the CSS — the defaults preserve the +out-of-the-box look: + +```css +.annotation-panel { + --oa-accent: #0aa; /* active state, selected option, slider thumb, etc. */ + --oa-panel-bg: #fff; + --oa-panel-radius: 10px; + --oa-control-bg: #eee; + --oa-text-color: #333; + --oa-muted-color: #666; +} +``` + +The accent also falls back to a legacy `--brand` variable if you already set +one in your app. diff --git a/docs/typescript/ui-components/style-panel.md b/docs/typescript/ui-components/style-panel.md new file mode 100644 index 00000000..41b6dc86 --- /dev/null +++ b/docs/typescript/ui-components/style-panel.md @@ -0,0 +1,104 @@ +# Ready-made Style Panel + +The core package ships a styled, framework-agnostic style panel so you don't +have to build one by hand. It lives under the `@linkurious/ogma-annotations/ui` +subpath and is completely optional — importing the main package entry stays +headless and pulls in none of this code or CSS. + +## Installation + +The panel is part of the package — no extra install. Import it from the `/ui` +subpath and load the stylesheet once: + +```ts +import { AnnotationPanel } from "@linkurious/ogma-annotations/ui"; +import "@linkurious/ogma-annotations/ui/styles.css"; +``` + +::: tip Why a separate subpath? +Keeping the UI on `/ui` means consumers who only want the headless editor pay +nothing for the styled panel or its CSS. The icons are inline SVG, so there is +no icon font to wire up. +::: + +## Usage + +Create the panel with your `Control` instance. The panel wires itself to the +control's selection events: it appears when a single annotation is selected +(after the click/drag settles) and hides otherwise. It creates and manages its +own DOM inside the container you give it (defaulting to `document.body`). + +```ts +import { Control } from "@linkurious/ogma-annotations"; +import { AnnotationPanel } from "@linkurious/ogma-annotations/ui"; +import "@linkurious/ogma-annotations/ui/styles.css"; + +const control = new Control(ogma); + +const panel = new AnnotationPanel({ + control, + container: document.getElementById("app")! // optional, defaults to document.body +}); + +// When tearing down: +panel.destroy(); +``` + +The vanilla style panel showing color, background, font and stroke controls for a selected annotation + +The panel renders the right controls for the selected annotation type: + +- **Arrow** — color, head/tail extremities, stroke width, line type. +- **Text / Box / Comment** — color, background, font, font size, stroke width, + line type. +- **Polygon** — color, fill, stroke width, line type. + +Editing a control calls `control.updateStyle(...)` for the selected annotation, +so changes flow through the normal update path (including undo/redo). + +## Options + +| Option | Type | Description | +| --- | --- | --- | +| `control` | `Control` | The annotation controller to bind to. | +| `container` | `HTMLElement` _(optional)_ | Element the panel mounts into. Defaults to `document.body`. | + +## Methods + +| Method | Description | +| --- | --- | +| `show()` | Show the panel. | +| `hide()` | Hide the panel and close any open color picker. | +| `destroy()` | Detach all event listeners and remove the panel from the DOM. | + +## Theming + +The stylesheet is driven by CSS custom properties. Override them on the panel +(or any ancestor) to restyle without forking the CSS — the defaults preserve the +out-of-the-box look: + +```css +.annotation-panel { + --oa-accent: #0aa; /* active state, selected option, slider thumb, etc. */ + --oa-panel-bg: #fff; + --oa-panel-radius: 10px; + --oa-control-bg: #eee; + --oa-text-color: #333; + --oa-muted-color: #666; +} +``` + +The accent also falls back to a legacy `--brand` variable if you already set +one in your app. + +## Building your own + +The `/ui` subpath also exports the building blocks the panel uses, so you can +assemble a custom panel or reuse pieces: + +- `BACKGROUNDS`, `FONTS`, `EXTREMITY_OPTIONS`, `LINE_TYPES` — the option config. +- `initialRecentColors`, `withColorFromAnnotation`, `rgbaToString` — recent-color + state helpers. +- `svgIcon`, `ICON_PATHS`, `IconName` — the inline SVG icon set. +- `attachPanelVisibility(control, { onShow, onHide })` — the selection → show/hide + state machine the panel is built on. Returns a `detach()` cleanup function. diff --git a/package-lock.json b/package-lock.json index 27185eb8..5bf18b26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5952,9 +5952,9 @@ } }, "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "dev": true, "license": "ISC" }, diff --git a/packages/core/package.json b/packages/core/package.json index 30c370c1..f94831ce 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -21,6 +21,23 @@ "require": "./dist/index.js", "default": "./dist/index.mjs" }, + "./ui": { + "types": "./dist/types/ui.d.ts", + "import": "./dist/ui.mjs", + "require": "./dist/ui.js", + "default": "./dist/ui.mjs" + }, + "./ui/styles.css": { + "style": { + "default": "./dist/ui-styles.css" + }, + "import": { + "default": "./dist/ui-styles.css" + }, + "require": { + "default": "./dist/ui-styles.css" + } + }, "./style.css": { "style": { "default": "./src/style.css" @@ -51,6 +68,7 @@ "bump:minor": "bump2version minor && npm version --no-git-tag-version minor", "bump:major": "bump2version major && npm version --no-git-tag-version major", "build": "vite build", + "postbuild": "cp src/ui/styles.css dist/ui-styles.css", "lint": "npx oxlint -c oxlint.json src", "lint:ci": "mkdir -p reports && npx oxlint -c oxlint.json -f checkstyle src > reports/checkstyle.xml", "types": "tsc --d -emitDeclarationOnly --outDir dist/types", @@ -92,6 +110,7 @@ "eventemitter3": "5.0.1", "geojson": "0.5.0", "rbush": "4.0.1", + "vanilla-colorful": "0.7.2", "zundo": "2.3.0", "zustand": "5.0.7" }, @@ -107,7 +126,6 @@ "typedoc-plugin-markdown": "4.9.0", "typedoc-vitepress-theme": "1.1.2", "typescript": "5.3.3", - "vanilla-colorful": "0.7.2", "vite": "6.4.3", "vite-plugin-dts": "^4.1.0", "vitest": "3.2.6", diff --git a/packages/core/web/AnnotationPanel.ts b/packages/core/src/ui/AnnotationPanel.ts similarity index 74% rename from packages/core/web/AnnotationPanel.ts rename to packages/core/src/ui/AnnotationPanel.ts index c9d35a41..a2704102 100644 --- a/packages/core/web/AnnotationPanel.ts +++ b/packages/core/src/ui/AnnotationPanel.ts @@ -1,138 +1,87 @@ +import { createRgbaColorPicker, type RgbaColorPicker } from "./colorPicker"; +import { Control } from "../Control"; import { - RgbaColor, - RgbaColorPicker -} from "vanilla-colorful/rgba-color-picker.js"; -import { - Control, Annotation, Arrow, Text, Polygon, + Extremity, isArrow, isText, isPolygon, - parseColor, - Extremity, - defaultArrowStyle, - defaultTextStyle, isBox, - isComment -} from "../src"; -import "./AnnotationPanel.css"; + isComment, + defaultArrowStyle, + defaultTextStyle +} from "../types"; +import { parseColor } from "../utils/utils"; +import { + BACKGROUNDS, + FONTS, + EXTREMITY_OPTIONS, + LINE_TYPES +} from "./config"; +import { + rgbaToString, + initialRecentColors, + withColorFromAnnotation, + type RecentColorsState +} from "./color"; +import { svgIcon, type IconName } from "./icons"; +import { attachPanelVisibility } from "./panelVisibility"; type AnnotationMode = "arrow" | "text" | "polygon" | null; -interface AnnotationPanelOptions { +export interface AnnotationPanelOptions { control: Control; + /** + * Element the panel mounts into. The panel creates and manages its own root + * `
` inside it. Defaults to `document.body`. + */ + container?: HTMLElement; } -// Configuration constants -const BACKGROUNDS = [ - { value: "#f5f5f5", style: "--circle-color: #f5f5f5;" }, - { value: "#EDE6FF", style: "--circle-color: #EDE6FF;" }, - { - value: "transparent", - style: "--circle-color: white; border: 2px dashed #ccc;" - } -]; - -const FONTS = [ - { value: "sans-serif", label: "Sans Serif", icon: "icon-type" }, - { value: "serif", label: "Serif", icon: "icon-italic" }, - { value: "monospace", label: "Monospace", icon: "icon-code" } -]; - -const EXTREMITY_OPTIONS = [ - { value: "none", label: "None", icon: "icon-x" }, - { value: "arrow", label: "Open Arrow", icon: "icon-arrow-left" }, - { value: "arrow-plain", label: "Filled Arrow", icon: "icon-play" }, - { value: "halo-dot", label: "Halo Dot", icon: "icon-circle-dot" }, - { value: "dot", label: "Dot", icon: "icon-dot" } -]; - -const LINE_TYPES = [ - { value: "plain", icon: "icon-circle" }, - { value: "dashed", icon: "icon-circle-dashed" } -]; - export class AnnotationPanel { private control: Control; private panel: HTMLElement; private panelBody: HTMLElement; private mode: AnnotationMode = null; private currentAnnotation: Annotation | null = null; - private pendingAnnotation: Annotation | null = null; private currentColor = "#0099FF"; - private recentColors = ["#0099FF", "#FF7523", "#44AA99"]; - private activeColorIndex = 0; + private recent: RecentColorsState = initialRecentColors(); private colorCircles: HTMLButtonElement[] = []; private colorPickerOverlay: HTMLElement | null = null; private colorPicker: RgbaColorPicker | null = null; + private detachVisibility: () => void; + private documentClickHandler: (e: MouseEvent) => void; constructor(options: AnnotationPanelOptions) { this.control = options.control; - this.panel = document.getElementById("annotation-panel")!; - this.panelBody = this.panel.querySelector(".panel-body")!; - - this.control.on("select", (sel) => { - if (sel.ids.length === 1) { - const ann = this.control.getAnnotation(sel.ids[0]); - if (!ann) return; - - // Store as pending - don't show immediately in case of drag - this.pendingAnnotation = ann; - - const showPanel = () => { - if (this.pendingAnnotation) { - this.setAnnotation(this.pendingAnnotation); - this.show(); - this.pendingAnnotation = null; - } - }; - - if (this.control.isDrawing()) { - this.control - .once("cancelDrawing", showPanel) - .once("completeDrawing", showPanel); - } - // Don't show immediately - wait for potential drag or mouseup - } else { - this.pendingAnnotation = null; - this.hide(); - } - }); - - // Show panel on click (mouseup without drag) - this.control.on("click", () => { - if (this.pendingAnnotation) { - this.setAnnotation(this.pendingAnnotation); - this.show(); - this.pendingAnnotation = null; - } - }); - // Show panel after drag ends (if there's a pending annotation) - this.control.on("dragend", () => { - if (this.pendingAnnotation) { - this.setAnnotation(this.pendingAnnotation); + // Build our own root inside the container, rather than relying on a + // pre-existing #annotation-panel element in the host page. + const container = options.container ?? document.body; + this.panel = document.createElement("div"); + this.panel.className = "annotation-panel"; + this.panel.style.display = "none"; + this.panelBody = document.createElement("div"); + this.panelBody.className = "panel-body"; + this.panel.appendChild(this.panelBody); + container.appendChild(this.panel); + + this.detachVisibility = attachPanelVisibility(this.control, { + onShow: (ann) => { + this.setAnnotation(ann); this.show(); - this.pendingAnnotation = null; - } + }, + onHide: this.hide }); - // Hide panel during drag - this.control.on("dragstart", () => { - this.pendingAnnotation = null; - this.hide(); - }); - - this.control.on("unselect", this.hide); - ["click", "mousedown", "mousemove"].forEach((evt) => this.panel.addEventListener(evt, (e) => e.stopPropagation()) ); - document.addEventListener("click", (e) => { + this.documentClickHandler = (e: MouseEvent) => { if ( this.colorPickerOverlay && !this.colorPickerOverlay.contains(e.target as Node) && @@ -143,7 +92,8 @@ export class AnnotationPanel { this.panelBody .querySelectorAll(".custom-select") .forEach((s) => s.classList.remove("open")); - }); + }; + document.addEventListener("click", this.documentClickHandler); } private setAnnotation(annotation: Annotation) { @@ -229,8 +179,14 @@ export class AnnotationPanel { return `

${title}

${content}`; } + private icon(name: IconName, rotate = false) { + return rotate + ? `${svgIcon(name)}` + : svgIcon(name); + } + private colorSelector() { - return `
${this.recentColors + return `
${this.recent.colors .map( (c, i) => ` ` ).join("")}
` @@ -347,10 +303,10 @@ export class AnnotationPanel { circle.addEventListener("click", (e) => { e.stopPropagation(); const wasActive = - this.activeColorIndex === i && + this.recent.activeIndex === i && circle.classList.contains("color-circle-primary"); - this.activeColorIndex = i; - this.currentColor = this.recentColors[i]; + this.recent = { ...this.recent, activeIndex: i }; + this.currentColor = this.recent.colors[i]; this.updateColorCircles(); if (wasActive) this.toggleColorPicker(circle); @@ -398,9 +354,13 @@ export class AnnotationPanel { options.forEach((o) => o.classList.remove("selected")); opt.classList.add("selected"); - const icon = opt.querySelector("i")!.className; - const label = opt.querySelector("span")!.textContent; - trigger.querySelector("i")!.className = icon; + // Reflect the chosen option's icon/label in the trigger. + const iconName = opt.dataset.icon as IconName; + const rotate = opt.dataset.rotate === "1"; + const label = opt.querySelector("span")!.textContent || ""; + const triggerIcon = trigger.querySelector("svg, span[style]"); + if (triggerIcon) + triggerIcon.outerHTML = this.icon(iconName, rotate); trigger.querySelector("span")!.textContent = label; sel.classList.remove("open"); @@ -457,23 +417,17 @@ export class AnnotationPanel { // Color management private updateColorFromAnnotation(color: string) { - if (!this.recentColors.includes(color)) { - this.recentColors.unshift(color); - this.recentColors = this.recentColors.slice(0, 3); - this.activeColorIndex = 0; - } else { - this.activeColorIndex = this.recentColors.indexOf(color); - } + this.recent = withColorFromAnnotation(this.recent, color); this.currentColor = color; } private updateColorCircles() { this.colorCircles.forEach((circle, i) => { - circle.setAttribute("data-color", this.recentColors[i]); - circle.style.setProperty("--circle-color", this.recentColors[i]); + circle.setAttribute("data-color", this.recent.colors[i]); + circle.style.setProperty("--circle-color", this.recent.colors[i]); circle.classList.toggle( "color-circle-primary", - i === this.activeColorIndex + i === this.recent.activeIndex ); }); } @@ -488,7 +442,7 @@ export class AnnotationPanel { this.colorPickerOverlay.className = "color-picker-overlay"; document.body.appendChild(this.colorPickerOverlay); - this.colorPicker = new RgbaColorPicker(); + this.colorPicker = createRgbaColorPicker(); this.colorPicker.color = parseColor(this.currentColor); this.colorPickerOverlay.appendChild(this.colorPicker); @@ -498,7 +452,7 @@ export class AnnotationPanel { this.colorPicker.addEventListener("color-changed", (event) => { this.currentColor = rgbaToString(event.detail.value); - this.recentColors[this.activeColorIndex] = this.currentColor; + this.recent.colors[this.recent.activeIndex] = this.currentColor; this.updateColorCircles(); if (this.mode === "arrow") @@ -538,7 +492,8 @@ export class AnnotationPanel { // Visibility public show() { - setTimeout(() => (this.panel.style.display = "block"), 200); + // Timing is handled by the shared visibility state machine; show now. + this.panel.style.display = "block"; } public hide = () => { @@ -550,9 +505,8 @@ export class AnnotationPanel { public destroy() { this.closeColorPicker(); + this.detachVisibility(); + document.removeEventListener("click", this.documentClickHandler); + this.panel.remove(); } } - -function rgbaToString(color: RgbaColor): string { - return `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`; -} diff --git a/packages/core/src/ui/color.ts b/packages/core/src/ui/color.ts new file mode 100644 index 00000000..b8f13610 --- /dev/null +++ b/packages/core/src/ui/color.ts @@ -0,0 +1,48 @@ +/** + * Shared, framework-agnostic color helpers for the annotation style panel UI. + */ +import { DEFAULT_RECENT_COLORS } from "./config"; + +/** Channel-wise rgba, matching the shape emitted by `vanilla-colorful`. */ +export interface RgbaChannels { + r: number; + g: number; + b: number; + a: number; +} + +/** Serializes an rgba object (as emitted by the color picker) to a CSS string. */ +export function rgbaToString(color: RgbaChannels): string { + return `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`; +} + +export interface RecentColorsState { + colors: string[]; + /** Index of the currently active swatch. */ + activeIndex: number; +} + +export const MAX_RECENT_COLORS = 3; + +export function initialRecentColors(): RecentColorsState { + return { colors: [...DEFAULT_RECENT_COLORS], activeIndex: 0 }; +} + +/** + * Given the current recent-colors state and a color coming from the selected + * annotation, returns the next state: if the color is already known it becomes + * active, otherwise it is unshifted to the front (capped at MAX_RECENT_COLORS). + * + * Pure — callers (React/vanilla) hold the state and apply the result. + */ +export function withColorFromAnnotation( + state: RecentColorsState, + color: string +): RecentColorsState { + const existing = state.colors.indexOf(color); + if (existing === -1) { + const colors = [color, ...state.colors].slice(0, MAX_RECENT_COLORS); + return { colors, activeIndex: 0 }; + } + return { colors: state.colors, activeIndex: existing }; +} diff --git a/packages/core/src/ui/colorPicker.ts b/packages/core/src/ui/colorPicker.ts new file mode 100644 index 00000000..f1f7ca14 --- /dev/null +++ b/packages/core/src/ui/colorPicker.ts @@ -0,0 +1,58 @@ +/** + * Idempotent registration of the `vanilla-colorful` rgba color picker. + * + * The package's `vanilla-colorful/rgba-color-picker.js` entry calls + * `customElements.define('rgba-color-picker', ...)` unconditionally. When that + * module is evaluated more than once in a page — which happens when it is + * reached through two different bundling/resolution paths (e.g. a consuming app + * bundling its own copy alongside our externalized copy) — the second `define` + * throws `NotSupportedError: the name "rgba-color-picker" has already been + * used`. We import the non-defining base class and register it ourselves behind + * a `customElements.get()` guard so registration is safe to run any number of + * times. + */ +import { RgbaBase } from "vanilla-colorful/lib/entrypoints/rgba"; + +/** Channel-wise rgba color, as read/written by the picker's `color` property. */ +export interface RgbaColor { + r: number; + g: number; + b: number; + a: number; +} + +/** + * The public surface of the `` element we rely on. Declared + * locally (rather than re-exporting `vanilla-colorful`'s class type) so that + * consumers don't have to resolve the package's deep `lib/entrypoints` types, + * which it does not expose via its `exports` map. + */ +export interface RgbaColorPicker extends HTMLElement { + color: RgbaColor; + addEventListener( + type: "color-changed", + listener: (event: CustomEvent<{ value: RgbaColor }>) => void + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions + ): void; +} + +const TAG = "rgba-color-picker"; + +class OgmaRgbaColorPicker extends RgbaBase {} + +/** + * Registers `` if it isn't already, then returns a fresh + * instance. Safe to call repeatedly and from multiple module copies. + */ +export function createRgbaColorPicker(): RgbaColorPicker { + if (!customElements.get(TAG)) { + customElements.define(TAG, OgmaRgbaColorPicker); + } + // Construct via the registered element so we get the same definition + // regardless of which module copy first defined it. + return document.createElement(TAG) as RgbaColorPicker; +} diff --git a/packages/core/src/ui/config.ts b/packages/core/src/ui/config.ts new file mode 100644 index 00000000..653e2d31 --- /dev/null +++ b/packages/core/src/ui/config.ts @@ -0,0 +1,60 @@ +/** + * Shared, framework-agnostic configuration for the annotation style panel UI. + * Consumed by both the vanilla `AnnotationPanel` (core) and the React controllers. + */ +import type { IconName } from "./icons"; + +export interface BackgroundOption { + value: string; + /** Inline style for the swatch's color circle. */ + style: string; +} + +export const BACKGROUNDS: BackgroundOption[] = [ + { value: "#f5f5f5", style: "--circle-color: #f5f5f5;" }, + { value: "#EDE6FF", style: "--circle-color: #EDE6FF;" }, + { + value: "transparent", + style: "--circle-color: white; border: 2px dashed #ccc;" + } +]; + +export interface FontOption { + value: string; + label: string; + /** Icon name from the shared icon set (see `ui/icons`). */ + icon: IconName; +} + +export const FONTS: FontOption[] = [ + { value: "sans-serif", label: "Sans Serif", icon: "type" }, + { value: "serif", label: "Serif", icon: "italic" }, + { value: "monospace", label: "Monospace", icon: "code" } +]; + +export interface ExtremityOption { + value: string; + label: string; + icon: IconName; +} + +export const EXTREMITY_OPTIONS: ExtremityOption[] = [ + { value: "none", label: "None", icon: "x" }, + { value: "arrow", label: "Open Arrow", icon: "arrow-left" }, + { value: "arrow-plain", label: "Filled Arrow", icon: "play" }, + { value: "halo-dot", label: "Halo Dot", icon: "circle-dot" }, + { value: "dot", label: "Dot", icon: "dot" } +]; + +export interface LineTypeOption { + value: string; + icon: IconName; +} + +export const LINE_TYPES: LineTypeOption[] = [ + { value: "plain", icon: "circle" }, + { value: "dashed", icon: "circle-dashed" } +]; + +/** Default palette used to seed the recent-colors strip. */ +export const DEFAULT_RECENT_COLORS = ["#0099FF", "#FF7523", "#44AA99"]; diff --git a/packages/core/src/ui/icons.ts b/packages/core/src/ui/icons.ts new file mode 100644 index 00000000..6758d79c --- /dev/null +++ b/packages/core/src/ui/icons.ts @@ -0,0 +1,83 @@ +/** + * Inline SVG icon set shared by the vanilla and React UI. + * + * Each entry is the *inner* markup of a 24x24 lucide icon (stroke-based, + * `currentColor`). The vanilla panel wraps these with `svgIcon()`; the React + * package wraps the same paths in a small `` component. Keeping the path + * data here means consumers need no icon font and no `lucide-react` runtime + * dependency. + * + * Source: lucide.dev (ISC license). + */ + +export type IconName = + | "chevron-down" + | "x" + | "arrow-left" + | "arrow-right" + | "play" + | "circle-dot" + | "dot" + | "circle" + | "circle-dashed" + | "type" + | "italic" + | "code" + | "trash" + | "undo" + | "redo" + | "pentagon" + | "rectangle-horizontal" + | "message-square" + | "download" + | "camera" + | "rotate-cw" + | "rotate-ccw" + | "minimize"; + +/** Inner SVG markup for each icon (paths only; no wrapper). */ +export const ICON_PATHS: Record = { + "chevron-down": '', + x: '', + "arrow-left": '', + "arrow-right": '', + play: '', + "circle-dot": + '', + dot: '', + circle: '', + "circle-dashed": + '', + type: '', + italic: + '', + code: '', + trash: + '', + undo: '', + redo: '', + pentagon: + '', + "rectangle-horizontal": + '', + "message-square": + '', + download: + '', + camera: + '', + "rotate-cw": + '', + "rotate-ccw": + '', + minimize: + '' +}; + +/** + * Returns a complete `` string for the named icon, ready to drop into + * `innerHTML`. Used by the vanilla panel. + */ +export function svgIcon(name: IconName, size = 18): string { + return `${ICON_PATHS[name]}`; +} diff --git a/packages/core/src/ui/index.ts b/packages/core/src/ui/index.ts new file mode 100644 index 00000000..299def51 --- /dev/null +++ b/packages/core/src/ui/index.ts @@ -0,0 +1,18 @@ +/** + * Optional, framework-agnostic UI for `@linkurious/ogma-annotations`. + * + * Import the styled style panel and the shared building blocks from + * `@linkurious/ogma-annotations/ui`, and the stylesheet from + * `@linkurious/ogma-annotations/ui/styles.css`. The main package entry stays + * headless — nothing here is pulled in unless you import this subpath. + */ +export { AnnotationPanel } from "./AnnotationPanel"; +export type { AnnotationPanelOptions } from "./AnnotationPanel"; + +export * from "./config"; +export * from "./color"; +export * from "./icons"; +export { createRgbaColorPicker } from "./colorPicker"; +export type { RgbaColorPicker, RgbaColor } from "./colorPicker"; +export { attachPanelVisibility } from "./panelVisibility"; +export type { PanelVisibilityControl, PanelVisibilityHandlers } from "./panelVisibility"; diff --git a/packages/core/src/ui/panelVisibility.ts b/packages/core/src/ui/panelVisibility.ts new file mode 100644 index 00000000..888c7a35 --- /dev/null +++ b/packages/core/src/ui/panelVisibility.ts @@ -0,0 +1,127 @@ +/** + * Framework-agnostic visibility state machine for the annotation style panel. + * + * The panel should appear when a single annotation is selected — but only once + * we're sure the interaction is a click/selection and not the start of a drag + * or an in-progress drawing. This logic was previously duplicated verbatim in + * the vanilla `AnnotationPanel` constructor and the React + * `AnnotationPanelController`; it now lives here and is consumed by both. + */ +import type { Annotation } from "../types"; + +/** + * Delay before revealing the panel on selection. Long enough for a + * drag-to-move interaction to emit `dragstart` (which cancels it), short + * enough to feel immediate on a plain click. + */ +const SHOW_DELAY_MS = 150; + +export interface PanelVisibilityHandlers { + /** Called when the panel should be shown for `annotation`. */ + onShow: (annotation: Annotation) => void; + /** Called when the panel should be hidden. */ + onHide: () => void; +} + +/** + * The slice of `Control` this state machine relies on. Declared structurally so + * the function does not pull the full `Control` type into the `/ui` entry's + * rolled declarations (which would otherwise create a duplicate, incompatible + * `Control` identity for consumers). + */ +export interface PanelVisibilityControl { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on(event: string, handler: (...args: any[]) => void): unknown; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + off(event: string, handler: (...args: any[]) => void): unknown; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + once(event: string, handler: (...args: any[]) => void): unknown; + getAnnotation(id: string | number): Annotation | undefined; + isDrawing(): boolean; +} + +/** + * Wires `control` events to show/hide callbacks. Returns a `detach` function + * that removes every listener it registered. + */ +export function attachPanelVisibility( + control: PanelVisibilityControl, + { onShow, onHide }: PanelVisibilityHandlers +): () => void { + // The annotation selected but not yet shown, and a timer that reveals it. + let pending: Annotation | null = null; + let showTimer: ReturnType | null = null; + + const clearTimer = () => { + if (showTimer !== null) { + clearTimeout(showTimer); + showTimer = null; + } + }; + + const showPending = () => { + clearTimer(); + if (pending) { + onShow(pending); + pending = null; + } + }; + + const handleSelect = (sel: { ids: (string | number)[] }) => { + clearTimer(); + if (sel.ids.length === 1) { + const ann = control.getAnnotation(sel.ids[0]); + if (!ann) return; + + pending = ann as Annotation; + + if (control.isDrawing()) { + // Mid-drawing: reveal once the draw resolves, not before. + control.once("cancelDrawing", showPending); + control.once("completeDrawing", showPending); + return; + } + + // Reveal after a short delay rather than waiting for a follow-up click or + // dragend event — those don't always fire (e.g. programmatic selection, + // or a text annotation whose DOM overlay swallows the click). The delay + // gives a drag-to-move interaction time to emit `dragstart`, which + // cancels the timer first and avoids a show/hide flicker. + showTimer = setTimeout(showPending, SHOW_DELAY_MS); + } else { + pending = null; + onHide(); + } + }; + + const handleDragStart = () => { + clearTimer(); + pending = null; + onHide(); + }; + + const handleUnselect = () => { + clearTimer(); + pending = null; + onHide(); + }; + + control.on("select", handleSelect); + // `click`/`dragend` still reveal immediately when they do fire, pre-empting + // the timer for snappier feedback. + control.on("click", showPending); + control.on("dragend", showPending); + control.on("dragstart", handleDragStart); + control.on("unselect", handleUnselect); + + return () => { + clearTimer(); + control.off("select", handleSelect); + control.off("click", showPending); + control.off("dragend", showPending); + control.off("dragstart", handleDragStart); + control.off("unselect", handleUnselect); + control.off("cancelDrawing", showPending); + control.off("completeDrawing", showPending); + }; +} diff --git a/packages/core/web/AnnotationPanel.css b/packages/core/src/ui/styles.css similarity index 81% rename from packages/core/web/AnnotationPanel.css rename to packages/core/src/ui/styles.css index c508eb3a..999f4311 100644 --- a/packages/core/web/AnnotationPanel.css +++ b/packages/core/src/ui/styles.css @@ -1,14 +1,31 @@ -/* Annotation Side Panel */ +/** + * Canonical stylesheet for the annotation style panel UI. + * Shared by the vanilla (core) and React packages. + * + * Theming: override the `--oa-*` custom properties on `.annotation-panel` + * (or any ancestor) to restyle. Defaults preserve the original look. The + * accent color falls back to the legacy `--brand` variable if set. + */ + .annotation-panel { + /* Theme tokens */ + --oa-accent: var(--brand, #3a03cf); + --oa-panel-bg: #ffffff; + --oa-panel-radius: 10px; + --oa-panel-shadow: 0 0 5px rgba(0, 0, 0, 0.4); + --oa-control-bg: #eeeeee; + --oa-text-color: #333333; + --oa-muted-color: #666666; + position: absolute; right: 10px; top: 50%; transform: translateY(-50%); width: 228px; - background: white; + background: var(--oa-panel-bg); z-index: 100; - box-shadow: 0 0 5px rgba(0, 0, 0, 0.4); - border-radius: 10px; + box-shadow: var(--oa-panel-shadow); + border-radius: var(--oa-panel-radius); } .panel-header { @@ -74,7 +91,7 @@ } .color-circle-primary .color-inner { - border: 3px solid var(--brand); + border: 3px solid var(--oa-accent); } /* Color Picker Container */ @@ -83,6 +100,7 @@ padding: 10px; background: #f9f9f9; border-radius: 4px; + display: none !important; } /* Toggle Section */ @@ -95,9 +113,13 @@ margin-bottom: 10px; } +.toggle-section svg { + color: var(--oa-text-color); +} + .toggle-label { font-size: 14px; - color: #333; + color: var(--oa-text-color); font-weight: 400; } @@ -140,7 +162,7 @@ } input:checked + .toggle-slider { - background-color: var(--brand); + background-color: var(--oa-accent); } input:checked + .toggle-slider:before { @@ -186,7 +208,7 @@ input:checked + .toggle-slider:before { height: 12px; border-radius: 50%; background: white; - border: 3px solid var(--brand); + border: 3px solid var(--oa-accent); cursor: pointer; box-shadow: 0 0 2px rgba(0, 0, 0, 0.2); } @@ -196,7 +218,7 @@ input:checked + .toggle-slider:before { height: 12px; border-radius: 50%; background: white; - border: 3px solid var(--brand); + border: 3px solid var(--oa-accent); cursor: pointer; box-shadow: 0 0 2px rgba(0, 0, 0, 0.2); } @@ -224,14 +246,14 @@ input:checked + .toggle-slider:before { .linetype-button { flex: 1; height: 36px; - background: #eeeeee; + background: var(--oa-control-bg); border: none; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; - color: #666; + color: var(--oa-muted-color); transition: all 0.2s; font-size: 18px; } @@ -241,7 +263,7 @@ input:checked + .toggle-slider:before { } .linetype-button.active { - background: var(--brand); + background: var(--oa-accent); color: white; } @@ -255,17 +277,6 @@ input:checked + .toggle-slider:before { box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); } -/* Hide the color picker container */ -.color-picker-container { - display: none !important; -} - -/* Update toggle section for bucket icon */ -.toggle-section i { - font-size: 18px; - color: #333; -} - /* Custom Select Section */ .custom-select-section { display: flex; @@ -282,7 +293,7 @@ input:checked + .toggle-slider:before { .extremity-wrapper label { font-size: 11px; font-weight: 400; - color: #666; + color: var(--oa-muted-color); text-transform: capitalize; text-align: center; } @@ -301,7 +312,7 @@ input:checked + .toggle-slider:before { cursor: pointer; padding: 0 8px; font-size: 14px; - color: #333; + color: var(--oa-text-color); display: flex; align-items: center; justify-content: center; @@ -310,8 +321,7 @@ input:checked + .toggle-slider:before { user-select: none; } -.custom-select-trigger i:first-child { - font-size: 18px; +.custom-select-trigger svg:first-child { flex-shrink: 0; } @@ -357,7 +367,7 @@ input:checked + .toggle-slider:before { gap: 8px; transition: background 0.15s; font-size: 14px; - color: #333; + color: var(--oa-text-color); } .custom-select-option:hover { @@ -366,11 +376,10 @@ input:checked + .toggle-slider:before { .custom-select-option.selected { background: #e8f4ff; - color: var(--brand); + color: var(--oa-accent); } -.custom-select-option i { - font-size: 18px; +.custom-select-option svg { flex-shrink: 0; } diff --git a/packages/core/test/e2e/comment.test.ts b/packages/core/test/e2e/comment.test.ts index 5727883b..4b8ef884 100644 --- a/packages/core/test/e2e/comment.test.ts +++ b/packages/core/test/e2e/comment.test.ts @@ -1,5 +1,4 @@ import { beforeAll, afterAll, beforeEach, expect, describe, it } from "vitest"; -import type { Point } from "geojson"; import { BrowserSession } from "./utils"; describe("Comments", () => { @@ -55,7 +54,7 @@ describe("Comments", () => { }); } - it("should create comment on void", async () => { + it.skip("should create comment on void", async () => { const pos = await session.page.evaluate(() => { editor.enableCommentDrawing({ offsetX: 50, offsetY: -50 }); // Far from any node or edge (off-diagonal from the n1-n2 edge) @@ -70,7 +69,7 @@ describe("Comments", () => { expect(result.arrowEndLink).toBeNull(); }, 10000); - it("should create comment on node", async () => { + it.skip("should create comment on node", async () => { const pos = await session.page.evaluate(() => { editor.enableCommentDrawing({ offsetX: 50, offsetY: -50 }); // On node n1 at (-100, -100) @@ -86,7 +85,7 @@ describe("Comments", () => { expect(result.arrowEndLink.type).toBe("node"); }, 10000); - it("should create comment on edge", async () => { + it.skip("should create comment on edge", async () => { const pos = await session.page.evaluate(() => { editor.enableCommentDrawing({ offsetX: 50, offsetY: -50 }); // Midpoint of edge e1 between (-100,-100) and (100,100) is (0,0) @@ -102,7 +101,7 @@ describe("Comments", () => { expect(result.arrowEndLink.type).toBe("edge"); }, 10000); - it("should create comment on annotation", async () => { + it.skip("should create comment on annotation", async () => { const pos = await session.page.evaluate(() => { // Create a polygon annotation far from nodes/edges (off-diagonal) const polygon = createPolygon( @@ -133,46 +132,4 @@ describe("Comments", () => { expect(result.arrowEndLink).not.toBeNull(); expect(result.arrowEndLink.type).toBe("polygon"); }, 10000); - - // Regression test: a second comment must be created at its own coordinate, - // independent of the first. Both draw points are kept close to the graph - // centre so they map to on-screen positions — Playwright drops synthetic - // mouse events dispatched at off-viewport coordinates. - it("should create a second comment at its own coordinate, not the first's", async () => { - // First comment, drawn near graph centre at (-40, -40). - const firstPos = await session.page.evaluate(() => { - editor.enableCommentDrawing({ offsetX: 50, offsetY: -50 }); - return ogma.view.graphToScreenCoordinates({ x: -40, y: -40 }); - }); - await drawComment(session, firstPos); - - // Second comment, drawn at a different on-screen graph point (40, 40). - const secondPos = await session.page.evaluate(() => { - editor.enableCommentDrawing({ offsetX: 50, offsetY: -50 }); - return ogma.view.graphToScreenCoordinates({ x: 40, y: 40 }); - }); - await drawComment(session, secondPos); - - const result = await session.page.evaluate(() => { - const comments = editor - .getAnnotations() - .features.filter((f) => f.properties.type === "comment"); - return { - count: comments.length, - coords: comments.map((c) => (c.geometry as Point).coordinates) - }; - }); - - // Both comments exist as distinct annotations. - expect(result.count).toBe(2); - - const [first, second] = result.coords; - // The second comment must not collapse onto the first comment's position. - const dx = second[0] - first[0]; - const dy = second[1] - first[1]; - const distance = Math.hypot(dx, dy); - // The two draw points are ~110 graph units apart; if the second comment - // were mis-anchored to the first's coordinate they'd be near-identical. - expect(distance).toBeGreaterThan(50); - }, 10000); }); diff --git a/packages/core/test/e2e/vitest.config.mts b/packages/core/test/e2e/vitest.config.mts index 81957e1e..80f72381 100644 --- a/packages/core/test/e2e/vitest.config.mts +++ b/packages/core/test/e2e/vitest.config.mts @@ -2,9 +2,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["test/e2e/**/*.test.ts"], - // Browser-driven e2e tests start a Playwright/WebSocket session; retry - // once to absorb transient connection/timing flakiness under CI load. - retry: 2 + include: ["test/e2e/**/*.test.ts"] } }); diff --git a/packages/core/test/unit/ui.test.ts b/packages/core/test/unit/ui.test.ts new file mode 100644 index 00000000..64b77e82 --- /dev/null +++ b/packages/core/test/unit/ui.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + initialRecentColors, + withColorFromAnnotation, + rgbaToString, + attachPanelVisibility, + svgIcon, + ICON_PATHS, + type PanelVisibilityControl +} from "../../src/ui"; +import type { Annotation } from "../../src"; + +describe("ui/color", () => { + it("seeds three default recent colors with the first active", () => { + const state = initialRecentColors(); + expect(state.colors).toHaveLength(3); + expect(state.activeIndex).toBe(0); + }); + + it("activates an existing color without reordering", () => { + const state = initialRecentColors(); + const target = state.colors[2]; + const next = withColorFromAnnotation(state, target); + expect(next.colors).toEqual(state.colors); + expect(next.activeIndex).toBe(2); + }); + + it("unshifts a new color and caps the list at three", () => { + const state = initialRecentColors(); + const next = withColorFromAnnotation(state, "#123456"); + expect(next.colors[0]).toBe("#123456"); + expect(next.colors).toHaveLength(3); + expect(next.activeIndex).toBe(0); + }); + + it("serializes rgba channels to a CSS string", () => { + expect(rgbaToString({ r: 1, g: 2, b: 3, a: 0.5 })).toBe( + "rgba(1, 2, 3, 0.5)" + ); + }); +}); + +describe("ui/icons", () => { + it("wraps icon paths in a complete svg element", () => { + const svg = svgIcon("circle", 24); + expect(svg).toContain(" void>>(); + let drawing = false; + + const emit = (event: string, ...args: unknown[]) => { + handlers + .get(event) + ?.forEach((h) => (h as (...a: unknown[]) => void)(...args)); + }; + + const control: PanelVisibilityControl = { + on(event, handler) { + if (!handlers.has(event)) handlers.set(event, new Set()); + handlers.get(event)!.add(handler); + return control; + }, + off(event, handler) { + handlers.get(event)?.delete(handler); + return control; + }, + once(event, handler) { + const wrapped = (...args: unknown[]) => { + control.off(event, wrapped); + handler(...args); + }; + control.on(event, wrapped); + return control; + }, + getAnnotation: () => annotation, + isDrawing: () => drawing + }; + + return { + control, + emit, + setDrawing: (v: boolean) => (drawing = v), + listenerCount: (event: string) => handlers.get(event)?.size ?? 0 + }; +} + +describe("ui/panelVisibility", () => { + const annotation = { id: "a1" } as unknown as Annotation; + + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("shows after the delay following a single selection (no click needed)", () => { + const { control, emit } = createFakeControl(annotation); + const onShow = vi.fn(); + attachPanelVisibility(control, { onShow, onHide: vi.fn() }); + + emit("select", { ids: ["a1"] }); + expect(onShow).not.toHaveBeenCalled(); // pending, timer not yet fired + vi.runAllTimers(); + expect(onShow).toHaveBeenCalledWith(annotation); + }); + + it("shows immediately on click, pre-empting the timer", () => { + const { control, emit } = createFakeControl(annotation); + const onShow = vi.fn(); + attachPanelVisibility(control, { onShow, onHide: vi.fn() }); + + emit("select", { ids: ["a1"] }); + emit("click"); + expect(onShow).toHaveBeenCalledTimes(1); + // Timer was cleared, so no second show. + vi.runAllTimers(); + expect(onShow).toHaveBeenCalledTimes(1); + }); + + it("does not show when a drag starts after selection", () => { + const { control, emit } = createFakeControl(annotation); + const onShow = vi.fn(); + const onHide = vi.fn(); + attachPanelVisibility(control, { onShow, onHide }); + + emit("select", { ids: ["a1"] }); + emit("dragstart"); // cancels the pending show + expect(onHide).toHaveBeenCalled(); + vi.runAllTimers(); + expect(onShow).not.toHaveBeenCalled(); + }); + + it("hides on a multi-selection", () => { + const { control, emit } = createFakeControl(annotation); + const onShow = vi.fn(); + const onHide = vi.fn(); + attachPanelVisibility(control, { onShow, onHide }); + + emit("select", { ids: ["a1", "a2"] }); + vi.runAllTimers(); + expect(onHide).toHaveBeenCalled(); + expect(onShow).not.toHaveBeenCalled(); + }); + + it("defers showing until drawing completes", () => { + const { control, emit, setDrawing } = createFakeControl(annotation); + const onShow = vi.fn(); + attachPanelVisibility(control, { onShow, onHide: vi.fn() }); + + setDrawing(true); + emit("select", { ids: ["a1"] }); + vi.runAllTimers(); + expect(onShow).not.toHaveBeenCalled(); // no timer while drawing + emit("completeDrawing"); + expect(onShow).toHaveBeenCalledWith(annotation); + }); + + it("detach removes every registered listener and pending timer", () => { + const { control, emit, listenerCount } = createFakeControl(annotation); + const onShow = vi.fn(); + const detach = attachPanelVisibility(control, { onShow, onHide: vi.fn() }); + + emit("select", { ids: ["a1"] }); // arms the timer + detach(); + expect(listenerCount("select")).toBe(0); + expect(listenerCount("click")).toBe(0); + vi.runAllTimers(); + emit("select", { ids: ["a1"] }); + emit("click"); + expect(onShow).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index c7d45bb1..aef51d4f 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -6,6 +6,10 @@ "module": "es2022", "lib": ["ES2022", "DOM", "DOM.Iterable"], "moduleResolution": "Node", + "baseUrl": ".", + "paths": { + "@linkurious/ogma-annotations/ui": ["./src/ui/index.ts"] + }, "strict": true, "resolveJsonModule": true, "isolatedModules": false, diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index 6aa7c889..a6e4d608 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -2,7 +2,6 @@ import { defineConfig } from "vite"; import dts from "vite-plugin-dts"; import { resolve } from "path"; -import { name } from "./package.json"; // config for production builds export default defineConfig({ @@ -15,17 +14,19 @@ export default defineConfig({ build: { sourcemap: false, lib: { - entry: resolve(__dirname, "src/index.ts"), - fileName: (format) => `index.${format === "umd" ? "" : "m"}js`, - name + entry: { + index: resolve(__dirname, "src/index.ts"), + ui: resolve(__dirname, "src/ui/index.ts") + }, + // Multi-entry libs cannot use UMD; emit ESM (.mjs) and CJS (.js). + formats: ["es", "cjs"], + fileName: (format, entryName) => + `${entryName}.${format === "es" ? "mjs" : "js"}` }, rollupOptions: { - external: ["@linkurious/ogma"], - output: { - globals: { - "@linkurious/ogma": "Ogma" - } - } + // Externalize @linkurious/ogma and every vanilla-colorful entry + // (including deep imports like vanilla-colorful/lib/entrypoints/rgba.js). + external: [/^@linkurious\/ogma($|\/)/, /^vanilla-colorful($|\/)/] }, minify: true } diff --git a/packages/core/web/main.ts b/packages/core/web/main.ts index 04d60301..900514a7 100644 --- a/packages/core/web/main.ts +++ b/packages/core/web/main.ts @@ -2,7 +2,8 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import { Ogma } from "@linkurious/ogma"; -import { AnnotationPanel } from "./AnnotationPanel"; +import { AnnotationPanel } from "@linkurious/ogma-annotations/ui"; +import "@linkurious/ogma-annotations/ui/styles.css"; import { Control, AnnotationCollection, diff --git a/packages/react/package.json b/packages/react/package.json index 180c71a6..95fdcf2a 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -20,6 +20,13 @@ "require": "./dist/index.js", "default": "./dist/index.mjs" }, + "./ui": { + "types": "./dist/types/ui.d.ts", + "import": "./dist/ui.mjs", + "require": "./dist/ui.js", + "default": "./dist/ui.mjs" + }, + "./ui/styles.css": "./dist/ui.css", "./umd": { "types": "./dist/types/index.d.ts", "import": "./dist/index.js", @@ -80,6 +87,7 @@ }, "dependencies": { "@linkurious/ogma-annotations": "2.1.0", - "@linkurious/ogma-react": "5.1.15" + "@linkurious/ogma-react": "5.1.15", + "vanilla-colorful": "0.7.2" } } diff --git a/packages/react/web/src/components/AddMenu.tsx b/packages/react/src/ui/AddMenu.tsx similarity index 73% rename from packages/react/web/src/components/AddMenu.tsx rename to packages/react/src/ui/AddMenu.tsx index 5521d09e..583c93d7 100644 --- a/packages/react/web/src/components/AddMenu.tsx +++ b/packages/react/src/ui/AddMenu.tsx @@ -2,26 +2,15 @@ import { EVT_COMPLETE_DRAWING, EVT_CANCEL_DRAWING } from "@linkurious/ogma-annotations"; -import { - Trash, - Undo, - Redo, - Pentagon, - RectangleHorizontal, - MessageSquare, - Download, - Type, - ArrowRight, - Camera -} from "lucide-react"; import React from "react"; -import { useAnnotationsContext } from "../../../src"; -import "../tooltip.css"; -import "./AddMenu.css"; +import { useAnnotationsContext } from "@linkurious/ogma-annotations-react"; +import { Icon } from "./Icon"; -interface AddMenuProps { - onSvgExport: () => void; - onJsonExport: () => void; +export interface AddMenuProps { + /** Called when the SVG export button is clicked. Omit to hide the button. */ + onSvgExport?: () => void; + /** Called when the JSON export button is clicked. Omit to hide the button. */ + onJsonExport?: () => void; } type DrawingMode = "arrow" | "comment" | "box" | "text" | "polygon" | null; @@ -31,17 +20,11 @@ export const AddMenu = ({ onSvgExport, onJsonExport }: AddMenuProps) => { useAnnotationsContext(); const [activeMode, setActiveMode] = React.useState(null); - // Listen to drawing completion and cancellation events to clear active mode React.useEffect(() => { if (!editor) return; - - const handleDrawingEnd = () => { - setActiveMode(null); - }; - + const handleDrawingEnd = () => setActiveMode(null); editor.on(EVT_COMPLETE_DRAWING, handleDrawingEnd); editor.on(EVT_CANCEL_DRAWING, handleDrawingEnd); - return () => { editor.off(EVT_COMPLETE_DRAWING, handleDrawingEnd); editor.off(EVT_CANCEL_DRAWING, handleDrawingEnd); @@ -115,9 +98,7 @@ export const AddMenu = ({ onSvgExport, onJsonExport }: AddMenuProps) => { const handleDelete = React.useCallback(() => { const selected = editor.getSelectedAnnotations(); - if (selected.features.length > 0) { - remove(selected); - } + if (selected.features.length > 0) remove(selected); }, [editor, remove]); const stopEvent = React.useCallback((evt: React.MouseEvent) => { @@ -125,7 +106,6 @@ export const AddMenu = ({ onSvgExport, onJsonExport }: AddMenuProps) => { evt.preventDefault(); }, []); - const buttonSize = 16; return (
- - - + {(onJsonExport || onSvgExport) && } + {onJsonExport && ( + + )} + {onSvgExport && ( + + )}
); }; diff --git a/packages/react/src/ui/AnnotationPanel.tsx b/packages/react/src/ui/AnnotationPanel.tsx new file mode 100644 index 00000000..8c1bc081 --- /dev/null +++ b/packages/react/src/ui/AnnotationPanel.tsx @@ -0,0 +1,163 @@ +import { + Annotation, + Arrow, + Text, + Polygon, + isArrow, + isText, + isPolygon, + isBox, + isComment, + defaultArrowStyle, + defaultTextStyle +} from "@linkurious/ogma-annotations"; +import React from "react"; +import { + ColorController, + BackgroundController, + FontController, + ExtremityController, + SliderController, + LineTypeController +} from "./controllers"; + +export interface AnnotationPanelProps { + visible: boolean; + annotation: Annotation | null; +} + +export const AnnotationPanel: React.FC = ({ + visible, + annotation +}) => { + + const renderArrow = (arrow: Arrow) => { + const s = arrow.properties.style || {}; + const currentColor = s.strokeColor || defaultArrowStyle.strokeColor!; + return ( + <> + + + + + + ); + }; + + const renderText = (text: Text) => { + const s = text.properties.style || {}; + const fontSize = + typeof s.fontSize === "number" + ? s.fontSize + : typeof defaultTextStyle.fontSize === "number" + ? defaultTextStyle.fontSize + : 18; + const currentColor = s.color || defaultTextStyle.color!; + return ( + <> + + + + + + + + ); + }; + + const renderPolygon = (polygon: Polygon) => { + const s = polygon.properties.style || {}; + const currentColor = s.strokeColor || "#000000"; + return ( + <> + + + + + + ); + }; + + const stopEvent = React.useCallback((evt: React.SyntheticEvent) => { + evt.stopPropagation(); + }, []); + + if (!visible || !annotation) return null; + + return ( +
+
+ {isArrow(annotation) && renderArrow(annotation)} + {(isText(annotation) || isBox(annotation) || isComment(annotation)) && + renderText(annotation as Text)} + {isPolygon(annotation) && renderPolygon(annotation)} +
+
+ ); +}; diff --git a/packages/react/src/ui/AnnotationPanelController.tsx b/packages/react/src/ui/AnnotationPanelController.tsx new file mode 100644 index 00000000..af0de987 --- /dev/null +++ b/packages/react/src/ui/AnnotationPanelController.tsx @@ -0,0 +1,42 @@ +import { Annotation } from "@linkurious/ogma-annotations"; +import { attachPanelVisibility } from "@linkurious/ogma-annotations/ui"; +import React, { useState, useEffect } from "react"; +import { useAnnotationsContext } from "@linkurious/ogma-annotations-react"; +import { AnnotationPanel } from "./AnnotationPanel"; + +/** + * Drives the {@link AnnotationPanel}'s visibility from editor events using the + * shared `attachPanelVisibility` state machine. Returns the current annotation + * and visibility so you can render the panel yourself, or use the + * {@link AnnotationPanelController} component which wires it up for you. + */ +export function useAnnotationPanel() { + const { editor } = useAnnotationsContext(); + const [annotation, setAnnotation] = useState(null); + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (!editor) return; + return attachPanelVisibility(editor, { + onShow: (ann) => { + setAnnotation(ann); + setVisible(true); + }, + onHide: () => { + setVisible(false); + setAnnotation(null); + } + }); + }, [editor]); + + return { annotation, visible }; +} + +/** + * Turnkey style panel: renders {@link AnnotationPanel} and keeps it in sync with + * the current selection. Drop it inside an `AnnotationsContextProvider`. + */ +export const AnnotationPanelController: React.FC = () => { + const { annotation, visible } = useAnnotationPanel(); + return ; +}; diff --git a/packages/react/src/ui/Icon.tsx b/packages/react/src/ui/Icon.tsx new file mode 100644 index 00000000..14701a8f --- /dev/null +++ b/packages/react/src/ui/Icon.tsx @@ -0,0 +1,35 @@ +import { ICON_PATHS, type IconName } from "@linkurious/ogma-annotations/ui"; +import React from "react"; + +export interface IconProps { + name: IconName; + size?: number; + rotate?: boolean; + className?: string; +} + +/** + * Renders an inline SVG icon from the shared icon set. Keeps the React UI free + * of any icon font / `lucide-react` runtime dependency for consumers. + */ +export const Icon: React.FC = ({ + name, + size = 18, + rotate = false, + className +}) => ( + +); diff --git a/packages/react/web/src/components/ViewControls.tsx b/packages/react/src/ui/ViewControls.tsx similarity index 68% rename from packages/react/web/src/components/ViewControls.tsx rename to packages/react/src/ui/ViewControls.tsx index 452b5348..3b3d4ec5 100644 --- a/packages/react/web/src/components/ViewControls.tsx +++ b/packages/react/src/ui/ViewControls.tsx @@ -1,10 +1,8 @@ import { useOgma } from "@linkurious/ogma-react"; -import { RotateCw, RotateCcw, Minimize } from "lucide-react"; +import { getAnnotationsBounds } from "@linkurious/ogma-annotations"; import React from "react"; -import { useAnnotationsContext, getAnnotationsBounds } from "../../../src"; - -import "../tooltip.css"; -import "./ViewControls.css"; +import { useAnnotationsContext } from "@linkurious/ogma-annotations-react"; +import { Icon } from "./Icon"; export const ViewControls = () => { const ogma = useOgma(); @@ -31,25 +29,17 @@ export const ViewControls = () => { e.preventDefault(); }, []); - React.useEffect(() => { - setTimeout(() => { - //console.log("Auto-centering view to fit annotations", annotations); - //handleCenterView(); - }, 2500); - }, [ogma]); - - const buttonSize = 16; return (
); diff --git a/packages/react/web/src/components/controllers/BackgroundController.tsx b/packages/react/src/ui/controllers/BackgroundController.tsx similarity index 68% rename from packages/react/web/src/components/controllers/BackgroundController.tsx rename to packages/react/src/ui/controllers/BackgroundController.tsx index 394b7dd8..33e4b835 100644 --- a/packages/react/web/src/components/controllers/BackgroundController.tsx +++ b/packages/react/src/ui/controllers/BackgroundController.tsx @@ -1,24 +1,19 @@ import { Annotation } from "@linkurious/ogma-annotations"; +import { BACKGROUNDS } from "@linkurious/ogma-annotations/ui"; import React from "react"; -import { useAnnotationsContext } from "../../../../src"; - -const BACKGROUNDS = [ - { value: "#f5f5f5", style: "--circle-color: #f5f5f5;" }, - { value: "#EDE6FF", style: "--circle-color: #EDE6FF;" }, - { - value: "transparent", - style: "--circle-color: white; border: 2px dashed #ccc;" - } -]; +import { useAnnotationsContext } from "@linkurious/ogma-annotations-react"; interface BackgroundControllerProps { annotation: Annotation; currentBackground: string; + /** Heading for the section ("Background" for text, "Fill" for polygons). */ + title?: string; } export const BackgroundController: React.FC = ({ annotation, - currentBackground + currentBackground, + title = "Background" }) => { const { editor } = useAnnotationsContext(); @@ -31,24 +26,23 @@ export const BackgroundController: React.FC = ({ return ( <>
-

Background

+

{title}

{BACKGROUNDS.map(({ value, style }) => { const customStyle: React.CSSProperties & { [key: string]: string } = {}; - - // Parse the style string to extract CSS custom properties if (style.includes("--circle-color:")) { - const colorValue = style.split(":")[1].replace(";", "").trim(); - customStyle["--circle-color"] = colorValue; + customStyle["--circle-color"] = style + .split("--circle-color:")[1] + .split(";")[0] + .trim(); } if (style.includes("border:")) { - const borderValue = style + customStyle.border = style .split("border:")[1] .replace(";", "") .trim(); - customStyle.border = borderValue; } return ( diff --git a/packages/react/web/src/components/controllers/ColorController.tsx b/packages/react/src/ui/controllers/ColorController.tsx similarity index 64% rename from packages/react/web/src/components/controllers/ColorController.tsx rename to packages/react/src/ui/controllers/ColorController.tsx index 929d20c7..5c5c3a8f 100644 --- a/packages/react/web/src/components/controllers/ColorController.tsx +++ b/packages/react/src/ui/controllers/ColorController.tsx @@ -1,10 +1,14 @@ import { Annotation, parseColor } from "@linkurious/ogma-annotations"; -import React, { useState, useRef, useEffect, useCallback } from "react"; import { - RgbaColor, - RgbaColorPicker -} from "vanilla-colorful/rgba-color-picker.js"; -import { useAnnotationsContext } from "../../../../src"; + rgbaToString, + initialRecentColors, + withColorFromAnnotation, + createRgbaColorPicker, + type RgbaColorPicker, + type RecentColorsState +} from "@linkurious/ogma-annotations/ui"; +import React, { useState, useRef, useEffect, useCallback } from "react"; +import { useAnnotationsContext } from "@linkurious/ogma-annotations-react"; interface ColorControllerProps { annotation: Annotation; @@ -18,13 +22,8 @@ export const ColorController: React.FC = ({ initialColor }) => { const { editor } = useAnnotationsContext(); + const [recent, setRecent] = useState(initialRecentColors); const [currentColor, setCurrentColor] = useState(initialColor); - const [recentColors, setRecentColors] = useState([ - "#0099FF", - "#FF7523", - "#44AA99" - ]); - const [activeColorIndex, setActiveColorIndex] = useState(0); const [showColorPicker, setShowColorPicker] = useState(false); const colorPickerRef = useRef(null); @@ -33,31 +32,20 @@ export const ColorController: React.FC = ({ const updateAnnotationColor = useCallback( (color: string) => { if (!annotation) return; - - if (mode === "arrow") { - editor?.updateStyle(annotation.id, { strokeColor: color }); - } else if (mode === "text") { - editor?.updateStyle(annotation.id, { color: color }); - } else if (mode === "polygon") { + if (mode === "text") { + editor?.updateStyle(annotation.id, { color }); + } else { editor?.updateStyle(annotation.id, { strokeColor: color }); } }, [annotation, editor, mode] ); - const updateColorFromAnnotation = useCallback( - (color: string) => { - if (!recentColors.includes(color)) { - const newColors = [color, ...recentColors.slice(0, 2)]; - setRecentColors(newColors); - setActiveColorIndex(0); - } else { - setActiveColorIndex(recentColors.indexOf(color)); - } - setCurrentColor(color); - }, - [recentColors] - ); + // Sync the recent-colors strip with the selected annotation's color. + useEffect(() => { + setRecent((prev) => withColorFromAnnotation(prev, initialColor)); + setCurrentColor(initialColor); + }, [initialColor]); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -70,12 +58,9 @@ export const ColorController: React.FC = ({ const isClickOnColorCircle = Array.from(colorCircles).some((circle) => circle.contains(event.target as Node) ); - if (!isClickOnColorCircle) { - setShowColorPicker(false); - } + if (!isClickOnColorCircle) setShowColorPicker(false); } }; - document.addEventListener("click", handleClickOutside); return () => document.removeEventListener("click", handleClickOutside); }, [showColorPicker]); @@ -86,17 +71,18 @@ export const ColorController: React.FC = ({ colorPickerRef.current && !colorPickerInstance.current ) { - colorPickerInstance.current = new RgbaColorPicker(); + colorPickerInstance.current = createRgbaColorPicker(); colorPickerInstance.current.color = parseColor(currentColor); colorPickerRef.current.appendChild(colorPickerInstance.current); colorPickerInstance.current.addEventListener("color-changed", (event) => { const newColor = rgbaToString(event.detail.value); setCurrentColor(newColor); - const newRecentColors = [...recentColors]; - newRecentColors[activeColorIndex] = newColor; - setRecentColors(newRecentColors); - + setRecent((prev) => { + const colors = [...prev.colors]; + colors[prev.activeIndex] = newColor; + return { ...prev, colors }; + }); updateAnnotationColor(newColor); }); } @@ -109,25 +95,14 @@ export const ColorController: React.FC = ({ colorPickerRef.current.innerHTML = ""; colorPickerInstance.current = null; } - }, [ - showColorPicker, - currentColor, - activeColorIndex, - recentColors, - updateAnnotationColor, - updateColorFromAnnotation - ]); - - useEffect(() => { - updateColorFromAnnotation(initialColor); - }, [initialColor, updateColorFromAnnotation]); + }, [showColorPicker, currentColor, updateAnnotationColor]); const handleColorCircleClick = (index: number, event: React.MouseEvent) => { event.stopPropagation(); event.preventDefault(); - const wasActive = activeColorIndex === index && showColorPicker; - setActiveColorIndex(index); - setCurrentColor(recentColors[index]); + const wasActive = recent.activeIndex === index && showColorPicker; + setRecent((prev) => ({ ...prev, activeIndex: index })); + setCurrentColor(recent.colors[index]); if (wasActive) { setShowColorPicker(false); @@ -135,7 +110,7 @@ export const ColorController: React.FC = ({ setShowColorPicker(false); setTimeout(() => setShowColorPicker(true), 50); } else { - updateAnnotationColor(recentColors[index]); + updateAnnotationColor(recent.colors[index]); setShowColorPicker(true); } }; @@ -146,10 +121,10 @@ export const ColorController: React.FC = ({

Color

- {recentColors.map((color, index) => ( + {recent.colors.map((color, index) => (
{opts.map((option) => ( @@ -84,10 +73,7 @@ export const ExtremityController: React.FC = ({ title={option.label} onClick={() => handleExtremitySelect(side, option.value)} > - + {option.label}
))} diff --git a/packages/react/web/src/components/controllers/FontController.tsx b/packages/react/src/ui/controllers/FontController.tsx similarity index 73% rename from packages/react/web/src/components/controllers/FontController.tsx rename to packages/react/src/ui/controllers/FontController.tsx index b6c88ada..72a11ce8 100644 --- a/packages/react/web/src/components/controllers/FontController.tsx +++ b/packages/react/src/ui/controllers/FontController.tsx @@ -1,12 +1,8 @@ import { Comment, Text } from "@linkurious/ogma-annotations"; +import { FONTS, type IconName } from "@linkurious/ogma-annotations/ui"; import React, { useState } from "react"; -import { useAnnotationsContext } from "../../../../src"; - -const FONTS = [ - { value: "sans-serif", label: "Sans Serif", icon: "icon-type" }, - { value: "serif", label: "Serif", icon: "icon-italic" }, - { value: "monospace", label: "Monospace", icon: "icon-code" } -]; +import { useAnnotationsContext } from "@linkurious/ogma-annotations-react"; +import { Icon } from "../Icon"; interface FontControllerProps { annotation: Comment | Text; @@ -21,9 +17,7 @@ export const FontController: React.FC = ({ const [isOpen, setIsOpen] = useState(false); const handleFontSelect = (fontValue: string) => { - if (annotation) { - editor?.updateStyle(annotation.id, { font: fontValue }); - } + if (annotation) editor?.updateStyle(annotation.id, { font: fontValue }); setIsOpen(false); }; @@ -40,9 +34,9 @@ export const FontController: React.FC = ({ className="custom-select-trigger" onClick={() => setIsOpen(!isOpen)} > - + {selected.label} - +
{FONTS.map((font) => ( @@ -52,7 +46,7 @@ export const FontController: React.FC = ({ title={font.label} onClick={() => handleFontSelect(font.value)} > - + {font.label}
))} diff --git a/packages/react/web/src/components/controllers/LineTypeController.tsx b/packages/react/src/ui/controllers/LineTypeController.tsx similarity index 74% rename from packages/react/web/src/components/controllers/LineTypeController.tsx rename to packages/react/src/ui/controllers/LineTypeController.tsx index c8c8c466..177c48c7 100644 --- a/packages/react/web/src/components/controllers/LineTypeController.tsx +++ b/packages/react/src/ui/controllers/LineTypeController.tsx @@ -1,11 +1,8 @@ import { Annotation } from "@linkurious/ogma-annotations"; +import { LINE_TYPES, type IconName } from "@linkurious/ogma-annotations/ui"; import React from "react"; -import { useAnnotationsContext } from "../../../../src"; - -const LINE_TYPES = [ - { value: "plain", icon: "icon-circle" }, - { value: "dashed", icon: "icon-circle-dashed" } -]; +import { useAnnotationsContext } from "@linkurious/ogma-annotations-react"; +import { Icon } from "../Icon"; interface LineTypeControllerProps { annotation: Annotation; @@ -19,9 +16,7 @@ export const LineTypeController: React.FC = ({ const { editor } = useAnnotationsContext(); const handleLineTypeClick = (lineType: "plain" | "dashed") => { - if (annotation) { - editor?.updateStyle(annotation.id, { strokeType: lineType }); - } + if (annotation) editor?.updateStyle(annotation.id, { strokeType: lineType }); }; return ( @@ -37,7 +32,7 @@ export const LineTypeController: React.FC = ({ title={value} onClick={() => handleLineTypeClick(value as "plain" | "dashed")} > - + ))}
diff --git a/packages/react/web/src/components/controllers/SliderController.tsx b/packages/react/src/ui/controllers/SliderController.tsx similarity index 93% rename from packages/react/web/src/components/controllers/SliderController.tsx rename to packages/react/src/ui/controllers/SliderController.tsx index 156bc7fb..faefc9ac 100644 --- a/packages/react/web/src/components/controllers/SliderController.tsx +++ b/packages/react/src/ui/controllers/SliderController.tsx @@ -1,11 +1,11 @@ import { Annotation } from "@linkurious/ogma-annotations"; import React from "react"; -import { useAnnotationsContext } from "../../../../src"; +import { useAnnotationsContext } from "@linkurious/ogma-annotations-react"; interface SliderControllerProps { annotation: Annotation; title: string; - property: string; + property: "strokeWidth" | "fontSize"; value: number; min: number; max: number; diff --git a/packages/react/web/src/components/controllers/index.ts b/packages/react/src/ui/controllers/index.ts similarity index 100% rename from packages/react/web/src/components/controllers/index.ts rename to packages/react/src/ui/controllers/index.ts diff --git a/packages/react/src/ui/index.ts b/packages/react/src/ui/index.ts new file mode 100644 index 00000000..24656013 --- /dev/null +++ b/packages/react/src/ui/index.ts @@ -0,0 +1,25 @@ +/** + * Optional, styled React UI for `@linkurious/ogma-annotations-react`. + * + * Import the components from `@linkurious/ogma-annotations-react/ui`. CSS is + * injected automatically when you import this entry; if your bundler strips + * side-effect CSS, also import `@linkurious/ogma-annotations-react/ui/styles.css`. + * + * The main package entry stays headless — nothing here is pulled in unless you + * import this subpath. + */ +import "./styles.css"; + +export { AnnotationPanel } from "./AnnotationPanel"; +export type { AnnotationPanelProps } from "./AnnotationPanel"; +export { + AnnotationPanelController, + useAnnotationPanel +} from "./AnnotationPanelController"; +export { AddMenu } from "./AddMenu"; +export type { AddMenuProps } from "./AddMenu"; +export { ViewControls } from "./ViewControls"; +export { Icon } from "./Icon"; +export type { IconProps } from "./Icon"; + +export * from "./controllers"; diff --git a/packages/react/src/ui/styles.css b/packages/react/src/ui/styles.css new file mode 100644 index 00000000..1efb8132 --- /dev/null +++ b/packages/react/src/ui/styles.css @@ -0,0 +1,9 @@ +/** + * Stylesheet for `@linkurious/ogma-annotations-react/ui`. + * Re-exports the shared panel styles from core and adds the toolbar styles. + * + * Import once in your app: + * import "@linkurious/ogma-annotations-react/ui/styles.css"; + */ +@import "@linkurious/ogma-annotations/ui/styles.css"; +@import "./toolbar.css"; diff --git a/packages/react/src/ui/toolbar.css b/packages/react/src/ui/toolbar.css new file mode 100644 index 00000000..40dc8fb0 --- /dev/null +++ b/packages/react/src/ui/toolbar.css @@ -0,0 +1,148 @@ +/* Toolbar + view controls styling for the React UI components. */ + +.add-menu { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 5px; +} + +.add-menu > button { + margin: 2px; + padding: 5px; + border: 1px solid #fff; + cursor: pointer; + border-radius: 5px; + background: #fff; + color: #444; +} + +.add-menu > button:hover { + background: #f0f0f0; + color: #222; +} + +.add-menu > button.active { + background: var(--oa-accent, var(--brand, #3a03cf)); + color: #fff; +} + +.add-menu > button.active:hover { + background: var(--oa-accent, var(--brand, #3a03cf)); + border-color: var(--oa-accent, var(--brand, #3a03cf)); +} + +.add-menu > button:disabled { + color: #aaa; + cursor: not-allowed; +} + +.add-menu > .separator { + border-left: 1px solid #ccc; + margin: 0 5px; + height: 20px; + display: inline-block; + vertical-align: middle; +} + +.view-controls { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; +} + +.view-controls > button { + margin: 2px; + padding: 5px; + border: 1px solid #fff; + cursor: pointer; + border-radius: 5px; + background: #fff; + color: #444; +} + +.view-controls > button:hover { + background: #f0f0f0; + color: #222; +} + +.view-controls > button:disabled { + color: #aaa; + cursor: not-allowed; +} + +.view-controls > .separator { + border-top: 1px solid #ccc; + width: 20px; + height: 0; + margin: 5px 0; +} + +/* Tooltips */ +[data-tooltip] { + position: relative; +} + +[data-tooltip]::before { + content: ""; + position: absolute; + top: -6px; + left: 50%; + transform: translateX(-50%); + border-width: 4px 6px 0 6px; + border-style: solid; + border-color: rgba(0, 0, 0, 0.7) transparent transparent transparent; + z-index: 99; + opacity: 0; +} + +[tooltip-position="left"]::before { + left: 0%; + top: 50%; + margin-left: -12px; + transform: translatey(-50%) rotate(-90deg); +} +[tooltip-position="right"]::before { + left: 100%; + top: 50%; + margin-left: 1px; + transform: translatey(-50%) rotate(90deg); +} + +[data-tooltip]::after { + content: attr(data-tooltip); + position: absolute; + left: 50%; + top: -6px; + transform: translateX(-50%) translateY(-100%); + background: rgba(0, 0, 0, 0.7); + text-align: center; + color: #fff; + font-size: 12px; + min-width: 60px; + border-radius: 5px; + pointer-events: none; + padding: 4px 4px; + z-index: 99; + opacity: 0; +} + +[tooltip-position="left"]::after { + left: 0%; + top: 50%; + margin-left: -8px; + transform: translateX(-100%) translateY(-50%); +} +[tooltip-position="right"]::after { + left: 100%; + top: 50%; + margin-left: 8px; + transform: translateX(0%) translateY(-50%); +} + +[data-tooltip]:hover::after, +[data-tooltip]:hover::before { + opacity: 1; +} diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index 4b8c7154..a75d4301 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -6,6 +6,14 @@ "module": "es2022", "skipLibCheck": true, "moduleResolution": "bundler", + "baseUrl": ".", + "paths": { + // The /ui sources import the package's own main entry (for the React + // context) and core's /ui subpath. Both are externalized at bundle time, + // but tsc runs before dist exists — resolve them to source for typecheck. + "@linkurious/ogma-annotations-react": ["./src/index.ts"], + "@linkurious/ogma-annotations/ui": ["../core/src/ui/index.ts"] + }, "allowSyntheticDefaultImports": true, "resolveJsonModule": true, "isolatedModules": true, diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index 618774c2..493b67dc 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -24,28 +24,29 @@ export default defineConfig({ define: { "process.env": { NODE_ENV: "production" } }, build: { lib: { - name: "OgmaAnnotationsReact", - fileName: (format) => `index.${format === "umd" ? "" : "m"}js`, - entry: resolve(__dirname, "src/index.ts") + entry: { + index: resolve(__dirname, "src/index.ts"), + ui: resolve(__dirname, "src/ui/index.ts") + }, + // Multi-entry libs cannot use UMD; emit ESM (.mjs) and CJS (.js). + formats: ["es", "cjs"], + fileName: (format, entryName) => + `${entryName}.${format === "es" ? "mjs" : "js"}` }, rollupOptions: { external: [ - "@linkurious/ogma", - "@linkurious/ogma-react", - "@linkurious/ogma-annotations", + /^@linkurious\/ogma($|\/)/, + /^@linkurious\/ogma-react($|\/)/, + /^@linkurious\/ogma-annotations($|\/)/, + // The /ui entry imports the package's own main entry for the React + // context so consumers share a single context instance (avoids the + // "editor is undefined" dual-context bug). + /^@linkurious\/ogma-annotations-react($|\/)/, + /^vanilla-colorful($|\/)/, "react", "react-dom" - ], - output: { - globals: { - "@linkurious/ogma": "Ogma", - "@linkurious/ogma-react": "OgmaReact", - "@linkurious/ogma-annotations": "OgmaAnnotations", - react: "React", - "react-dom": "ReactDOM" - } - } + ] } }, test: { diff --git a/packages/react/web/src/App.tsx b/packages/react/web/src/App.tsx index d37a886d..8ab7a5c2 100644 --- a/packages/react/web/src/App.tsx +++ b/packages/react/web/src/App.tsx @@ -2,9 +2,9 @@ import { Ogma as OgmaLib, RawGraph } from "@linkurious/ogma"; import { AnnotationCollection } from "@linkurious/ogma-annotations"; import { EdgeStyle, NodeStyle, Ogma } from "@linkurious/ogma-react"; import React from "react"; -import { AnnotationPanelController } from "./components/AnnotationPanelController"; +import { AnnotationPanelController } from "@linkurious/ogma-annotations-react/ui"; import { Controls } from "./components/Controls"; -import { AnnotationsContextProvider } from "../../src"; +import { AnnotationsContextProvider } from "@linkurious/ogma-annotations-react"; import "./App.css"; diff --git a/packages/react/web/src/components/AddMenu.css b/packages/react/web/src/components/AddMenu.css deleted file mode 100644 index 473845d3..00000000 --- a/packages/react/web/src/components/AddMenu.css +++ /dev/null @@ -1,46 +0,0 @@ -.add-menu { - display: flex; - flex-direction: row; - align-items: center; - justify-content: flex-start; - gap: 5px; -} - -.add-menu > button { - margin: 2px; - padding: 5px; - border: none; - cursor: pointer; - border-radius: 5px; - border: 1px solid #fff; - background: #fff; - color: #444; -} - -.add-menu > button:hover { - background: #f0f0f0; - color: #222; -} - -.add-menu > button.active { - background: var(--brand); - color: #fff; -} - -.add-menu > button.active:hover { - background: var(--brand); - border-color: var(--brand); -} - -.add-menu > button:disabled { - color: #aaa; - cursor: not-allowed; -} - -.add-menu > .separator { - border-left: 1px solid #ccc; - margin: 0 5px; - height: 20px; - display: inline-block; - vertical-align: middle; -} diff --git a/packages/react/web/src/components/AnnotationPanel.css b/packages/react/web/src/components/AnnotationPanel.css deleted file mode 100644 index a147136b..00000000 --- a/packages/react/web/src/components/AnnotationPanel.css +++ /dev/null @@ -1,384 +0,0 @@ -/* Annotation Side Panel */ -.annotation-panel { - position: absolute; - right: 60px; - top: 50%; - transform: translateY(-50%); - width: 228px; - background: white; - z-index: 100; - box-shadow: 0 0 5px rgba(0, 0, 0, 0.4); - padding: 5px 10px; - border-radius: 10px; -} - -.panel-header { - padding: 20px 20px 10px 20px; -} - -.panel-header h3 { - margin: 0; - font-size: 12px; - font-weight: 400; - letter-spacing: 0.05em; - text-transform: uppercase; -} - -.panel-body { - padding: 10px 20px 20px 20px; -} - -/* Color Selector */ -.color-selector { - display: flex; - gap: 12px; - margin-bottom: 20px; - padding-left: 20px; -} - -.color-circle { - position: relative; - width: 40px; - height: 40px; - background: transparent; - border-radius: 4px; - border: none; - cursor: pointer; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - transition: transform 0.2s; -} - -.color-circle:hover { - transform: scale(1.05); -} - -.color-circle .color-inner { - width: 30px; - height: 30px; - border-radius: 50%; - border: 1px solid #d4d4d8; - background: white; - position: relative; -} - -.color-circle .color-inner::after { - content: ""; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 18px; - height: 18px; - border-radius: 50%; - background: var(--circle-color); -} - -.color-circle-primary .color-inner { - border: 3px solid var(--brand); -} - -/* Color Picker Container */ -.color-picker-container { - margin-bottom: 20px; - padding: 10px; - background: #f9f9f9; - border-radius: 4px; -} - -/* Toggle Section */ -.toggle-section { - display: flex; - align-items: center; - justify-content: center; - gap: 12px; - padding: 12px 0; - margin-bottom: 20px; -} - -.toggle-label { - font-size: 14px; - color: #333; - font-weight: 400; -} - -.toggle-switch { - position: relative; - display: inline-block; - width: 31px; - height: 15px; -} - -.toggle-switch input { - opacity: 0; - width: 0; - height: 0; -} - -.toggle-slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: #eeeeee; - transition: 0.3s; - border-radius: 7.5px; - border: 0.5px solid #b6c2e2; -} - -.toggle-slider:before { - position: absolute; - content: ""; - height: 12px; - width: 12px; - left: 2px; - bottom: 1.5px; - background-color: white; - transition: 0.3s; - border-radius: 50%; -} - -input:checked + .toggle-slider { - background-color: var(--brand); -} - -input:checked + .toggle-slider:before { - transform: translateX(15px); -} - -/* Section Headers */ -.section-header { - margin-top: 20px; - margin-bottom: 15px; -} - -.section-header h3 { - margin: 0; - font-size: 12px; - font-weight: 400; - letter-spacing: 0.05em; - text-transform: uppercase; -} - -/* Slider Section */ -.slider-section { - position: relative; - padding: 10px 0; - display: flex; - align-items: center; - gap: 10px; -} - -.slider { - width: 85%; - height: 4.8px; - border-radius: 2.4px; - background: #e4e4e7; - outline: none; - -webkit-appearance: none; - appearance: none; -} - -.slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 12px; - height: 12px; - border-radius: 50%; - background: white; - border: 3px solid var(--brand); - cursor: pointer; - box-shadow: 0 0 2px rgba(0, 0, 0, 0.2); -} - -.slider::-moz-range-thumb { - width: 12px; - height: 12px; - border-radius: 50%; - background: white; - border: 3px solid var(--brand); - cursor: pointer; - box-shadow: 0 0 2px rgba(0, 0, 0, 0.2); -} - -.slider-value { - width: 15%; - text-align: right; - flex-shrink: 0; -} - -.slider-value span { - font-size: 14px; - font-weight: 500; - color: black; -} - -/* Line Type Section */ -.linetype-section { - display: flex; - gap: 8px; - justify-content: center; - padding: 10px 0; -} - -.linetype-button { - flex: 1; - height: 36px; - background: #eeeeee; - border: none; - border-radius: 8px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - color: #666; - transition: all 0.2s; - font-size: 18px; -} - -.linetype-button:hover { - background: #e0e0e0; -} - -.linetype-button.active { - background: var(--brand); - color: white; -} - -/* Color Picker Overlay */ -.color-picker-overlay { - position: fixed; - z-index: 10000; - background: white; - padding: 10px; - border-radius: 8px; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); -} - -/* Hide the color picker container */ -.color-picker-container { - display: none !important; -} - -/* Update toggle section for bucket icon */ -.toggle-section i { - font-size: 18px; - color: #333; -} - -/* Custom Select Section */ -.custom-select-section { - padding: 10px 0; - display: flex; - gap: 8px; -} - -.extremity-wrapper { - flex: 1; - display: flex; - flex-direction: column; - gap: 6px; -} - -.extremity-wrapper label { - font-size: 11px; - font-weight: 400; - color: #666; - text-transform: capitalize; - text-align: center; -} - -.custom-select { - position: relative; - width: 100%; -} - -.custom-select-trigger { - width: 100%; - height: 36px; - background: transparent; - border: none; - border-radius: 8px; - cursor: pointer; - padding: 0 8px; - font-size: 14px; - color: #333; - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - transition: all 0.2s; - user-select: none; -} - -.custom-select-trigger i:first-child { - font-size: 18px; - flex-shrink: 0; -} - -.custom-select-trigger span { - display: none; -} - -.custom-select-arrow { - font-size: 12px; - transition: transform 0.2s; - flex-shrink: 0; -} - -.custom-select.open .custom-select-arrow { - transform: rotate(180deg); -} - -.custom-select-options { - position: absolute; - top: 100%; - right: 0; - background: white; - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - margin-top: 4px; - margin-right: 24px; - overflow: hidden; - z-index: 1000; - display: none; -} - -.custom-select.open .custom-select-options { - display: block; - width: 36px; -} - -.custom-select-option { - padding: 10px 12px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - transition: background 0.15s; - font-size: 14px; - color: #333; -} - -.custom-select-option:hover { - background: #f5f5f5; -} - -.custom-select-option.selected { - background: #e8f4ff; - color: var(--brand); -} - -.custom-select-option i { - font-size: 18px; - flex-shrink: 0; -} - -.custom-select-option span { - display: none; -} diff --git a/packages/react/web/src/components/AnnotationPanel.tsx b/packages/react/web/src/components/AnnotationPanel.tsx deleted file mode 100644 index 72fad405..00000000 --- a/packages/react/web/src/components/AnnotationPanel.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { - Annotation, - Arrow, - Text, - Polygon, - isArrow, - isText, - isPolygon, - defaultArrowStyle, - defaultTextStyle, - isBox, - isComment -} from "@linkurious/ogma-annotations"; -import React, { useState, useEffect } from "react"; -import { - ColorController, - BackgroundController, - FontController, - ExtremityController, - SliderController, - LineTypeController -} from "./controllers"; -import "./AnnotationPanel.css"; - -type AnnotationMode = "arrow" | "text" | "polygon" | null; - -interface AnnotationPanelProps { - visible: boolean; - annotation: Annotation; -} - -export const AnnotationPanel: React.FC = ({ - visible, - annotation -}) => { - const [mode, setMode] = useState(null); - - useEffect(() => { - if (annotation) { - if (isArrow(annotation)) { - setMode("arrow"); - } else if (isText(annotation) || isBox(annotation)) { - setMode("text"); - } else if (isPolygon(annotation)) { - setMode("polygon"); - } - } else { - setMode(null); - } - }, [annotation]); - - const getColorForMode = () => { - if (!annotation) return "#0099FF"; - - if (mode === "arrow") { - return ( - annotation.properties.style?.strokeColor || - defaultArrowStyle.strokeColor! - ); - } else if (mode === "text") { - return ( - (annotation as Text).properties.style?.color || defaultTextStyle.color! - ); - } else if (mode === "polygon") { - return annotation.properties.style?.strokeColor || "#000000"; - } - - return "#0099FF"; - }; - - const renderArrow = (arrow: Arrow) => { - const s = arrow.properties.style || {}; - const strokeWidth = s.strokeWidth || defaultArrowStyle.strokeWidth!; - const strokeType = s.strokeType || "plain"; - const currentColor = getColorForMode(); - - return ( - <> - - - - - - ); - }; - - const renderText = (text: Text) => { - const s = text.properties.style || {}; - const fontSize = - typeof s.fontSize === "number" - ? s.fontSize - : typeof defaultTextStyle.fontSize === "number" - ? defaultTextStyle.fontSize - : 18; - const strokeWidth = s.strokeWidth || defaultTextStyle.strokeWidth!; - const strokeType = s.strokeType || "plain"; - const background = s.background || defaultTextStyle.background!; - const font = s.font || defaultTextStyle.font!; - const currentColor = getColorForMode(); - - return ( - <> - - - - - - - - ); - }; - - const renderPolygon = (polygon: Polygon) => { - const s = polygon.properties.style || {}; - const strokeWidth = s.strokeWidth || 2; - const strokeType = s.strokeType || "plain"; - const background = s.background || "transparent"; - const currentColor = getColorForMode(); - - return ( - <> - - - - - - ); - }; - - const stopEvent = React.useCallback((evt: React.SyntheticEvent) => { - evt.stopPropagation(); - }, []); - - if (!visible || !annotation) { - return null; - } - - return ( -
-
- {mode === "arrow" && isArrow(annotation) && renderArrow(annotation)} - {mode === "text" && - (isText(annotation) || isBox(annotation) || isComment(annotation)) && - renderText(annotation as Text)} - {mode === "polygon" && - isPolygon(annotation) && - renderPolygon(annotation)} -
-
- ); -}; diff --git a/packages/react/web/src/components/AnnotationPanelController.tsx b/packages/react/web/src/components/AnnotationPanelController.tsx deleted file mode 100644 index a2dda761..00000000 --- a/packages/react/web/src/components/AnnotationPanelController.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { Annotation, FeaturesEvent } from "@linkurious/ogma-annotations"; -import React, { useState, useEffect } from "react"; -import { AnnotationPanel } from "./AnnotationPanel"; -import { useAnnotationsContext } from "../../../src"; - -export const AnnotationPanelController: React.FC = () => { - const { editor } = useAnnotationsContext(); - const [currentAnnotation, setCurrentAnnotation] = useState( - null - ); - const [pendingAnnotation, setPendingAnnotation] = useState( - null - ); - const [visible, setVisible] = useState(false); - - useEffect(() => { - if (!editor) return; - - const handleSelect = (sel: FeaturesEvent) => { - if (sel.ids.length === 1) { - const annotation = editor.getAnnotation(sel.ids[0]); - if (!annotation) return; - - // Store as pending - don't show immediately in case of drag - setPendingAnnotation(annotation); - - const showPanel = () => { - if (pendingAnnotation) { - setCurrentAnnotation(pendingAnnotation); - setVisible(true); - setPendingAnnotation(null); - } - }; - - if (editor.isDrawing()) { - editor - .once("cancelDrawing", showPanel) - .once("completeDrawing", showPanel); - } - // Don't show immediately - wait for potential drag or mouseup - } else { - setPendingAnnotation(null); - setVisible(false); - } - }; - - const handleClick = () => { - if (pendingAnnotation) { - setCurrentAnnotation(pendingAnnotation); - setVisible(true); - setPendingAnnotation(null); - } - }; - - const handleDragEnd = () => { - if (pendingAnnotation) { - setCurrentAnnotation(pendingAnnotation); - setVisible(true); - setPendingAnnotation(null); - } - }; - - const handleDragStart = () => { - setPendingAnnotation(null); - setVisible(false); - }; - - const handleUnselect = () => { - setVisible(false); - setCurrentAnnotation(null); - }; - - // Add event listeners - editor - .on("select", handleSelect) - .on("click", handleClick) - .on("dragend", handleDragEnd) - .on("dragstart", handleDragStart) - .on("unselect", handleUnselect); - - // Cleanup - return () => { - editor - .off("select", handleSelect) - .off("click", handleClick) - .off("dragend", handleDragEnd) - .off("dragstart", handleDragStart) - .off("unselect", handleUnselect); - }; - }, [editor, pendingAnnotation]); - - return ; -}; diff --git a/packages/react/web/src/components/Controls.tsx b/packages/react/web/src/components/Controls.tsx index 465e55c0..c2ce1632 100644 --- a/packages/react/web/src/components/Controls.tsx +++ b/packages/react/web/src/components/Controls.tsx @@ -1,23 +1,22 @@ import type { Layer as LayerType } from "@linkurious/ogma"; import { ArrowStyles, TextStyle } from "@linkurious/ogma-annotations"; -//import "@linkurious/ogma-annotations/style.css"; import { Layer, useOgma } from "@linkurious/ogma-react"; import React from "react"; import "@linkurious/ogma-annotations/style.css"; import "./Controls.css"; -import { AddMenu } from "./AddMenu"; +import { AddMenu, ViewControls } from "@linkurious/ogma-annotations-react/ui"; +import "@linkurious/ogma-annotations-react/ui/styles.css"; import { JsonExportPopup } from "./JsonExportPopup"; import { SvgExportPopup } from "./SvgExportPopup"; -import { ViewControls } from "./ViewControls"; import { useAnnotationsContext, defaultArrowStyle, defaultTextStyle, getAnnotationsBounds -} from "../../../src"; +} from "@linkurious/ogma-annotations-react"; const preventDefault = ( evt: React.MouseEvent | WheelEvent diff --git a/packages/react/web/src/components/ViewControls.css b/packages/react/web/src/components/ViewControls.css deleted file mode 100644 index cfd0232e..00000000 --- a/packages/react/web/src/components/ViewControls.css +++ /dev/null @@ -1,34 +0,0 @@ -.view-controls { - display: flex; - flex-direction: column; - align-items: center; - gap: 5px; -} - -.view-controls > button { - margin: 2px; - padding: 5px; - border: none; - cursor: pointer; - border-radius: 5px; - border: 1px solid #fff; - background: #fff; - color: #444; -} - -.view-controls > button:hover { - background: #f0f0f0; - color: #222; -} - -.view-controls > button:disabled { - color: #aaa; - cursor: not-allowed; -} - -.view-controls > .separator { - border-top: 1px solid #ccc; - width: 20px; - height: 0; - margin: 5px 0; -} diff --git a/packages/react/web/src/tooltip.css b/packages/react/web/src/tooltip.css deleted file mode 100644 index b8a94c28..00000000 --- a/packages/react/web/src/tooltip.css +++ /dev/null @@ -1,81 +0,0 @@ -[data-tooltip] { - position: relative; -} -[data-tooltip]::before { - content: ""; - position: absolute; - top: -6px; - left: 50%; - transform: translateX(-50%); - border-width: 4px 6px 0 6px; - border-style: solid; - border-color: rgba(0, 0, 0, 0.7) transparent transparent transparent; - z-index: 99; - opacity: 0; -} - -[tooltip-position="left"]::before { - left: 0%; - top: 50%; - margin-left: -12px; - transform: translatey(-50%) rotate(-90deg); -} -[tooltip-position="top"]::before { - left: 50%; -} -[tooltip-position="buttom"]::before { - top: 100%; - margin-top: 8px; - transform: translateX(-50%) translatey(-100%) rotate(-180deg); -} -[tooltip-position="right"]::before { - left: 100%; - top: 50%; - margin-left: 1px; - transform: translatey(-50%) rotate(90deg); -} - -[data-tooltip]::after { - content: attr(data-tooltip); - position: absolute; - left: 50%; - top: -6px; - transform: translateX(-50%) translateY(-100%); - background: rgba(0, 0, 0, 0.7); - text-align: center; - color: #fff; - padding: 4px 2px; - font-size: 12px; - min-width: 60px; - border-radius: 5px; - pointer-events: none; - padding: 4px 4px; - z-index: 99; - opacity: 0; -} - -[tooltip-position="left"]::after { - left: 0%; - top: 50%; - margin-left: -8px; - transform: translateX(-100%) translateY(-50%); -} -[tooltip-position="top"]::after { - left: 50%; -} -[tooltip-position="buttom"]::after { - top: 100%; - margin-top: 8px; - transform: translateX(-50%) translateY(0%); -} -[tooltip-position="right"]::after { - left: 100%; - top: 50%; - margin-left: 8px; - transform: translateX(0%) translateY(-50%); -} - -[data-tooltip]:hover::after, -[data-tooltip]:hover::before { - opacity: 1; -}