UNPKG

@itwin/core-markup

Version:
470 lines • 25 kB
"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