/**
* OpenSeadragon paperjs overlay plugin based on paper.js
* @version 0.7.6
*
* Includes additional open source libraries which are subject to copyright notices
* as indicated accompanying those segments of code.
*
* Original code:
* Copyright (c) 2022-2026, Thomas Pearce
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* * Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* * Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* * Neither the name of osd-paperjs-annotation nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
*/
import { AnnotationUITool, AnnotationUIToolbarBase } from './annotationUITool.mjs';
import { paper } from '../paperjs.mjs';
import { makeFaIcon } from '../utils/faIcon.mjs';
import { clampDecimals, normalizeRoundingMode, formatDecimal } from '../utils/measurementFormat.mjs';
const ZERO_LENGTH_EPSILON = 1e-6;
const CROSSHAIR_SIZE_PX = 8;
const RULER_LABEL_FONT_SIZE = 12;
const RULER_LABEL_GAP_PX = 4; // Gap between line and label in screen pixels (zoom-independent)
const RULER_HALO_EXTRA_PX = 2; // Extra stroke width for white halo (in screen pixels, zoom-independent)
const RULER_LABEL_STROKE_PX = 3; // Heavy white stroke for stroke-only label (background, in screen pixels, zoom-independent)
const DEFAULT_DECIMALS = 2;
const DEFAULT_ROUNDING_MODE = 'round';
// Segment group layout: exactly 3 children (halo, path, labelGroup with [strokeLabel, fillLabel])
const SEGMENT_HALO = 0;
const SEGMENT_PATH = 1;
const SEGMENT_LABEL_GROUP = 2;
/**
* Ruler tool: two-point measurement. Extends AnnotationUITool only.
* Creates segments with exactly two points (click-move-click or click-drag).
* Line width is in screen pixels (zoom-independent via rescale).
* Displays P1, P2, and distance in toolbar.
*
* Emits on project when a measurement's geometry or display changes (for list/UI sync):
* - `ruler-measurement-updated` with payload `{ item, label?, distance? }` (item = measurement Group;
* label = display string; distance = length in project units). Fired after segment commit,
* after endpoint/line drag edit, and when display settings (units etc.) change.
*
* @extends AnnotationUITool
* @class
* @memberof OSDPaperjsAnnotation
*/
class RulerTool extends AnnotationUITool {
constructor(paperScope) {
super(paperScope);
this.setToolbarControl(new RulerToolbar(this));
this.registerOverlayCursorOwnedClasses('rectangle-tool-resize', 'rectangle-tool-move');
// Config and placement state
this.strokeWidthPixels = 2;
this.haloExtraPixels = 2;
this.labelFontSize = 12;
this._firstPoint = null;
this._previewSegmentGroup = null;
this._didDrag = false;
this._lastMeasurement = { p1: null, p2: null, distance: null };
// Selection tracking (for onSelectionChanged)
this._currentItem = null;
this._drawingItem = null;
// Editing mode: 'creating' | 'modifying' | 'endpoint-drag' | 'line-drag'
this.mode = null;
this._editPath = null;
this._editSegmentIndex = null;
// Drawing group: transient preview; moved to targetLayer on activate, back to toolLayer on deactivate
this.drawingGroup = new paper.Group();
this.drawingGroup.visible = false;
this.project.toolLayer.addChild(this.drawingGroup);
// Crosshair cursor (size recomputed from CROSSHAIR_SIZE_PX in setCrosshairPosition)
const crosshairSize = CROSSHAIR_SIZE_PX / this.project.getZoom();
this._crosshair = this._createCrosshair(crosshairSize);
this.project.toolLayer.addChild(this._crosshair);
// Activate / deactivate
this.extensions.onActivate = () => {
this.drawingGroup.visible = true;
this.targetLayer.addChild(this.drawingGroup);
this._currentItem = this.item;
this.tool.minDistance = 0;
this.tool.maxDistance = 0;
const view = this.tool.view;
const center = view.viewToProject(new paper.Point(view.viewSize.width / 2, view.viewSize.height / 2));
this.setCrosshairPosition(center);
this._updateModeAndCrosshair();
};
this.extensions.onDeactivate = (finished) => {
this._crosshair.visible = false;
this.mode = null;
this._editPath = null;
this._editSegmentIndex = null;
this.clearOverlayCursorOwnedClasses();
if (finished) {
this._clearPlacementState();
this.drawingGroup.removeChildren();
this.drawingGroup.visible = false;
this.project.toolLayer.addChild(this.drawingGroup);
this._currentItem = null;
this.toolbarControl.updateMeasurement(null, null, null);
}
};
// No erase or polygon; key handlers are no-ops
this.tool.extensions.onKeyDown = () => {};
this.tool.extensions.onKeyUp = () => {};
}
/**
* Build crosshair group (four paths) for cursor feedback.
* @private
*/
_createCrosshair(size) {
const g = new paper.Group({ visible: false });
const h1 = new paper.Path({
segments: [new paper.Point(-size, 0), new paper.Point(size, 0)],
strokeScaling: false,
strokeWidth: 1,
strokeColor: 'black',
});
const h2 = new paper.Path({
segments: [new paper.Point(-size, 0), new paper.Point(size, 0)],
strokeScaling: false,
strokeWidth: 1,
strokeColor: 'white',
dashArray: [3, 3],
});
const v1 = new paper.Path({
segments: [new paper.Point(0, -size), new paper.Point(0, size)],
strokeScaling: false,
strokeWidth: 1,
strokeColor: 'black',
});
const v2 = new paper.Path({
segments: [new paper.Point(0, -size), new paper.Point(0, size)],
strokeScaling: false,
strokeWidth: 1,
strokeColor: 'white',
dashArray: [3, 3],
});
g.addChildren([h1, h2, v1, v2]);
return g;
}
/**
* Clear placement state and remove preview path from scene.
* @private
*/
_clearPlacementState() {
this._firstPoint = null;
this._didDrag = false;
this._drawingItem = null;
if (this._previewSegmentGroup && this._previewSegmentGroup.parent) {
this._previewSegmentGroup.remove();
}
this._previewSegmentGroup = null;
}
/**
* Ensure we have an item to draw into (create MultiLineString from itemToCreate if needed).
* @private
* @returns {boolean} true if this.item is available
*/
_ensureItemForDrawing() {
if (this.itemToCreate) {
this.itemToCreate.initializeGeoJSONFeature('MultiLineString', 'Measurement');
this.refreshItems();
this._setTargetLayer();
if (this.isActive()) {
this.targetLayer.addChild(this.drawingGroup);
}
}
return !!this.item;
}
/**
* Resolve the main path from a segment child (group with path, or legacy path).
* @private
* @param {paper.Item} child - segment group (Group with [halo, path, labelGroup] or legacy 4-child) or legacy Path
* @returns {paper.Path|null}
* @description For groups: 3 or 4 children -> path at index 1.
*/
_getPathFromSegmentChild(child) {
if (!child) return null;
if (child instanceof paper.Path) return child;
if (child instanceof paper.Group && (child.children.length === 3 || child.children.length === 4)) return child.children[SEGMENT_PATH];
return null;
}
/**
* Update label content and position for a segment group (assumes 3 children: halo, path, labelGroup).
* @private
* @param {paper.Group} segmentGroup - group with [halo, path, labelGroup] where labelGroup has [strokeLabel, fillLabel]
*/
_ensurePathLabel(segmentGroup) {
if (segmentGroup.children.length !== 3) return;
const path = segmentGroup.children[SEGMENT_PATH];
const labelGroup = segmentGroup.children[SEGMENT_LABEL_GROUP];
const haloPath = segmentGroup.children[SEGMENT_HALO];
if (!path || path.segments.length < 2 || !labelGroup || labelGroup.children.length < 2) return;
// Segment in item: delegate content and position to the item (no view listeners here)
if (this.item && segmentGroup.parent === this.item && this.item.annotationItem && typeof this.item.annotationItem.refreshSegmentLabel === 'function') {
this.item.annotationItem.refreshSegmentLabel(segmentGroup);
} else {
// Preview or non-Measurement: tool updates content and position (no listeners)
const strokeLabel = labelGroup.children[0];
const fillLabel = labelGroup.children[1];
const p1 = path.segments[0].point;
const p2 = path.segments[1].point;
const distance = p1.getDistance(p2);
const midpoint = p1.add(p2).divide(2);
const formatted = this.toolbarControl.formatDistance(distance);
const fillColor = path.strokeColor || new paper.Color('black');
fillLabel.justification = 'center';
fillLabel.fillColor = fillColor;
fillLabel.strokeColor = null;
fillLabel.content = formatted;
fillLabel.rescale = fillLabel.rescale || {};
fillLabel.rescale.fontSize = (z) => this.labelFontSize / z;
delete fillLabel.rescale.strokeWidth;
strokeLabel.justification = 'center';
strokeLabel.strokeColor = 'white';
strokeLabel.fillColor = null;
strokeLabel.content = formatted;
strokeLabel.rescale = strokeLabel.rescale || {};
strokeLabel.rescale.fontSize = (z) => this.labelFontSize / z;
strokeLabel.rescale.strokeWidth = (z) => RULER_LABEL_STROKE_PX / z;
if (fillLabel.applyRescale) fillLabel.applyRescale();
if (strokeLabel.applyRescale) strokeLabel.applyRescale();
const labelHeight = (fillLabel.getInternalBounds && fillLabel.getInternalBounds()) ? fillLabel.getInternalBounds().height : (fillLabel.bounds ? fillLabel.bounds.height : 0);
if (labelHeight > 0) {
fillLabel.point = new paper.Point(0, labelHeight / 2);
strokeLabel.point = new paper.Point(0, labelHeight / 2);
labelGroup.pivot = new paper.Point(0, labelHeight / 2);
}
const placementCenter = this._computeLabelPlacementCenter(fillLabel, p1, p2, midpoint);
labelGroup.position = placementCenter.clone();
}
// Sync halo geometry to path (tool responsibility whenever it touches segment groups)
if (haloPath instanceof paper.Path && haloPath.segments.length === path.segments.length) {
path.segments.forEach((seg, i) => {
haloPath.segments[i].point = seg.point.clone();
});
}
}
/**
* Compute placement center for label offset above segment (constant pixel gap, zoom-aware).
* @private
* @param {paper.PointText} label - label with bounds set (after applyRescale)
* @param {paper.Point} p1 - segment start point
* @param {paper.Point} p2 - segment end point
* @param {paper.Point} midpoint - segment midpoint
* @returns {paper.Point} placement center (midpoint if segment too short, otherwise offset above)
*/
_computeLabelPlacementCenter(label, p1, p2, midpoint) {
const segmentDir = p2.subtract(p1);
const segmentLength = segmentDir.length;
if (segmentLength < 1e-6) return midpoint; // Degenerate segment: use midpoint
const normalizedDir = segmentDir.normalize();
// Normal pointing "above" (90° counter-clockwise from segment direction)
const normal = new paper.Point(normalizedDir.y, -normalizedDir.x);
// Get zoom factor (same convention as PointText / applyRescale)
const zoomFactor = label.view.scaling.x * label.layer.scaling.x;
const gapPaper = RULER_LABEL_GAP_PX / zoomFactor;
// Offset: half label height (use getInternalBounds like PointText when available) + gap (constant pixels)
const labelBounds = (label.getInternalBounds && label.getInternalBounds()) ? label.getInternalBounds() : label.bounds;
const labelHeight = labelBounds ? labelBounds.height : 0;
const offset = labelHeight / 2 + gapPaper;
return midpoint.add(normal.multiply(offset));
}
/**
* Position a PointText so its visual center is at the given point (like PointText annotation).
* @private
* @param {paper.PointText} label - label with justification 'center'
* @param {paper.Point} centerPoint - desired center position
*/
_centerLabelOnPoint(label, centerPoint) {
if (!label.bounds) return;
const h = label.bounds.height;
label.point = new paper.Point(centerPoint.x, centerPoint.y + h / 2);
}
/**
* Ensure every segment is a 3-child group (halo, path, labelGroup). Replace bare Paths with a new segment group; refresh labels on Groups.
* @private
*/
_ensureItemLabels() {
if (!this.item || !this.item.children.length) return;
const toProcess = this.item.children.slice();
toProcess.forEach((child, index) => {
if (child instanceof paper.Path) {
const path = child;
const p1 = path.segments[0] && path.segments[0].point;
const p2 = path.segments[1] && path.segments[1].point;
if (!p1 || !p2) return;
const group = this.buildSegmentGroup(p1, p2, { preview: false });
this.item.removeChild(path);
this.item.insertChild(index, group);
this._ensurePathLabel(group);
} else if (child instanceof paper.Group && child.children.length === 3) {
this._ensurePathLabel(child);
}
});
}
/**
* Set mode (creating vs modifying) and crosshair visibility; when modifying, sync _lastMeasurement from first path.
* @private
*/
_updateModeAndCrosshair() {
const creating = this.itemToCreate ||
this._firstPoint !== null ||
(this.item && this.item.children.length === 0);
this.mode = creating ? 'creating' : (this.item && this.item.children.length > 0 ? 'modifying' : null);
if (this.mode !== 'modifying') this.clearOverlayCursorOwnedClasses();
this._crosshair.visible = this.mode === 'creating';
if (this.mode === 'modifying' && this.item && this.item.children.length > 0) {
const path = this._getPathFromSegmentChild(this.item.children[0]);
if (!path || path.segments.length < 2) return;
const p1 = path.segments[0].point;
const p2 = path.segments[1].point;
const distance = p1.getDistance(p2);
this._lastMeasurement = { p1, p2, distance };
this.toolbarControl.updateMeasurement(p1, p2, distance);
}
if (this.toolbarControl.updateInstructions) {
this.toolbarControl.updateInstructions(this.mode);
}
if (this.mode === 'modifying' && this.item && this.item.children.length > 0) {
this._ensureItemLabels();
}
}
onSelectionChanged() {
if (this.item === this._currentItem) return;
// In-progress placement: selection "changed" to the item we're drawing into (e.g. placeholder → new item) or to null
if (this._firstPoint !== null && this._drawingItem) {
if (this.item === this._drawingItem) {
this._currentItem = this.item;
this.targetLayer.addChild(this.drawingGroup);
return;
}
if (this.item == null) {
this._currentItem = this.item;
const layer = this._drawingItem.layer || this.targetLayer;
layer.addChild(this.drawingGroup);
return;
}
}
this._clearPlacementState();
this.drawingGroup.removeChildren();
this.toolbarControl.updateMeasurement(null, null, null);
this._currentItem = this.item;
this.targetLayer.addChild(this.drawingGroup);
this._updateModeAndCrosshair();
}
/**
* Zoom factor for stroke width (matches paper-extensions rescale convention).
* @returns {number}
*/
getZoomFactor() {
return this.targetLayer.scaling.x * this.project.getZoom();
}
/**
* Write current tool and toolbar settings to this.item.data.ruler when item is a Measurement (for save/load).
* @private
*/
_writeRulerDataToItem() {
if (!this.item || !this.item.annotationItem) return;
const typeInfo = this.item.annotationItem.getGeoJSONType?.();
if (typeInfo?.subtype !== 'Measurement') return;
const tc = this.toolbarControl;
this.item.data.ruler = {
units: tc && tc.labelUnit != null ? tc.labelUnit : 'px',
unitsPerPixel: tc && tc.unitsPerPixel != null ? tc.unitsPerPixel : 1,
decimals: tc && tc.decimals != null ? tc.decimals : DEFAULT_DECIMALS,
roundingMode: tc && tc.roundingMode != null ? tc.roundingMode : DEFAULT_ROUNDING_MODE,
strokeWidthPixels: this.strokeWidthPixels,
haloExtraPixels: this.haloExtraPixels,
labelFontSize: this.labelFontSize,
};
}
setStrokeWidthPixels(n) {
this.strokeWidthPixels = Math.max(1, parseInt(n, 10) || 1);
this._refreshItemSegments();
this._writeRulerDataToItem();
if (this.item) this.emitItemEvent('item-updated', { item: this.item, tool: this, reason: 'ruler-settings' });
}
setHaloExtraPixels(n) {
this.haloExtraPixels = Math.max(0, parseInt(n, 10) || 0);
this._refreshItemSegments();
this._writeRulerDataToItem();
if (this.item) this.emitItemEvent('item-updated', { item: this.item, tool: this, reason: 'ruler-settings' });
}
setLabelFontSize(n) {
const v = parseInt(n, 10);
this.labelFontSize = (v >= 6 && v <= 72) ? v : 12;
this._refreshItemSegments();
this._writeRulerDataToItem();
if (this.item) this.emitItemEvent('item-updated', { item: this.item, tool: this, reason: 'ruler-settings' });
}
setDecimals(n) {
const v = clampDecimals(parseInt(n, 10), DEFAULT_DECIMALS);
this.toolbarControl.decimals = v;
this.toolbarControl.decimalsInput.value = String(v);
this.toolbarControl.updateMeasurement(this._lastMeasurement?.p1 ?? null, this._lastMeasurement?.p2 ?? null, this._lastMeasurement?.distance ?? null);
this.refreshSegmentLabels();
this._writeRulerDataToItem();
}
setRoundingMode(mode) {
const m = normalizeRoundingMode(mode, DEFAULT_ROUNDING_MODE);
this.toolbarControl.roundingMode = m;
this.toolbarControl.roundingModeInput.value = m;
this.toolbarControl.updateMeasurement(this._lastMeasurement?.p1 ?? null, this._lastMeasurement?.p2 ?? null, this._lastMeasurement?.distance ?? null);
this.refreshSegmentLabels();
this._writeRulerDataToItem();
}
/**
* Re-apply halo style, path style, and labels for all segments in this.item.
* @private
*/
_refreshItemSegments() {
if (!this.item || !this.item.children.length) return;
this.item.children.forEach((child) => {
if (child instanceof paper.Group && child.children.length === 3) {
const haloPath = child.children[SEGMENT_HALO];
const path = child.children[SEGMENT_PATH];
if (haloPath instanceof paper.Path) this.applyHaloPathStyle(haloPath);
if (path instanceof paper.Path) this.applyPreviewOrPathStyle(path, false);
this._ensurePathLabel(child);
}
});
}
/**
* Emit ruler-measurement-updated on the project so listeners (e.g. measurements list) can update in real time.
* @private
* @param {paper.Item} [item=this.item] - The measurement Group to report.
*/
_emitMeasurementUpdated(item) {
item = item || this.item;
if (!item || !item.project) return;
const path = item.children?.length ? this._getPathFromSegmentChild(item.children[0]) : null;
const distance = this._lastMeasurement?.distance ?? (path && path.segments?.length >= 2 ? path.segments[0].point.getDistance(path.segments[1].point) : undefined);
item.project.emit('ruler-measurement-updated', {
item,
label: item.displayName || 'Measurement',
distance,
});
}
/**
* Refresh on-canvas label content/position for all segments (e.g. when units or unitsPerPixel change).
*/
refreshSegmentLabels() {
if (!this.item || !this.item.children.length) return;
this._writeRulerDataToItem();
this.item.children.forEach((child) => {
if (child instanceof paper.Group && child.children.length === 3) {
this._ensurePathLabel(child);
}
});
this._emitMeasurementUpdated();
if (this.item) this.emitItemEvent('item-updated', { item: this.item, tool: this, reason: 'ruler-settings' });
}
/**
* Position crosshair at point (project space). Uses view to keep crosshair at 8 view pixels.
* @param {paper.Point} point - position in project/view space (e.g. ev.original.point)
*/
setCrosshairPosition(point) {
const view = this.tool.view;
const pt = view.projectToView(point);
const half = CROSSHAIR_SIZE_PX;
const left = view.viewToProject(new paper.Point(pt.x - half, pt.y));
const right = view.viewToProject(new paper.Point(pt.x + half, pt.y));
const top = view.viewToProject(new paper.Point(pt.x, pt.y - half));
const bottom = view.viewToProject(new paper.Point(pt.x, pt.y + half));
const [h1, h2, v1, v2] = this._crosshair.children;
h1.segments[0].point = left;
h2.segments[0].point = left;
h1.segments[1].point = right;
h2.segments[1].point = right;
v1.segments[0].point = top;
v2.segments[0].point = top;
v1.segments[1].point = bottom;
v2.segments[1].point = bottom;
}
/**
* Apply zoom-independent stroke and optional preview styling to a path.
* @param {paper.Path} path
* @param {boolean} [isPreview=false]
*/
applyPreviewOrPathStyle(path, isPreview = false) {
const z = this.getZoomFactor();
path.strokeWidth = this.strokeWidthPixels / z;
path.rescale = { strokeWidth: this.strokeWidthPixels };
const firstPath = this.item && this.item.children.length
? this._getPathFromSegmentChild(this.item.children[0])
: null;
path.strokeColor = firstPath ? firstPath.strokeColor : new paper.Color('black');
path.strokeCap = 'round';
path.strokeJoin = 'round';
if (isPreview) {
path.dashArray = [6, 6];
path.opacity = 0.8;
}
}
/**
* Apply white halo style to a path (for contrast border behind main path).
* @private
* @param {paper.Path} haloPath - path to style as white halo
*/
applyHaloPathStyle(haloPath) {
const z = this.getZoomFactor();
const haloWidthPixels = this.strokeWidthPixels + this.haloExtraPixels;
haloPath.strokeWidth = haloWidthPixels / z;
haloPath.rescale = { strokeWidth: haloWidthPixels };
haloPath.strokeColor = 'white';
haloPath.strokeCap = 'round';
haloPath.strokeJoin = 'round';
}
/**
* Build a segment group with exactly 3 children: halo, path, labelGroup ([strokeLabel, fillLabel]).
* Labels are in a group that counter-rotates with view (upright like PointText).
* Caller must add the group to the project before calling _ensurePathLabel for correct label placement.
* @private
* @param {paper.Point} p1 - segment start
* @param {paper.Point} p2 - segment end
* @param {Object} [options] - `preview`: true for dashed path
* @returns {paper.Group}
*/
buildSegmentGroup(p1, p2, options = {}) {
const isPreview = !!options.preview;
const haloPath = new paper.Path([p1.clone(), p2.clone()]);
this.applyHaloPathStyle(haloPath);
const path = new paper.Path([p1.clone(), p2.clone()]);
this.applyPreviewOrPathStyle(path, isPreview);
const distance = p1.getDistance(p2);
const formatted = this.toolbarControl.formatDistance(distance);
const fillColor = path.strokeColor || new paper.Color('black');
const midpoint = p1.add(p2).divide(2);
const strokeLabel = new paper.PointText({
point: new paper.Point(0, 0),
content: formatted,
fontSize: this.labelFontSize,
fillColor: null,
strokeColor: 'white',
justification: 'center',
});
strokeLabel.rescale = {
fontSize: (z) => this.labelFontSize / z,
strokeWidth: (z) => RULER_LABEL_STROKE_PX / z,
};
const fillLabel = new paper.PointText({
point: new paper.Point(0, 0),
content: formatted,
fontSize: this.labelFontSize,
fillColor,
justification: 'center',
});
fillLabel.rescale = { fontSize: (z) => this.labelFontSize / z };
const labelGroup = new paper.Group();
labelGroup.pivot = new paper.Point(0, 0);
labelGroup.applyMatrix = true;
labelGroup.position = midpoint.clone();
labelGroup.addChild(strokeLabel);
labelGroup.addChild(fillLabel);
const group = new paper.Group();
group.addChild(haloPath);
group.addChild(path);
group.addChild(labelGroup);
return group;
}
/**
* Commit a two-point segment to the current item and clear placement state.
* @param {paper.Point} p1
* @param {paper.Point} p2
*/
commitRulerSegment(p1, p2) {
if (p1.getDistance(p2) < ZERO_LENGTH_EPSILON) return;
if (!this._ensureItemForDrawing()) return;
const hadSegmentsBefore = this.item.children.length > 0;
const segmentGroup = this.buildSegmentGroup(p1, p2, { preview: false });
this.item.addChild(segmentGroup);
this._writeRulerDataToItem();
this._ensurePathLabel(segmentGroup);
// Only the ruler line (path) gets selection style; not the segment group or labels
const parentSelected = this.item.selected;
const segPath = segmentGroup.children.length > SEGMENT_PATH ? segmentGroup.children[SEGMENT_PATH] : null;
if (segPath) segPath.selected = parentSelected;
this._clearPlacementState();
const distance = p1.getDistance(p2);
this._lastMeasurement = { p1, p2, distance };
this.toolbarControl.updateMeasurement(p1, p2, distance);
this.mode = 'modifying';
this._crosshair.visible = false;
if (this.toolbarControl.updateInstructions) {
this.toolbarControl.updateInstructions('modifying');
}
this._emitMeasurementUpdated();
if (!hadSegmentsBefore) this.emitItemEvent('item-created', { item: this.item, tool: this });
else this.emitItemEvent('item-updated', { item: this.item, tool: this, subpathAdded: true, subpath: segmentGroup });
}
onMouseDown(ev) {
if (this.mode === 'modifying') {
const tol = this.getTolerance(5);
const segHit = this.item.hitTest(ev.point, { fill: false, stroke: false, segments: true, tolerance: tol });
if (segHit && segHit.type === 'segment') {
const hitPath = segHit.segment.path;
this._editPath = (hitPath.parent instanceof paper.Group ? this._getPathFromSegmentChild(hitPath.parent) : null) || hitPath;
this._editSegmentIndex = this._editPath.segments.indexOf(segHit.segment);
this.mode = 'endpoint-drag';
return;
}
const strokeHit = this.item.hitTest(ev.point, { fill: false, stroke: true, segments: false, tolerance: tol });
if (strokeHit) {
const hitItem = strokeHit.item;
this._editPath = (hitItem.parent instanceof paper.Group ? this._getPathFromSegmentChild(hitItem.parent) : null) || hitItem;
this.mode = 'line-drag';
return;
}
return;
}
if (this._firstPoint === null) {
if (!this._ensureItemForDrawing()) return;
this._firstPoint = ev.point.clone();
this._previewSegmentGroup = this.buildSegmentGroup(this._firstPoint.clone(), this._firstPoint.clone(), { preview: true });
this.drawingGroup.removeChildren();
this.drawingGroup.addChild(this._previewSegmentGroup);
const previewPath = this._previewSegmentGroup.children.length > SEGMENT_PATH ? this._previewSegmentGroup.children[SEGMENT_PATH] : null;
if (previewPath) previewPath.selected = true;
this._didDrag = false;
this._drawingItem = this.item;
this.toolbarControl.updateMeasurement(this._firstPoint, this._firstPoint, 0);
return;
}
if (!this._didDrag) {
this.commitRulerSegment(this._firstPoint, ev.point);
this._clearPlacementState();
}
}
onMouseMove(ev) {
this.setCrosshairPosition(ev.original.point);
if (this.mode === 'modifying' && this.item) {
const tol = this.getTolerance(5);
const segHit = this.item.hitTest(ev.point, { fill: false, stroke: false, segments: true, tolerance: tol });
if (segHit) {
this.project.overlay.addClass('rectangle-tool-resize');
this.project.overlay.removeClass('rectangle-tool-move');
} else {
const strokeHit = this.item.hitTest(ev.point, { fill: false, stroke: true, segments: false, tolerance: tol });
if (strokeHit) {
this.project.overlay.addClass('rectangle-tool-move');
this.project.overlay.removeClass('rectangle-tool-resize');
} else {
this.project.overlay.removeClass('rectangle-tool-resize', 'rectangle-tool-move');
}
}
}
if (this._firstPoint !== null && this._previewSegmentGroup) {
const path = this._previewSegmentGroup.children[SEGMENT_PATH];
const haloPath = this._previewSegmentGroup.children[SEGMENT_HALO];
if (path) path.segments[1].point = ev.point.clone();
if (haloPath && haloPath.segments.length === 2) haloPath.segments[1].point = ev.point.clone();
this._ensurePathLabel(this._previewSegmentGroup);
const d = this._firstPoint.getDistance(ev.point);
this.toolbarControl.updateMeasurement(this._firstPoint, ev.point, d);
} else {
if (this._lastMeasurement.p1 && this._lastMeasurement.p2) {
this.toolbarControl.updateMeasurement(
this._lastMeasurement.p1,
this._lastMeasurement.p2,
this._lastMeasurement.distance
);
} else {
this.toolbarControl.updateMeasurement(null, null, null);
}
}
}
onMouseDrag(ev) {
if (this.mode === 'endpoint-drag' && this._editPath) {
this._editPath.segments[this._editSegmentIndex].point = ev.point.clone();
if (this._editPath.parent instanceof paper.Group && (this._editPath.parent.children.length === 3 || this._editPath.parent.children.length === 4)) {
const haloPath = this._editPath.parent.children[SEGMENT_HALO];
if (haloPath instanceof paper.Path && haloPath.segments.length === this._editPath.segments.length) {
haloPath.segments[this._editSegmentIndex].point = ev.point.clone();
}
}
const p1 = this._editPath.segments[0].point;
const p2 = this._editPath.segments[1].point;
const distance = p1.getDistance(p2);
this._lastMeasurement = { p1, p2, distance };
this.toolbarControl.updateMeasurement(p1, p2, distance);
if (this._editPath.parent instanceof paper.Group) {
this._ensurePathLabel(this._editPath.parent);
}
return;
}
if (this.mode === 'line-drag') {
this.item.translate(ev.delta);
return;
}
this._didDrag = true;
this.setCrosshairPosition(ev.original.point);
if (this._firstPoint !== null && this._previewSegmentGroup) {
const path = this._previewSegmentGroup.children[SEGMENT_PATH];
const haloPath = this._previewSegmentGroup.children[SEGMENT_HALO];
if (path) path.segments[1].point = ev.point.clone();
if (haloPath && haloPath.segments.length === 2) haloPath.segments[1].point = ev.point.clone();
this._ensurePathLabel(this._previewSegmentGroup);
const d = this._firstPoint.getDistance(ev.point);
this.toolbarControl.updateMeasurement(this._firstPoint, ev.point, d);
}
}
onMouseUp(ev) {
if (this.mode === 'endpoint-drag' || this.mode === 'line-drag') {
this.mode = 'modifying';
this._editPath = null;
this._editSegmentIndex = null;
this._emitMeasurementUpdated();
if (this.item) this.emitItemEvent('item-updated', { item: this.item, tool: this });
return;
}
if (this._firstPoint !== null && this._didDrag) {
this.commitRulerSegment(this._firstPoint, ev.point);
this._clearPlacementState();
}
this._didDrag = false;
}
}
export { RulerTool };
/**
* Toolbar for RulerTool: line width (px) number input and measurement display (P1, P2, distance).
* @extends AnnotationUIToolbarBase
* @class
* @memberof OSDPaperjsAnnotation.RulerTool
*/
class RulerToolbar extends AnnotationUIToolbarBase {
constructor(rulerTool) {
super(rulerTool);
this.rulerTool = rulerTool;
this.labelUnit = 'px';
this.unitsPerPixel = 1;
this.decimals = DEFAULT_DECIMALS;
this.roundingMode = DEFAULT_ROUNDING_MODE;
const i = makeFaIcon('fa-ruler');
this.button.configure(i, 'Ruler Tool');
const fdd = document.createElement('div');
fdd.classList.add('dropdown', 'ruler-toolbar');
fdd.setAttribute('data-tool', 'ruler');
this.dropdown.appendChild(fdd);
// Default row: "Length: _____" + expand/collapse button (single line when collapsed)
const lengthRow = document.createElement('div');
lengthRow.classList.add('ruler-length-row');
fdd.appendChild(lengthRow);
const lengthLabel = document.createElement('span');
lengthLabel.textContent = 'Length: ';
lengthRow.appendChild(lengthLabel);
this.lengthEl = document.createElement('span');
this.lengthEl.className = 'ruler-length-value';
lengthRow.appendChild(this.lengthEl);
const toggleBtn = document.createElement('button');
toggleBtn.type = 'button';
toggleBtn.classList.add('ruler-details-toggle');
toggleBtn.setAttribute('aria-label', 'Toggle details');
this._detailsExpanded = false;
this._chevronDown = makeFaIcon('fa-caret-down');
this._chevronUp = makeFaIcon('fa-caret-up');
toggleBtn.appendChild(this._chevronDown);
lengthRow.appendChild(toggleBtn);
toggleBtn.addEventListener('click', () => {
this._detailsExpanded = !this._detailsExpanded;
this.detailsPanel.hidden = !this._detailsExpanded;
toggleBtn.replaceChildren(this._detailsExpanded ? this._chevronUp : this._chevronDown);
const tk = rulerTool?.project?.paperScope?.annotationToolkit;
if (tk && tk._emitIntegrationEvent) tk._emitIntegrationEvent('ruler-details-open-changed', { open: this._detailsExpanded }, { tool: rulerTool });
});
// Details panel (collapsible); instructions live here so collapsed = one line only
this.detailsPanel = document.createElement('div');
this.detailsPanel.classList.add('ruler-details-panel');
this.detailsPanel.hidden = true;
fdd.appendChild(this.detailsPanel);
const detailsContent = document.createElement('div');
detailsContent.classList.add('ruler-details-content');
this.detailsPanel.appendChild(detailsContent);
this.instructions = document.createElement('span');
this.instructions.className = 'ruler-instructions';
detailsContent.appendChild(this.instructions);
const addReadonlyRow = (labelText) => {
const row = document.createElement('div');
row.classList.add('ruler-detail-row', 'ruler-detail-readonly');
const lab = document.createElement('span');
lab.classList.add('ruler-detail-label');
lab.textContent = labelText;
const val = document.createElement('span');
val.classList.add('ruler-detail-value');
val.textContent = '—';
row.appendChild(lab);
row.appendChild(val);
detailsContent.appendChild(row);
return val;
};
this.p1ValueEl = addReadonlyRow('P1');
this.p2ValueEl = addReadonlyRow('P2');
const addRow = (labelText, inputEl, inputId, title) => {
const row = document.createElement('div');
row.classList.add('ruler-detail-row');
const lab = document.createElement('label');
lab.htmlFor = inputId;
lab.textContent = labelText;
if (title) lab.title = title;
row.appendChild(lab);
inputEl.id = inputId;
row.appendChild(inputEl);
detailsContent.appendChild(row);
};
this.widthInput = document.createElement('input');
this.widthInput.type = 'number';
this.widthInput.min = 1;
this.widthInput.value = 2;
this.widthInput.classList.add('ruler-width-input');
this.widthInput.addEventListener('change', () => {
rulerTool.setStrokeWidthPixels(this.widthInput.value);
const tk = rulerTool?.project?.paperScope?.annotationToolkit;
if (tk && tk._emitIntegrationEvent) tk._emitIntegrationEvent('ruler-setting-changed', { key: 'widthPixels', value: Number(this.widthInput.value) }, { tool: rulerTool });
});
addRow('Line width', this.widthInput, 'ruler-line-width', 'Line width in screen pixels');
this.haloInput = document.createElement('input');
this.haloInput.type = 'number';
this.haloInput.min = 0;
this.haloInput.value = 2;
this.haloInput.classList.add('ruler-halo-input');
this.haloInput.addEventListener('change', () => {
rulerTool.setHaloExtraPixels(this.haloInput.value);
const tk = rulerTool?.project?.paperScope?.annotationToolkit;
if (tk && tk._emitIntegrationEvent) tk._emitIntegrationEvent('ruler-setting-changed', { key: 'haloPixels', value: Number(this.haloInput.value) }, { tool: rulerTool });
});
addRow('Halo', this.haloInput, 'ruler-halo', 'Padding / halo width in screen pixels');
this.fontSizeInput = document.createElement('input');
this.fontSizeInput.type = 'number';
this.fontSizeInput.min = 6;
this.fontSizeInput.max = 72;
this.fontSizeInput.value = 12;
this.fontSizeInput.classList.add('ruler-font-size-input');
this.fontSizeInput.addEventListener('change', () => {
rulerTool.setLabelFontSize(this.fontSizeInput.value);
const tk = rulerTool?.project?.paperScope?.annotationToolkit;
if (tk && tk._emitIntegrationEvent) tk._emitIntegrationEvent('ruler-setting-changed', { key: 'fontSize', value: Number(this.fontSizeInput.value) }, { tool: rulerTool });
});
addRow('Font size', this.fontSizeInput, 'ruler-font-size');
this.unitsInput = document.createElement('input');
this.unitsInput.type = 'text';
this.unitsInput.value = 'px';
this.unitsInput.classList.add('ruler-units-input');
this.unitsInput.addEventListener('change', () => {
this.labelUnit = (this.unitsInput.value || 'px').trim();
this.updateMeasurement(rulerTool._lastMeasurement?.p1 ?? null, rulerTool._lastMeasurement?.p2 ?? null, rulerTool._lastMeasurement?.distance ?? null);
rulerTool.refreshSegmentLabels();
const tk = rulerTool?.project?.paperScope?.annotationToolkit;
if (tk && tk._emitIntegrationEvent) tk._emitIntegrationEvent('ruler-setting-changed', { key: 'units', value: this.labelUnit }, { tool: rulerTool });
});
addRow('Units', this.unitsInput, 'ruler-units');
this.unitsPerPixelInput = document.createElement('input');
this.unitsPerPixelInput.type = 'number';
this.unitsPerPixelInput.min = 0.0001;
this.unitsPerPixelInput.step = 'any';
this.unitsPerPixelInput.value = 1;
this.unitsPerPixelInput.classList.add('ruler-units-per-pixel-input');
this.unitsPerPixelInput.addEventListener('change', () => {
const v = parseFloat(this.unitsPerPixelInput.value);
this.unitsPerPixel = (v > 0 && Number.isFinite(v)) ? v : 1;
this.updateMeasurement(rulerTool._lastMeasurement?.p1 ?? null, rulerTool._lastMeasurement?.p2 ?? null, rulerTool._lastMeasurement?.distance ?? null);
rulerTool.refreshSegmentLabels();
const tk = rulerTool?.project?.paperScope?.annotationToolkit;
if (tk && tk._emitIntegrationEvent) tk._emitIntegrationEvent('ruler-setting-changed', { key: 'unitsPerPixel', value: this.unitsPerPixel }, { tool: rulerTool });
});
addRow('Scale', this.unitsPerPixelInput, 'ruler-units-per-pixel', 'Units per pixel (display units per paper unit)');
this.decimalsInput = document.createElement('input');
this.decimalsInput.type = 'number';
this.decimalsInput.min = 0;
this.decimalsInput.step = 1;
this.decimalsInput.value = DEFAULT_DECIMALS;
this.decimalsInput.classList.add('ruler-decimals-input');
this.decimalsInput.addEventListener('change', () => {
rulerTool.setDecimals(this.decimalsInput.value);
const tk = rulerTool?.project?.paperScope?.annotationToolkit;
if (tk && tk._emitIntegrationEvent) tk._emitIntegrationEvent('ruler-setting-changed', { key: 'decimals', value: Number(this.decimalsInput.value) }, { tool: rulerTool });
});
addRow('Decimals', this.decimalsInput, 'ruler-decimals');
this.roundingModeInput = document.createElement('select');
this.roundingModeInput.classList.add('ruler-rounding-mode-input');
const roundOption = document.createElement('option');
roundOption.value = 'round';
roundOption.textContent = 'round';
const truncateOption = document.createElement('option');
truncateOption.value = 'truncate';
truncateOption.textContent = 'truncate';
this.roundingModeInput.append(roundOption, truncateOption);
this.roundingModeInput.value = DEFAULT_ROUNDING_MODE;
this.roundingModeInput.addEventListener('change', () => {
rulerTool.setRoundingMode(this.roundingModeInput.value);
const tk = rulerTool?.project?.paperScope?.annotationToolkit;
if (tk && tk._emitIntegrationEvent) tk._emitIntegrationEvent('ruler-setting-changed', { key: 'roundingMode', value: this.roundingModeInput.value }, { tool: rulerTool });
});
addRow('Round', this.roundingModeInput, 'ruler-rounding-mode', 'Rounding mode');
rulerTool.setStrokeWidthPixels(2);
rulerTool.setHaloExtraPixels(2);
rulerTool.setLabelFontSize(12);
this.updateMeasurement(null, null, null);
this.updateInstructions(null);
}
updateInstructions(mode) {
const text = mode === 'creating' || !mode
? 'Click to set first point, then click or drag to complete.'
: 'Drag endpoints to resize, drag line to move.';
this.instructions.textContent = text;
}
formatNum(n) {
if (n == null || typeof n !== 'number') return '—';
return formatDecimal(n, this.decimals, this.roundingMode);
}
formatDistance(distancePaper) {
if (distancePaper == null || typeof distancePaper !== 'number') return '—';
const value = distancePaper * this.unitsPerPixel;
return this.formatNum(value) + ' ' + this.labelUnit;
}
updateMeasurement(p1, p2, distance) {
this.lengthEl.textContent = distance != null ? this.formatDistance(distance) : '—';
const fmt = (p) => (p ? `(${this.formatNum(p.x)}, ${this.formatNum(p.y)})` : '—');
this.p1ValueEl.textContent = p1 != null ? fmt(p1) : '—';
this.p2ValueEl.textContent = p2 != null ? fmt(p2) : '—';
}
isEnabledForMode(mode) {
return ['new', 'LineString', 'MultiLineString', 'MultiLineString:Measurement'].includes(mode);
}
}