Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/docs/02-user-interactions.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,15 @@ For elements with pan and zoom functionality enabled, users can:

If the toolbar is enabled, users can also use the toolbar buttons to zoom in, zoom out, and reset the view. See the
[Configuration](03-getting-started/02-configuration.md) section to learn how to enable/disable the toolbar.

## Pinning to select text

The toolbar also has a pin button. Pinning suspends pan and zoom while keeping the current view, so that users can
select and copy the text inside the element (e.g. labels of a diagram). While pinned:

- dragging selects text instead of panning, and the cursor behaves as usual (e.g. a text cursor over text)
- the mouse wheel scrolls the page instead of zooming
- double-clicking selects a word instead of resetting the view
- the zoom buttons are disabled and the toolbar stays visible

Click the pin button again to resume pan and zoom.
7 changes: 4 additions & 3 deletions docs/docs/03-getting-started/02-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ module.exports = {

// The toolbar configuration (optional)
toolbar: {
// Whether to enable and show a control toolbar with buttons for zoom in, zoom out, and reset
// Whether to enable and show a control toolbar with buttons for zoom in, zoom out, reset, and pin
// Default: false
enabled: false,

Expand Down Expand Up @@ -111,7 +111,8 @@ module.exports = {
## Toolbar Configuration

You can customize the toolbar that appears when hovering over the pan and zoom elements. The toolbar provides buttons
for zooming in, zooming out, and resetting the view.
for zooming in, zooming out, resetting the view, and pinning the view to select text (see
[User Interactions](../02-user-interactions.md#pinning-to-select-text)).

```js title="docusaurus.config.js"
module.exports = {
Expand All @@ -123,7 +124,7 @@ module.exports = {

// The toolbar configuration (optional)
toolbar: {
// Whether to enable and show a control toolbar with buttons for zoom in, zoom out, and reset
// Whether to enable and show a control toolbar with buttons for zoom in, zoom out, reset, and pin
// Default: false
enabled: true,

Expand Down
22 changes: 11 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,21 @@
},
"devDependencies": {
"@docusaurus/types": "^3.10.2",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/changelog": "^7.0.0",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^12.0.9",
"@semantic-release/npm": "^13.1.5",
"@semantic-release/git": "^11.0.1",
"@semantic-release/github": "^12.0.10",
"@semantic-release/npm": "^13.2.0",
"@semantic-release/release-notes-generator": "^14.1.1",
"@tsconfig/docusaurus": "^2.0.9",
"@types/node": "^26.5.0",
"lint-staged": "^16.4.0",
"oxfmt": "0.67.0",
"oxlint": "1.82.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"@types/node": "^26.6.3",
"lint-staged": "^17.6.0",
"oxfmt": "0.70.0",
"oxlint": "1.85.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"semantic-release": "^25.0.9",
"typescript": "^5.9.3"
"typescript": "^7.0.2"
},
"release": {
"plugins": [
Expand Down
937 changes: 503 additions & 434 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

107 changes: 101 additions & 6 deletions src/PanZoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { PanZoomPluginOptions, PanZoomPluginToolbarPosition } from './PanzoomPlu
import SvgZoomIn from './img/zoom-in';
import SvgZoomOut from './img/zoom-out';
import SvgZoomReset from './img/zoom-reset';
import SvgPin from './img/pin';
import SvgPinFilled from './img/pin-filled';
import './styles/panzoom.css';

// oxlint-disable-next-line typescript/no-require-imports
Expand All @@ -23,14 +25,77 @@ const {
...panZoomConfig
} = zoom;

/**
* Per-element state shared between the toolbar and the event listeners
*/
type PanZoomState = {
/**
* While pinned, pan/zoom is suspended at the current view so that text can be selected
*/
pinned: boolean;
};

// Inline styles that panzoom sets to make the element draggable. They are cleared while pinned.
const INTERACTION_STYLES = ['cursor', 'userSelect', 'touchAction'] as const;

/**
* Suspend or resume pan/zoom on the element while keeping the current transform.
* While pinned, the pointer handlers are unbound and the element behaves like normal content
* (text cursor, text selection), so that the user can select the text inside it.
*
* @returns A function that toggles the pinned state
*/
const createPinController = (element: HTMLElement, instance: PanzoomObject, state: PanZoomState) => {
const targets = [element, element.parentElement].filter((target): target is HTMLElement => target !== null);
const savedStyles = new Map<HTMLElement, Record<string, string>>();

return (pinned: boolean) => {
if (state.pinned === pinned) {
return;
}
state.pinned = pinned;

if (pinned) {
// destroy() only unbinds the pointer handlers. The current transform is kept.
instance.destroy();
targets.forEach((target) => {
const saved: Record<string, string> = {};
INTERACTION_STYLES.forEach((prop) => {
saved[prop] = target.style[prop];
target.style[prop] = '';
});
savedStyles.set(target, saved);
});
return;
}

targets.forEach((target) => {
const saved = savedStyles.get(target);
INTERACTION_STYLES.forEach((prop) => {
target.style[prop] = saved?.[prop] ?? '';
});
});
savedStyles.clear();
instance.bind();
};
};

/**
* Creates a toolbar with zoom controls for a panzoom instance
*
* @param container The container element to append the toolbar to
* @param instance The panzoom instance to control
* @param position The position of the toolbar
* @param state The state of the panzoom element
* @param setPinned Function to suspend or resume pan/zoom
*/
const createToolbar = (container: HTMLElement, instance: PanzoomObject, position: PanZoomPluginToolbarPosition) => {
const createToolbar = (
container: HTMLElement,
instance: PanzoomObject,
position: PanZoomPluginToolbarPosition,
state: PanZoomState,
setPinned: (pinned: boolean) => void,
) => {
const toolbar = document.createElement('div');
toolbar.className = `panzoom-toolbar panzoom-toolbar-${position} ${excludeClass}`;

Expand Down Expand Up @@ -59,7 +124,7 @@ const createToolbar = (container: HTMLElement, instance: PanzoomObject, position

// Create and append all buttons

const buttons = [
const zoomButtons = [
// Zoom in
createButton(SvgZoomIn, 'Zoom in', () => {
instance.zoomIn();
Expand All @@ -80,7 +145,25 @@ const createToolbar = (container: HTMLElement, instance: PanzoomObject, position
}),
];

buttons.forEach((button) => toolbar.appendChild(button));
// Pin the current view to allow text selection
const pinButton = createButton(SvgPin, '', () => {
setPinned(!state.pinned);
updatePinState();
});

const updatePinState = () => {
const { pinned } = state;
pinButton.innerHTML = pinned ? SvgPinFilled : SvgPin;
pinButton.title = pinned ? 'Unpin to enable pan and zoom' : 'Pin to select text';
pinButton.setAttribute('aria-pressed', String(pinned));
container.classList.toggle('panzoom-pinned', pinned);
zoomButtons.forEach((button) => {
button.disabled = pinned;
});
};
updatePinState();

[...zoomButtons, pinButton].forEach((button) => toolbar.appendChild(button));
container.appendChild(toolbar);
};

Expand All @@ -90,9 +173,15 @@ const createToolbar = (container: HTMLElement, instance: PanzoomObject, position
*
* @param element The element to add event listeners to
* @param instance The panzoom instance to control
* @param state The state of the panzoom element
*/
const addEventListeners = (element: HTMLElement, instance: PanzoomObject) => {
const addEventListeners = (element: HTMLElement, instance: PanzoomObject, state: PanZoomState) => {
const handleZoomWithWheel = (event: WheelEvent) => {
// Leave the wheel to the page scroll while pinned
if (state.pinned) {
return;
}

if (restrictZoomOutBeyondOrigin) {
// Allow zooming in or zooming out only to the original size
if (event.deltaY < 0 || (event.deltaY > 0 && instance.getScale() > 1)) {
Expand Down Expand Up @@ -122,6 +211,10 @@ const addEventListeners = (element: HTMLElement, instance: PanzoomObject) => {
// Handle double-click reset zoom
if (enableDoubleClickResetZoom) {
element.addEventListener('dblclick', () => {
// Double-click selects a word while pinned
if (state.pinned) {
return;
}
instance.reset();
});
}
Expand Down Expand Up @@ -155,12 +248,14 @@ const zoomElements = (selectors: string[]) => {
container = htmlElement;
}
const instance = panzoom(element as HTMLElement, { excludeClass, ...panZoomConfig });
const state: PanZoomState = { pinned: false };

addEventListeners(container, instance);
addEventListeners(container, instance, state);

// Add toolbar if enabled
if (enabled) {
createToolbar(container, instance, position);
const setPinned = createPinController(element as HTMLElement, instance, state);
createToolbar(container, instance, position, state, setPinned);
}
});
};
Expand Down
1 change: 1 addition & 0 deletions src/css.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module '*.css';
1 change: 1 addition & 0 deletions src/img/pin-filled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default '<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#000"><path d="m640-480 80 80v80H520v240l-40 40-40-40v-240H240v-80l80-80v-280h-40v-80h400v80h-40v280Z"/></svg>';
1 change: 1 addition & 0 deletions src/img/pin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default '<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#000"><path d="m640-480 80 80v80H520v240l-40 40-40-40v-240H240v-80l80-80v-280h-40v-80h400v80h-40v280Zm-286 80h252l-46-46v-314H400v314l-46 46Zm126 0Z"/></svg>';
15 changes: 15 additions & 0 deletions src/styles/panzoom.css
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,18 @@
justify-content: center;
align-items: center;
}

/* Keep the toolbar visible while pinned so that the pinned state is noticeable */
.panzoom-pinned > .panzoom-toolbar {
opacity: 1 !important;
}

.panzoom-toolbar button:disabled {
cursor: not-allowed;
opacity: 0.4;
}

.panzoom-toolbar button[aria-pressed='true'] {
border-color: #888;
background-color: #e6e6e6;
}
6 changes: 3 additions & 3 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
"compilerOptions": {
"target": "ES2020", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"module": "CommonJS", /* Specify what module code is generated. */
"baseUrl": "./src", /* Specify the base directory to resolve non-relative module names. */
"moduleResolution": "bundler", /* Resolve modules the way Docusaurus' bundler does. */
"rootDir": "./src", /* Specify the root folder within your source files. */
"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
"declarationDir": "./dist", /* Specify the output directory for generated declaration files. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
"strict": true, /* Enable all strict type-checking options. */
"skipLibCheck": true, /* Skip type checking all .d.ts files. */
"moduleResolution": "node"
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "docs/**/*"]
Expand Down
Loading