@itwin/core-markup
Version:
iTwin.js markup package
470 lines • 25 kB
JavaScript
"use strict";
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
/** @packageDocumentation
* @module MarkupApp
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.Markup = exports.MarkupApp = void 0;
const core_bentley_1 = require("@itwin/core-bentley");
const core_geometry_1 = require("@itwin/core-geometry");
const core_common_1 = require("@itwin/core-common");
const core_frontend_1 = require("@itwin/core-frontend");
const svg_js_1 = require("@svgdotjs/svg.js");
const redlineTool = require("./RedlineTool");
const SelectTool_1 = require("./SelectTool");
const textTool = require("./TextEdit");
const Undo_1 = require("./Undo");
/**
* The main object for the Markup package. It is a singleton that stores the state of the Markup application.
* It has only static members and methods. Applications may customize and control the behavior of the Markup by
* setting members of [[MarkupApp.props]]. When [[MarkupApp.start]] is first called, it registers a set of "Markup.xxx"
* tools that may be invoked from UI controls.
* @public
*/
class MarkupApp {
/** the current Markup being created */
static markup;
/** The namespace for the Markup tools */
static namespace;
/** By setting members of this object, applications can control the appearance and behavior of various parts of MarkupApp. */
static props = {
/** the UI controls displayed on Elements by the Select Tool to allow users to modify them. */
handles: {
/** The diameter of the circles for the handles. */
size: 10,
/** The attributes of the stretch handles */
stretch: { "fill-opacity": .85, "stroke": "black", "fill": "white" },
/** The attributes of the line that connects the top-center stretch handle to the rotate handle. */
rotateLine: { "stroke": "grey", "fill-opacity": .85 },
/** The attributes of the rotate handle. */
rotate: { "cursor": `url(${core_frontend_1.IModelApp.publicPath}Markup/rotate.png) 12 12, auto`, "fill-opacity": .85, "stroke": "black", "fill": "lightBlue" },
/** The attributes of box around the element. */
moveOutline: { "cursor": "move", "stroke-dasharray": "6,6", "fill": "none", "stroke-opacity": .85, "stroke": "white" },
/** The attributes of box that provides the move cursor. */
move: { "cursor": "move", "opacity": 0, "stroke-width": 10, "stroke": "white" },
/** The attributes of handles on the vertices of lines. */
vertex: { "cursor": `url(${core_frontend_1.IModelApp.publicPath}cursors/crosshair.cur), crosshair`, "fill-opacity": .85, "stroke": "black", "fill": "white" },
},
/** properties for providing feedback about selected elements. */
hilite: {
/** the color of selected elements */
color: "magenta",
/** the color of an element as the cursor passes over it */
flash: "cyan",
},
/** optionally, show a drop-shadow behind all markup elements. */
dropShadow: {
/** if false, no drop shadow */
enable: true,
/** the attributes of the drop shadow. See https://developer.mozilla.org/en-US/docs/Web/SVG/Element/feDropShadow */
attr: {
"stdDeviation": 2,
"dx": 1.2,
"dy": 1.4,
"flood-color": "#1B3838",
},
},
/** The "active placement" parameters. New elements are created with these parameters, so UI controls should set them. */
active: {
/** the CSS style properties of new text elements. */
text: {
"font-family": "sans-serif",
"font-size": "30px",
"stroke": "none",
"fill": "red",
},
/** the CSS style properties of new elements. */
element: {
"stroke": "red",
"stroke-opacity": 0.8,
"stroke-width": 3,
"stroke-dasharray": 0,
"stroke-linecap": "round",
"stroke-linejoin": "round",
"fill": "blue",
"fill-opacity": 0.2,
},
arrow: {
length: 7,
width: 6,
},
cloud: {
path: "M3.0,2.5 C3.9,.78 5.6,-.4 8.1,1.0 C9.1,0 11.3,-.2 12.5,.5 C14.2,-.5 17,.16 17.9,2.5 C21,3 20.2,7.3 17.6,7.5 C16.5,9.2 14.4,9.8 12.7,8.9 C11.6,10 9.5,10.3 8.1,9.4 C5.7,10.8 3.3,9.4 2.6,7.5 C-.9,7.7 .6,1.7 3.0,2.5z",
},
},
/** Values for placing and editing Text. */
text: {
/** A default string for the Markup.Text.Place command. Applications can turn this off, or supply the user's initials, for example. */
startValue: "Note: ",
/** Parameters for the size and appearance of the text editor */
edit: {
background: "blanchedalmond",
/** Starting size, will be updated if user stretches the box */
size: { width: "25%", height: "4em" },
/** font size of the text editor */
fontSize: "14pt",
/** A background box drawn around text so user can tell what's being selected */
textBox: { "fill": "lightGrey", "fill-opacity": .1, "stroke-opacity": .85, "stroke": "lightBlue" },
},
},
/** Used to draw the border outline around the view while it is being marked up so the user can tell Markup is active */
borderOutline: {
"stroke": "gold",
"stroke-width": 6,
"stroke-opacity": 0.4,
"fill": "none",
},
/** Used to draw the border corner symbols for the view while it is being marked up so the user can tell Markup is active */
borderCorners: {
"stroke": "black",
"stroke-width": 2,
"stroke-opacity": 0.2,
"fill": "gold",
"fill-opacity": 0.2,
},
/** Determines what is returned by MarkupApp.stop */
result: {
/** The format for the image data. */
imageFormat: "image/png",
/** If true, the markup graphics will be imprinted in the returned image. */
imprintSvgOnImage: true,
/** the maximum width for the returned image. If the source view width is larger than this, it will be scaled down to this size. */
maxWidth: 2048,
},
};
static _saveDefaultToolId = "";
/** @internal */
static screenToVbMtx() {
const matrix = this.markup?.svgMarkup?.screenCTM().inverse();
return (undefined !== matrix ? matrix : new svg_js_1.Matrix());
}
/** @internal */
static getVpToScreenMtx() {
const rect = this.markup.markupDiv.getBoundingClientRect();
return (new svg_js_1.Matrix()).translateO(rect.left, rect.top);
}
/** @internal */
static getVpToVbMtx() { return this.getVpToScreenMtx().lmultiplyO(this.screenToVbMtx()); }
/** @internal */
static convertVpToVb(pt) {
const pt0 = new svg_js_1.Point(pt.x, pt.y);
pt0.transformO(this.getVpToVbMtx());
return new core_geometry_1.Point3d(pt0.x, pt0.y, 0);
}
/** determine whether there's a markup session currently active */
static get isActive() { return undefined !== this.markup; }
static markupSelectToolId = "Markup.Select";
static createMarkup(view, markupData) { return new Markup(view, markupData); }
static lockViewportSize(view, markupData) {
const parentDiv = view.vpDiv;
const rect = parentDiv.getBoundingClientRect();
let width = rect.width;
let height = rect.height;
if (markupData) {
const aspect = markupData.rect.height / markupData.rect.width;
if ((width * aspect) > height)
width = Math.floor(height / aspect);
else
height = Math.floor(width * aspect);
}
const style = parentDiv.style;
style.width = `${width}px`;
style.height = `${height}px`;
}
/** @internal */
static getActionName(action) { return core_frontend_1.IModelApp.localization.getLocalizedString(`${this.namespace}:actions.${action}`); }
/** Start a markup session */
static async start(view, markupData) {
if (this.markup)
return; // a markup session is already active.
await this.initialize();
// first, lock the viewport to its current size while the markup session is running
this.lockViewportSize(view, markupData);
this.markup = this.createMarkup(view, markupData); // start a markup against the provided view.
if (!this.markup.svgMarkup) {
core_frontend_1.ScreenViewport.setToParentSize(this.markup.vp.vpDiv);
this.markup.markupDiv.remove();
return;
}
core_frontend_1.IModelApp.toolAdmin.markupView = view; // so viewing tools won't operate on the view.
// set the markup Select tool as the default tool and start it, saving current default tool
this._saveDefaultToolId = core_frontend_1.IModelApp.toolAdmin.defaultToolId;
core_frontend_1.IModelApp.toolAdmin.defaultToolId = this.markupSelectToolId;
return core_frontend_1.IModelApp.toolAdmin.startDefaultTool();
}
/** Read the result of a Markup session, then stop the session.
* @note see [MarkupApp.props.result] for options.
*/
static async stop() {
await core_frontend_1.IModelApp.toolAdmin.startDefaultTool(); // Make sure current markup tool exits first...
const data = await this.readMarkup();
if (!this.markup)
return data;
// restore original size for vp.
core_frontend_1.ScreenViewport.setToParentSize(this.markup.vp.vpDiv);
core_frontend_1.IModelApp.toolAdmin.markupView = undefined; // re-enable viewing tools for the view being marked-up
this.markup.destroy();
this.markup = undefined;
// now restore the default tool and start it
core_frontend_1.IModelApp.toolAdmin.defaultToolId = this._saveDefaultToolId;
this._saveDefaultToolId = "";
await core_frontend_1.IModelApp.toolAdmin.startDefaultTool();
return data;
}
/** Call this method to initialize the Markup system.
* It asynchronously loads the MarkupTools namespace for the prompts and tool names for the Markup system, and
* also registers all of the Markup tools.
* @return a Promise that may be awaited to ensure that the MarkupTools namespace had been loaded.
* @note This method is automatically called every time you call [[start]]. Since the Markup tools cannot
* start unless there is a Markup active, there's really no need to call this method directly.
* The only virtue in doing so is to pre-load the Markup namespace if you have an opportunity to do so earlier in your
* startup code.
* @note This method may be called multiple times, but only the first time initiates the loading/registering. Subsequent
* calls return the same Promise.
*/
static async initialize() {
if (undefined === this.namespace) { // only need to do this once
this.namespace = "MarkupTools";
const namespacePromise = core_frontend_1.IModelApp.localization.registerNamespace(this.namespace);
core_frontend_1.IModelApp.tools.register(SelectTool_1.SelectTool, this.namespace);
core_frontend_1.IModelApp.tools.registerModule(redlineTool, this.namespace);
core_frontend_1.IModelApp.tools.registerModule(textTool, this.namespace);
return namespacePromise;
}
return core_frontend_1.IModelApp.localization.getNamespacePromise(this.namespace); // so caller can make sure localized messages are ready.
}
/** convert the current markup SVG into a string, but don't include decorations or dynamics
* @internal
*/
static readMarkupSvg() {
const markup = this.markup;
if (!markup || !markup.svgContainer)
return undefined;
markup.svgDecorations.remove(); // we don't want the decorations or dynamics to be included
markup.svgDynamics.remove();
void core_frontend_1.IModelApp.toolAdmin.startDefaultTool();
return markup.svgContainer.svg(); // string-ize the SVG data
}
/** convert the current markup SVG into a string (after calling readMarkupSvg) making sure width and height are specified.
* @internal
*/
static readMarkupSvgForDrawImage() {
const markup = this.markup;
if (!markup || !markup.svgContainer)
return undefined;
// Firefox requires width and height on top-level svg or drawImage does nothing, passing width/height to drawImage doesn't work.
const rect = markup.markupDiv.getBoundingClientRect();
markup.svgContainer.width(rect.width);
markup.svgContainer.height(rect.height);
return markup.svgContainer.svg(); // string-ize the SVG data
}
/** @internal */
static async readMarkup() {
const result = this.props.result;
let canvas = this.markup.vp.readImageToCanvas({ omitCanvasDecorations: false });
let svg, image;
try {
svg = this.readMarkupSvg(); // read the current svg data for the markup
const svgForImage = (svg && result.imprintSvgOnImage ? this.readMarkupSvgForDrawImage() : undefined);
if (svgForImage) {
const svgImage = await (0, core_frontend_1.imageElementFromImageSource)(new core_common_1.ImageSource(svgForImage, core_common_1.ImageSourceFormat.Svg));
canvas.getContext("2d").drawImage(svgImage, 0, 0); // draw markup svg onto view's canvas2d
}
// is the source view too wide? If so, we need to scale the image down.
if (canvas.width > result.maxWidth) {
// yes, we have to scale it down, create a new canvas and set the new canvas' size
const newCanvas = document.createElement("canvas");
newCanvas.width = result.maxWidth;
newCanvas.height = canvas.height * (result.maxWidth / canvas.width);
newCanvas.getContext("2d").drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, newCanvas.width, newCanvas.height);
canvas = newCanvas; // return the image from adjusted canvas, not view canvas.
}
// return the markup data to be saved by the application.
image = (!result.imageFormat ? undefined : canvas.toDataURL(result.imageFormat));
}
catch (e) {
core_bentley_1.Logger.logError(`${core_frontend_1.FrontendLoggerCategory.Package}.markup`, "Error creating image from svg", core_bentley_1.BentleyError.getErrorProps(e));
}
return { rect: { width: canvas.width, height: canvas.height }, svg, image };
}
/** @internal */
static markupPrefix = "markup-";
/** @internal */
static get dropShadowId() { return `${this.markupPrefix}dropShadow`; } // this is referenced in the markup Svg to apply the drop-shadow filter to all markup elements.
/** @internal */
static get cornerId() { return `${this.markupPrefix}photoCorner`; }
/** @internal */
static get containerClass() { return `${this.markupPrefix}container`; }
/** @internal */
static get dynamicsClass() { return `${this.markupPrefix}dynamics`; }
/** @internal */
static get decorationsClass() { return `${this.markupPrefix}decorations`; }
/** @internal */
static get markupSvgClass() { return `${this.markupPrefix}svg`; }
/** @internal */
static get boxedTextClass() { return `${this.markupPrefix}boxedText`; }
/** @internal */
static get textClass() { return `${this.markupPrefix}text`; }
/** @internal */
static get stretchHandleClass() { return `${this.markupPrefix}stretchHandle`; }
/** @internal */
static get rotateLineClass() { return `${this.markupPrefix}rotateLine`; }
/** @internal */
static get rotateHandleClass() { return `${this.markupPrefix}rotateHandle`; }
/** @internal */
static get vertexHandleClass() { return `${this.markupPrefix}vertexHandle`; }
/** @internal */
static get moveHandleClass() { return `${this.markupPrefix}moveHandle`; }
/** @internal */
static get textOutlineClass() { return `${this.markupPrefix}textOutline`; }
/** @internal */
static get textEditorClass() { return `${this.markupPrefix}textEditor`; }
}
exports.MarkupApp = MarkupApp;
const removeSvgNamespace = (svg) => {
svg.node.removeAttribute("xmlns:svgjs");
return svg;
};
const newSvgElement = (name) => (0, svg_js_1.adopt)((0, svg_js_1.create)(name));
/**
* The current markup being created/edited. Holds the SVG elements, plus the active [[MarkupTool]].
* When starting a Markup, a new Div is added as a child of the ScreenViewport's vpDiv.
* @public
*/
class Markup {
vp;
/** @internal */
markupDiv;
/** Support undo/redo of markup operations */
undo = new Undo_1.UndoManager();
/** The set of currently selected markup elements */
selected;
/** @internal */
svgContainer;
/** @internal */
svgMarkup;
/** @internal */
svgDynamics;
/** @internal */
svgDecorations;
/** create the drop-shadow filter in the Defs section of the supplied svg element */
createDropShadow(svg) {
const filter = (0, svg_js_1.SVG)(`#${MarkupApp.dropShadowId}`); // see if we already have one?
if (filter)
filter.remove(); // yes, remove it. This must be someone modifying the drop shadow properties
// create a new filter, and add it to the Defs of the supplied svg
svg.defs()
.add(newSvgElement("filter").id(MarkupApp.dropShadowId)
.add(newSvgElement("feDropShadow").attr(MarkupApp.props.dropShadow.attr)));
}
addNested(className) { return this.svgContainer.group().addClass(className); }
addBorder() {
const rect = this.svgContainer.viewbox();
const inset = MarkupApp.props.borderOutline["stroke-width"];
const cornerSize = inset * 6;
const cornerPts = [0, 0, cornerSize, 0, cornerSize * .7, cornerSize * .3, cornerSize * .3, cornerSize * .3, cornerSize * .3, cornerSize * .7, 0, cornerSize];
const decorations = this.svgDecorations;
const photoCorner = decorations.symbol().polygon(cornerPts).attr(MarkupApp.props.borderCorners).id(MarkupApp.cornerId);
const cornerGroup = decorations.group();
cornerGroup.rect(rect.width - inset, rect.height - inset).move(inset / 2, inset / 2).attr(MarkupApp.props.borderOutline);
cornerGroup.use(photoCorner);
cornerGroup.use(photoCorner).rotate(90).translate(rect.width - cornerSize, 0);
cornerGroup.use(photoCorner).rotate(180).translate(rect.width - cornerSize, rect.height - cornerSize);
cornerGroup.use(photoCorner).rotate(270).translate(0, rect.height - cornerSize);
}
/** Create a new Markup for the supplied ScreenViewport. Adds a new "overlay-markup" div into the "vpDiv"
* of the viewport.
* @note you must call destroy on this object at end of markup to remove the markup div.
*/
constructor(vp, markupData) {
this.vp = vp;
this.markupDiv = vp.addNewDiv("overlay-markup", true, 20); // this div goes on top of the canvas, but behind UI layers
const rect = this.markupDiv.getBoundingClientRect();
// First, see if there is a markup passed in as an argument
if (markupData && markupData.svg) {
this.markupDiv.innerHTML = markupData.svg; // make it a child of the markupDiv
this.svgContainer = (0, svg_js_1.SVG)(`.${MarkupApp.containerClass}`); // get it in svg.js format
this.svgMarkup = (0, svg_js_1.SVG)(`.${MarkupApp.markupSvgClass}`);
if (!this.svgContainer || !this.svgMarkup) // if either isn't present, its not a valid markup
return;
removeSvgNamespace(this.svgContainer); // the SVG call above adds this - remove it
this.svgMarkup.each(() => { }, true); // create an SVG.Element for each entry in the supplied markup.
}
else {
// create the container that will be returned as the "svg" data for this markup
this.svgContainer = (0, svg_js_1.SVG)().addTo(this.markupDiv).addClass(MarkupApp.containerClass).viewbox(0, 0, rect.width, rect.height);
removeSvgNamespace(this.svgContainer);
this.svgMarkup = this.addNested(MarkupApp.markupSvgClass);
}
if (MarkupApp.props.dropShadow.enable) {
this.createDropShadow(this.svgContainer);
this.svgContainer.attr("filter", `url(#${MarkupApp.dropShadowId})`);
}
/** add two nested groups for providing feedback during the markup session. These Svgs are removed before the data is returned. */
this.svgDynamics = this.addNested(MarkupApp.dynamicsClass); // only for tool dynamics of SVG graphics.
this.svgDecorations = this.addNested(MarkupApp.decorationsClass); // only for temporary decorations of SVG graphics.
this.addBorder();
this.selected = new SelectTool_1.MarkupSelected(this.svgDecorations);
}
/** Called when the Markup is destroyed */
destroy() { this.markupDiv.remove(); }
/** Turn on picking the markup elements in the markup view */
enablePick() { this.markupDiv.style.pointerEvents = "auto"; }
/** Turn off picking the markup elements in the markup view */
disablePick() { this.markupDiv.style.pointerEvents = "none"; }
/** Change the default cursor for the markup view */
setCursor(cursor) { this.markupDiv.style.cursor = cursor; }
/** Delete all the entries in the selection set, then empty it. */
deleteSelected() { this.selected.deleteAll(this.undo); }
/** Bring all the entries in the selection set to the front. */
bringToFront() { this.selected.reposition(MarkupApp.getActionName("toFront"), this.undo, (el) => el.front()); }
/** Send all the entries in the selection set to the back. */
sendToBack() { this.selected.reposition(MarkupApp.getActionName("toBack"), this.undo, (el) => el.back()); }
/** Group all the entries in the selection set, then select the group. */
groupSelected() {
if (undefined !== this.svgMarkup)
this.selected.groupAll(this.undo);
}
/** Ungroup all the group entries in the selection set. */
ungroupSelected() {
if (undefined !== this.svgMarkup)
this.selected.ungroupAll(this.undo);
}
/** Check if the supplied MarkupElement is a group of MarkupText and the MarkupText's outline Rect.
* @param el the markup element to check
* @returns true if boxed text
*/
isBoxedText(el) {
return el.type === "g" &&
el.node.classList.length > 0 &&
el.node.classList[0] === MarkupApp.boxedTextClass &&
el.children().length === 2;
}
/** Get an existing or create a new reusable symbol representing an arrow head.
* If a Marker for the supplied color and size already exists it is returned, otherwise a new Marker is created.
* @param color the arrow head color
* @param length the arrow head length
* @param width the arrow head width
* @note Flashing doesn't currently affect markers, need support for "context-stroke" and "context-fill". For now encode color in name...
*/
createArrowMarker(color, length, width) {
length = Math.ceil(length); // Don't allow "." in selector string...
width = Math.ceil(width);
const arrowMarkerId = `ArrowMarker${length}x${width}-${color}`;
let marker = (0, svg_js_1.SVG)(`#${arrowMarkerId}`);
if (null === marker) {
marker = this.svgMarkup.marker(length, width).id(arrowMarkerId);
marker.polygon([0, 0, length, width * 0.5, 0, width]);
marker.attr("orient", "auto-start-reverse");
marker.attr("overflow", "visible"); // Don't clip the stroke that is being applied to allow the specified start/end to be used directly while hiding the arrow tail fully under the arrow head...
marker.attr("refX", length);
marker.css({ stroke: color, fill: color });
}
return marker;
}
}
exports.Markup = Markup;
//# sourceMappingURL=Markup.js.map