UNPKG

@infinite-canvas-tutorial/webcomponents

Version:
698 lines (696 loc) 29.9 kB
"use strict"; var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ContextMenu = void 0; exports.executeCopy = executeCopy; exports.executeCut = executeCut; exports.executePaste = executePaste; const localize_1 = require("@lit/localize"); const lit_1 = require("lit"); const decorators_js_1 = require("lit/decorators.js"); const when_js_1 = require("lit/directives/when.js"); const context_1 = require("@lit/context"); const ecs_1 = require("@infinite-canvas-tutorial/ecs"); const base_1 = require("@spectrum-web-components/base"); const overlay_1 = require("@spectrum-web-components/overlay"); const uuid_1 = require("uuid"); const context_2 = require("../context"); const url_1 = require("../utils/url"); const utils_1 = require("../utils"); const common_1 = require("../utils/common"); const ZINDEX_OFFSET = 0.0001; function executeCopy(api, appState, event) { api.copyToClipboard(appState.layersSelected.map((id) => api.getNodeById(id)), event); } function executeCut(api, appState, event) { executeCopy(api, appState, event); // delete nodes api.deleteNodesById(appState.layersSelected); api.record(); } function getMaxZIndex(api) { return api.getNodes().reduce((max, node) => { var _a; return Math.max(max, (_a = node.zIndex) !== null && _a !== void 0 ? _a : 0); }, 0); } function createSVG(api, appState, svg, position) { var _a, _b; // TODO: Extract semantic groups inside comments const doc = ecs_1.DOMAdapter.get() .getDOMParser() .parseFromString(svg, 'image/svg+xml'); const $svg = doc.documentElement; const width = $svg.width.baseVal.value; const height = $svg.height.baseVal.value; // This method also works, but it may lose the namespace of the SVG element. // const $container = document.createElement('div'); // $container.innerHTML = string; // const $svg = $container.children[0] as SVGSVGElement; const root = { id: (0, uuid_1.v4)(), type: 'rect', zIndex: getMaxZIndex(api) + 1, x: (_a = position === null || position === void 0 ? void 0 : position.x) !== null && _a !== void 0 ? _a : 0, y: (_b = position === null || position === void 0 ? void 0 : position.y) !== null && _b !== void 0 ? _b : 0, width, height, }; const nodes = (0, ecs_1.svgElementsToSerializedNodes)(Array.from($svg.children)); nodes.forEach((node) => { node.parentId = root.id; node.locked = true; }); (0, common_1.updateAndSelectNodes)(api, appState, [root, ...nodes]); } function createText(api, appState, text, position) { var _a, _b; (0, common_1.updateAndSelectNodes)(api, appState, [ { id: (0, uuid_1.v4)(), type: 'text', anchorX: (_a = position === null || position === void 0 ? void 0 : position.x) !== null && _a !== void 0 ? _a : 0, anchorY: (_b = position === null || position === void 0 ? void 0 : position.y) !== null && _b !== void 0 ? _b : 0, content: text, fontSize: 16, fontFamily: 'system-ui', fill: 'black', zIndex: getMaxZIndex(api) + 1, }, ]); } function createHTML(api, appState, html, position) { var _a, _b; const { width, height } = (0, utils_1.measureHTML)(html); (0, common_1.updateAndSelectNodes)(api, appState, [ { id: (0, uuid_1.v4)(), type: 'html', x: (_a = position === null || position === void 0 ? void 0 : position.x) !== null && _a !== void 0 ? _a : 0, y: (_b = position === null || position === void 0 ? void 0 : position.y) !== null && _b !== void 0 ? _b : 0, width, height, html, zIndex: getMaxZIndex(api) + 1, }, ]); } function executePaste(api, appState, event, position) { return __awaiter(this, void 0, void 0, function* () { var _a; // FIXME: Paste text inside a textfield if (!document.hasFocus()) { return; } if (!event) { let types; try { types = yield (0, ecs_1.readSystemClipboard)(); } catch (error) { } event = (0, ecs_1.createPasteEvent)({ types }); } let canvasPosition = null; if (position) { canvasPosition = api.viewport2Canvas(api.client2Viewport(position)); } // must be called in the same frame (thus before any awaits) as the paste // event else some browsers (FF...) will clear the clipboardData // (something something security) const file = (_a = event === null || event === void 0 ? void 0 : event.clipboardData) === null || _a === void 0 ? void 0 : _a.files[0]; const data = yield (0, ecs_1.parseClipboard)(event, false); if (!file) { if (data.html) { createHTML(api, appState, data.html, canvasPosition); // return this.addElementsFromMixedContentPaste(data.mixedContent, { // isPlainPaste, // sceneX, // sceneY, // }); } else if (data.text) { const string = data.text.trim(); if ((0, ecs_1.isUrl)(data.text)) { // TODO: youtube, figma, google maps, etc. // Plain url, extract metadata const meta = yield (0, url_1.extractExternalUrlMetadata)(data.text); // console.log(meta); // TODO: create bookmark asset } else if (string.startsWith('<svg') && string.endsWith('</svg>')) { createSVG(api, appState, string, canvasPosition); } else { // const nonEmptyLines = data.text // .replace(/\r?\n|\r/g, '\n') // .split(/\n+/) // .map((s) => s.trim()) // .filter(Boolean); createText(api, appState, data.text, canvasPosition); } } else if (data.elements) { const nodes = data.elements.map((node) => { node.id = (0, uuid_1.v4)(); if (node.zIndex) { node.zIndex += ZINDEX_OFFSET; } if (canvasPosition) { node.x = canvasPosition.x; node.y = canvasPosition.y; } else { node.x = node.x + 10; node.y = node.y + 10; } return node; }); (0, common_1.updateAndSelectNodes)(api, appState, nodes); } } else if ((0, ecs_1.isSupportedImageFileType)(file === null || file === void 0 ? void 0 : file.type)) { yield api.createImageFromFile(file, { position: canvasPosition }); } }); } /** * @see https://opensource.adobe.com/spectrum-web-components/components/imperative-api/#using-a-virtual-trigger */ let ContextMenu = class ContextMenu extends lit_1.LitElement { constructor() { super(...arguments); this.isClipboardEmpty = true; this.binded = false; this.lastContextMenuPosition = null; this.lastPointerMovePosition = null; this.handleExecuteAction = (event) => { const value = event.target.value; if (value === 'copy') { executeCopy(this.api, this.appState); } else if (value === 'paste') { executePaste(this.api, this.appState, undefined, this.lastContextMenuPosition); } else if (value === 'cut') { executeCut(this.api, this.appState); } else if (value === 'bring-to-front') { this.executeBringToFront(); } else if (value === 'bring-forward') { this.executeBringForward(); } else if (value === 'send-backward') { this.executeSendBackward(); } else if (value === 'send-to-back') { this.executeSendToBack(); } else if (value === 'toggle-visibility') { this.executeToggleVisibility(); } else if (value === 'toggle-lock') { this.executeToggleLock(); } else if (value === 'crop') { this.executeCrop(); } }; this.handleExport = (event) => { const format = event.target.value; const nodes = this.api .getAppState() .layersSelected.map((id) => this.api.getNodeById(id)); // Should export children recursively const allNodes = nodes.flatMap((node) => { return [node, ...this.api.getChildrenRecursively(node)]; }); this.api.export(format, true, allNodes); }; this.handleContextMenu = (event) => __awaiter(this, void 0, void 0, function* () { event.preventDefault(); event.stopPropagation(); this.lastContextMenuPosition = { x: event.clientX, y: event.clientY }; const trigger = event.target; const virtualTrigger = new overlay_1.VirtualTrigger(this.lastContextMenuPosition.x, this.lastContextMenuPosition.y); const fragment = document.createDocumentFragment(); (0, base_1.render)(this.contextMenuTemplate(), fragment); const popover = fragment.querySelector('sp-popover'); const overlay = yield (0, overlay_1.openOverlay)(popover, { trigger: virtualTrigger, placement: 'right-start', offset: 0, notImmediatelyClosable: true, type: 'auto', }); trigger.insertAdjacentElement('afterend', overlay); this.renderRoot.appendChild(overlay); }); this.handleCopy = (event) => { const { layersSelected } = this.appState; if (document.activeElement !== this.api.element || layersSelected.length === 0) { return; } executeCopy(this.api, this.appState, event); event.preventDefault(); event.stopPropagation(); }; this.handleCut = (event) => { const { layersSelected } = this.appState; if (document.activeElement !== this.api.element || layersSelected.length === 0) { return; } executeCut(this.api, this.appState, event); event.preventDefault(); event.stopPropagation(); }; this.handlePaste = (event) => { if (document.activeElement !== this.api.element) { return; } executePaste(this.api, this.appState, event, this.lastPointerMovePosition); event.preventDefault(); event.stopPropagation(); }; this.handleKeyDown = (event) => { if (document.activeElement !== this.api.element) { return; } // bring to front ⌥⌘] if (event.key === ']' && event.metaKey && event.ctrlKey) { this.executeBringToFront(); event.preventDefault(); event.stopPropagation(); } else if (event.key === '[' && event.metaKey && event.ctrlKey) { this.executeSendToBack(); event.preventDefault(); event.stopPropagation(); } else if (event.key === ']' && event.metaKey) { this.executeBringForward(); event.preventDefault(); event.stopPropagation(); } else if (event.key === '[' && event.metaKey) { this.executeSendBackward(); event.preventDefault(); event.stopPropagation(); } const { layersSelected } = this.api.getAppState(); if (layersSelected.length === 0) { return; } if (event.key === 'ArrowUp') { event.preventDefault(); layersSelected.forEach((id) => { const node = this.api.getNodeById(id); if (node) { this.api.updateNodeOBB(node, { y: node.y - 10 }); } }); this.api.record(); } else if (event.key === 'ArrowDown') { event.preventDefault(); layersSelected.forEach((id) => { const node = this.api.getNodeById(id); if (node) { this.api.updateNodeOBB(node, { y: node.y + 10 }); } }); this.api.record(); } else if (event.key === 'ArrowLeft') { event.preventDefault(); layersSelected.forEach((id) => { const node = this.api.getNodeById(id); if (node) { this.api.updateNodeOBB(node, { x: node.x - 10 }); } }); this.api.record(); } else if (event.key === 'ArrowRight') { event.preventDefault(); layersSelected.forEach((id) => { const node = this.api.getNodeById(id); if (node) { this.api.updateNodeOBB(node, { x: node.x + 10 }); } }); this.api.record(); } else if (event.key === 'Backspace') { event.preventDefault(); this.api.deleteNodesById(layersSelected); this.api.record(); } else if (event.key === 'Escape') { event.preventDefault(); this.api.selectNodes([]); this.api.record(); } }; this.handlePointerMove = (event) => { this.lastPointerMovePosition = { x: event.clientX, y: event.clientY }; }; /** * @see https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/File_drag_and_drop#prevent_the_browsers_default_drag_behavior */ this.handleDragOver = (event) => { event.preventDefault(); }; /** * @see https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/components/App.tsx#L10242 */ this.handleDrop = (event) => __awaiter(this, void 0, void 0, function* () { event.preventDefault(); const canvasPosition = this.api.viewport2Canvas(this.api.client2Viewport({ x: event.clientX, y: event.clientY, })); const url = event.dataTransfer.getData('text/uri-list'); if (url) { try { yield this.api.createImageFromFile(url, { position: canvasPosition }); return; } catch (error) { console.error(error); } } const text = event.dataTransfer.getData('text/plain'); if (text) { createText(this.api, this.appState, text, canvasPosition); return; } for (const file of Array.from(event.dataTransfer.files)) { if ((0, ecs_1.isSupportedImageFileType)(file.type)) { if (file.type === ecs_1.MIME_TYPES.svg) { const svg = yield file.text(); createSVG(this.api, this.appState, svg, canvasPosition); } else { yield this.api.createImageFromFile(file, { position: canvasPosition }); } } } }); } contextMenuTemplate() { const { layersSelected, contextMenuVisible } = this.appState; if (document.hasFocus()) { // check if clipboard is empty (0, ecs_1.readSystemClipboard)().then((clipboard) => { this.isClipboardEmpty = Object.keys(clipboard).length === 0; }); } // Locked element or unselected element should also be included in the context menu // const { x, y } = this.lastContextMenuPosition; // const canvasPosition = this.api.viewport2Canvas({ x, y }); // const [topmost] = this.api.elementsFromBBox(canvasPosition.x, canvasPosition.y, canvasPosition.x, canvasPosition.y, false); const isSelectedEmpty = layersSelected.length === 0; let bringForwardDisabled = false; let sendBackwardDisabled = false; let isLocked = false; let isVisible = true; if (layersSelected.length === 1) { const node = this.api.getNodeById(layersSelected[0]); const children = this.api .getSiblings(node) .filter((child) => !child.has(ecs_1.UI)); const maxZIndex = Math.max(...children.map((child) => child.read(ecs_1.ZIndex).value)); const minZIndex = Math.min(...children.map((child) => child.read(ecs_1.ZIndex).value)); if (node.zIndex === maxZIndex) { bringForwardDisabled = true; } if (node.zIndex === minZIndex) { sendBackwardDisabled = true; } isLocked = node.locked; isVisible = node.visibility !== 'hidden'; } return (0, base_1.html) `${(0, when_js_1.when)(contextMenuVisible, () => (0, base_1.html) `<sp-popover style="width:200px;" @change=${(event) => { event.target.dispatchEvent(new Event('close', { bubbles: true })); }} > <h4>${(0, localize_1.msg)((0, localize_1.str) `Actions`)}</h4> <sp-menu @change=${this.handleExecuteAction}> <sp-menu-item ?disabled=${isSelectedEmpty} value="copy"> <sp-icon-copy slot="icon"></sp-icon-copy> ${(0, localize_1.msg)((0, localize_1.str) `Copy`)} <kbd slot="value">⌘C</kbd> </sp-menu-item> <sp-menu-item ?disabled=${this.isClipboardEmpty} value="paste"> <sp-icon-paste slot="icon"></sp-icon-paste> ${(0, localize_1.msg)((0, localize_1.str) `Paste`)} <kbd slot="value">⌘V</kbd> </sp-menu-item> <sp-menu-item ?disabled=${isSelectedEmpty} value="cut"> <sp-icon-cut slot="icon"></sp-icon-cut> ${(0, localize_1.msg)((0, localize_1.str) `Cut`)} <kbd slot="value">⌘X</kbd> </sp-menu-item> <sp-menu-divider></sp-menu-divider> <sp-menu-item ?disabled=${isSelectedEmpty} value="toggle-visibility"> ${(0, when_js_1.when)(isVisible, () => (0, base_1.html) `<sp-icon-visibility slot="icon"></sp-icon-visibility>`, () => (0, base_1.html) `<sp-icon-visibility-off slot="icon"></sp-icon-visibility-off>`)} ${isVisible ? (0, localize_1.msg)((0, localize_1.str) `Hide layer`) : (0, localize_1.msg)((0, localize_1.str) `Show layer`)} <kbd slot="value">⌘H</kbd> </sp-menu-item> <sp-menu-item ?disabled=${isSelectedEmpty} value="toggle-lock"> ${(0, when_js_1.when)(isLocked, () => (0, base_1.html) `<sp-icon-lock-closed slot="icon"></sp-icon-lock-closed>`, () => (0, base_1.html) `<sp-icon-lock-open slot="icon"></sp-icon-lock-open>`)} ${isLocked ? (0, localize_1.msg)((0, localize_1.str) `Unlock layer`) : (0, localize_1.msg)((0, localize_1.str) `Lock layer`)} <kbd slot="value">⌘L</kbd> </sp-menu-item> <sp-menu-divider></sp-menu-divider> <sp-menu-item ?disabled=${isSelectedEmpty || bringForwardDisabled} value="bring-to-front" > <sp-icon-layers-bring-to-front slot="icon" ></sp-icon-layers-bring-to-front> ${(0, localize_1.msg)((0, localize_1.str) `Bring to front`)} <kbd slot="value">⌥⌘]</kbd> </sp-menu-item> <sp-menu-item ?disabled=${isSelectedEmpty || bringForwardDisabled} value="bring-forward" > <sp-icon-layers-forward slot="icon"></sp-icon-layers-forward> ${(0, localize_1.msg)((0, localize_1.str) `Bring forward`)} <kbd slot="value">⌘]</kbd> </sp-menu-item> <sp-menu-item ?disabled=${isSelectedEmpty || sendBackwardDisabled} value="send-backward" > <sp-icon-layers-backward slot="icon"></sp-icon-layers-backward> ${(0, localize_1.msg)((0, localize_1.str) `Send backward`)} <kbd slot="value">⌘[</kbd> </sp-menu-item> <sp-menu-item ?disabled=${isSelectedEmpty || sendBackwardDisabled} value="send-to-back" > <sp-icon-layers-send-to-back slot="icon" ></sp-icon-layers-send-to-back> ${(0, localize_1.msg)((0, localize_1.str) `Send to back`)} <kbd slot="value">⌥⌘[</kbd> </sp-menu-item> <sp-menu-divider></sp-menu-divider> <sp-menu-item ?disabled=${isSelectedEmpty} value="crop"> <sp-icon-crop slot="icon"></sp-icon-crop> ${(0, localize_1.msg)((0, localize_1.str) `Crop`)} <kbd slot="value">⌘K</kbd> </sp-menu-item> <sp-menu-item> ${(0, localize_1.msg)((0, localize_1.str) `Export as...`)} <sp-menu slot="submenu" @change=${this.handleExport}> <sp-menu-item value=${ecs_1.ExportFormat.SVG} ?disabled=${isSelectedEmpty} >SVG</sp-menu-item > <sp-menu-item value=${ecs_1.ExportFormat.PNG} ?disabled=${isSelectedEmpty} >PNG</sp-menu-item > <sp-menu-item value=${ecs_1.ExportFormat.JPEG} ?disabled=${isSelectedEmpty} >JPEG</sp-menu-item > </sp-menu> </sp-menu-item> </sp-menu> </sp-popover>`)}`; } executeBringToFront() { const node = this.api.getNodeById(this.appState.layersSelected[0]); if (node) { this.api.bringToFront(node); this.api.record(); } } executeBringForward() { const node = this.api.getNodeById(this.appState.layersSelected[0]); if (node) { this.api.bringForward(node); this.api.record(); } } executeSendBackward() { const node = this.api.getNodeById(this.appState.layersSelected[0]); if (node) { this.api.sendBackward(node); this.api.record(); } } executeSendToBack() { const node = this.api.getNodeById(this.appState.layersSelected[0]); if (node) { this.api.sendToBack(node); this.api.record(); } } executeToggleVisibility() { const node = this.api.getNodeById(this.appState.layersSelected[0]); if (node) { this.api.updateNode(node, { visibility: node.visibility === 'hidden' ? 'visible' : 'hidden', }); this.api.record(); } } executeToggleLock() { const node = this.api.getNodeById(this.appState.layersSelected[0]); if (node) { const isLocked = !!node.locked; this.api.updateNode(node, { locked: !isLocked, }); this.api.record(); } } executeCrop() { const { layersSelected } = this.api.getAppState(); if (layersSelected.length === 1) { const node = this.api.getNodeById(layersSelected[0]); if (node.clipMode) { this.api.setAppState({ layersCropping: layersSelected, penbarSelected: ecs_1.Pen.SELECT, }); // already is a clipping node, do nothing return; } } const children = layersSelected.map((id) => this.api.getNodeById(id)); const bounds = this.api.getBounds(children); const { minX, minY, maxX, maxY } = bounds; // create a clip parent for all the selected nodes const clipParent = { id: (0, uuid_1.v4)(), type: 'rect', clipMode: 'clip', x: minX, y: minY, width: maxX - minX, height: maxY - minY, zIndex: 0, }; this.api.runAtNextTick(() => { this.api.updateNodes([clipParent]); children.forEach((child) => { this.api.reparentNode(child, clipParent); }); this.api.setAppState({ layersCropping: [clipParent.id], penbarSelected: ecs_1.Pen.SELECT, }); this.api.record(); }); } disconnectedCallback() { var _a, _b, _c, _d, _e, _f; super.disconnectedCallback(); (_b = (_a = this.api) === null || _a === void 0 ? void 0 : _a.element) === null || _b === void 0 ? void 0 : _b.removeEventListener('contextmenu', this.handleContextMenu); (_d = (_c = this.api) === null || _c === void 0 ? void 0 : _c.element) === null || _d === void 0 ? void 0 : _d.removeEventListener('pointermove', this.handlePointerMove); (_f = (_e = this.api) === null || _e === void 0 ? void 0 : _e.element) === null || _f === void 0 ? void 0 : _f.removeEventListener('drop', this.handleDrop); document.removeEventListener('copy', this.handleCopy); document.removeEventListener('cut', this.handleCut); document.removeEventListener('paste', this.handlePaste); // this.api // .getCanvasElement() // .removeEventListener('keydown', this.handleKeyDown); } render() { var _a; // FIXME: wait for the element to be ready. if (((_a = this.api) === null || _a === void 0 ? void 0 : _a.element) && !this.binded) { this.api.element.addEventListener('contextmenu', this.handleContextMenu); this.api.element.addEventListener('pointermove', this.handlePointerMove); this.api.element.addEventListener('dragover', this.handleDragOver); this.api.element.addEventListener('drop', this.handleDrop); document.addEventListener('copy', this.handleCopy, { passive: false }); document.addEventListener('cut', this.handleCut, { passive: false }); document.addEventListener('paste', this.handlePaste, { passive: false }); this.api .getCanvasElement() .addEventListener('keydown', this.handleKeyDown); } return (0, base_1.html) ``; } }; exports.ContextMenu = ContextMenu; ContextMenu.styles = (0, lit_1.css) ` sp-popover { padding: 0; } kbd { font-family: var(--spectrum-alias-body-text-font-family); letter-spacing: 0.1em; white-space: nowrap; border: none; padding: none; padding: 0; line-height: normal; } h4 { margin: 0; padding: 8px; } `; __decorate([ (0, context_1.consume)({ context: context_2.appStateContext, subscribe: true }) ], ContextMenu.prototype, "appState", void 0); __decorate([ (0, context_1.consume)({ context: context_2.apiContext, subscribe: true }) ], ContextMenu.prototype, "api", void 0); __decorate([ (0, decorators_js_1.state)() ], ContextMenu.prototype, "isClipboardEmpty", void 0); exports.ContextMenu = ContextMenu = __decorate([ (0, decorators_js_1.customElement)('ic-spectrum-context-menu'), (0, localize_1.localized)() ], ContextMenu); //# sourceMappingURL=context-menu.js.map