diff --git a/sass/editor.scss b/sass/editor.scss index 655ba06ef..d9fe5b668 100644 --- a/sass/editor.scss +++ b/sass/editor.scss @@ -56,4 +56,5 @@ @import 'editor/editor-confirmation-dialog'; @import 'editor/editor-settings-launch-page'; @import 'editor/editor-engine-picker'; -@import 'editor/editor-auditor-picker'; \ No newline at end of file +@import 'editor/editor-auditor-picker'; +@import 'editor/editor-clipboard'; \ No newline at end of file diff --git a/sass/editor/_editor-clipboard.scss b/sass/editor/_editor-clipboard.scss new file mode 100644 index 000000000..a26c5a0e1 --- /dev/null +++ b/sass/editor/_editor-clipboard.scss @@ -0,0 +1,105 @@ +$highlight-color: #07f; + +.pcui-contextmenu-clipboard { + > .pcui-menu-items { + > .pcui-menu-item { + > .pcui-menu-item-content { + > .pcui-menu-item-postfix { + flex-grow: 1; + text-align: right; + + &.disabled { + display: none; + } + } + } + } + } +} + +%pcui-highlight { + content: '\00a0'; + position: absolute; + inset: -4px; + border: 1px dotted $highlight-color; + z-index: 1; + pointer-events: none; +} + +.pcui-element.pcui-highlight::before { + @extend %pcui-highlight; +} + +.pcui-element.pcui-highlight-flash::before { + @extend %pcui-highlight; + + animation: pcui-clipboard-flash-animation 200ms ease-in-out forwards; +} + +@keyframes pcui-clipboard-flash-animation { + from { border-color: $highlight-color; } + to { border-color: rgba($highlight-color, 0.0); } +} + +.pcui-label-group, +.pcui-asset-input { + &:hover { + > .pcui-label > .pcui-clipboard-button { + display: block; + + &.pcui-hidden { + display: none; + } + } + } +} + +.pcui-label { + &:hover { + > .pcui-clipboard-button { + display: block; + + &.pcui-hidden { + display: none; + } + } + } + + > .pcui-clipboard-button { + display: none; + position: absolute; + top: 0; + right: 0; + width: 16px; + height: 16px; + line-height: 16px; + margin: 0; + padding: 0; + text-align: center; + border: 0; + background-color: transparent; + + &::before { + display: inline-block; + line-height: 16px; + } + + &:hover, + &:focus { + box-shadow: none; + background-color: transparent; + } + + &.pcui-clipboard-button-copy { + right: 16px; + } + + &.pcui-hidden { + display: none; + } + } +} + +.ui-tooltip > .inner.pcui-tooltip-disabled { + color: #777; +} \ No newline at end of file diff --git a/src/editor/assets/assets-context-menu.ts b/src/editor/assets/assets-context-menu.ts index fabf969b6..97c24543d 100644 --- a/src/editor/assets/assets-context-menu.ts +++ b/src/editor/assets/assets-context-menu.ts @@ -661,7 +661,7 @@ editor.once('load', () => { menuItemPaste.disabled = true; } else { const clipboard = editor.call('clipboard:get'); - menuItemPaste.disabled = !clipboard || clipboard.type !== 'asset'; + menuItemPaste.disabled = !clipboard || clipboard.type !== 'asset' || !clipboard.branchId || !clipboard.projectId; } menuItemCopy.disabled = !currentAsset; diff --git a/src/editor/entities/entities-context-menu.ts b/src/editor/entities/entities-context-menu.ts index 61082ed58..e9915aa52 100644 --- a/src/editor/entities/entities-context-menu.ts +++ b/src/editor/entities/entities-context-menu.ts @@ -167,7 +167,7 @@ editor.once('load', () => { onIsEnabled: function () { if (items.length <= 1) { const clipboard = editor.call('clipboard:get'); - if (clipboard && clipboard.type === 'entity') { + if (clipboard && clipboard.type === 'entity' && clipboard.branch && clipboard.scene && clipboard.hierarchy) { return true; } } diff --git a/src/editor/index.ts b/src/editor/index.ts index e97fee1a4..e216321a6 100644 --- a/src/editor/index.ts +++ b/src/editor/index.ts @@ -270,6 +270,9 @@ import './inspector/settings-panels/batchgroups-create.ts'; // attributes assets import './attributes/attributes-asset.ts'; +// clipboard +import './storage/clipboard-context-menu.ts'; + // chat import './chat/chat-connection.ts'; import './chat/chat-widget.ts'; diff --git a/src/editor/inspector/attributes-inspector.ts b/src/editor/inspector/attributes-inspector.ts index 62131262c..ca8ee95de 100644 --- a/src/editor/inspector/attributes-inspector.ts +++ b/src/editor/inspector/attributes-inspector.ts @@ -1,7 +1,9 @@ -import { Element, Container, LabelGroup, Panel, ArrayInput, BindingTwoWay } from '@playcanvas/pcui'; +import { Element, Container, LabelGroup, Panel, Button, ArrayInput, BindingTwoWay } from '@playcanvas/pcui'; import { AssetInput } from '../../common/pcui/element/element-asset-input.ts'; import { tooltip, tooltipRefItem } from '../../common/tooltips.ts'; +import { LegacyTooltip } from '../../common/ui/tooltip.ts'; +import '../storage/clipboard-context-menu.ts'; const isEnabledAttribute = ({ label, type }) => label === 'enabled' && type === 'boolean'; @@ -19,6 +21,8 @@ class AttributesInspector extends Container { */ _templateOverridesInspector; + _clipboardTypes: Set | null; + constructor(args) { args = Object.assign({ flex: true @@ -47,6 +51,8 @@ class AttributesInspector extends Container { this._suspendChangeEvt = false; this._onAttributeChangeHandler = this._onAttributeChange.bind(this); + this._clipboardTypes = editor.call('clipboard:types') ?? null; + // entity attributes args.attributes.forEach((attr) => { this.addAttribute(attr); @@ -128,6 +134,146 @@ class AttributesInspector extends Container { evtChange = null; }); + // if clipboard types are available + if (this._clipboardTypes) { + // check if attribute has type and path and is of known type + if (attr.type && attr.path && this._clipboardTypes.has(attr.type)) { + // once the field is parented + field.once('parent', (parent) => { + let target = field; + + // if part of a label group, provide copying for the whole element + if (parent instanceof LabelGroup) { + target = parent; + } + + // modify type based on various rules + let type = attr.type; + + if (type === 'select') { + type = attr.args.type; + } + if (type === 'assets') { + type = 'array:asset'; + } + if (type === 'slider') { + type = 'number'; + } + if ((type === 'asset' || type === 'array:asset') && attr.args?.assetType) { + type += `:${attr.args.assetType}`; + } + + // try to bring context menu + const onContextMenu = (evt) => { + // do not interfere with inputs and buttons + if (evt.target.tagName === 'BUTTON' || + evt.target.tagName === 'INPUT') { + + return; + } + + // supress default context menu + evt.stopPropagation(); + evt.preventDefault(); + + // call context menu + editor.call('clipboard:contextmenu:open', evt.clientX + 1, evt.clientY, attr.path, type, target.dom); + }; + + let element = target.dom; + element.addEventListener('contextmenu', onContextMenu); + + // clean up on field destroy + field.once('destroy', () => { + element.removeEventListener('contextmenu', onContextMenu); + element = null; + }); + + if (((target instanceof LabelGroup) || (target instanceof AssetInput)) && type !== 'label') { + target.label.dom.style.position = 'relative'; + + // paste button + const btnPaste = new Button({ + icon: 'E353', + enabled: false, + class: 'pcui-clipboard-button' + }); + target.label.dom.appendChild(btnPaste.dom); + + btnPaste.on('click', () => { + const pasted = editor.call('clipboard:paste', attr.path, type); + if (pasted) { + editor.call('clipboard:flashElement', target.dom); + } + }); + + // copy button + const btnCopy = new Button({ + icon: 'E126', + enabled: false, + class: ['pcui-clipboard-button', 'pcui-clipboard-button-copy'] + }); + target.label.dom.appendChild(btnCopy.dom); + + // when copy button clicked + btnCopy.on('click', () => { + const copied = editor.call('clipboard:copy', attr.path, type); + if (!copied) { + btnCopy.enabled = false; + btnPaste.enabled = false; + } else { + // toggle paste button + btnPaste.enabled = editor.call('clipboard:validPaste', attr.path, type); + editor.call('clipboard:flashElement', target.dom); + } + }); + + // tooltip on hover for copy + const tooltipCopy = LegacyTooltip.attach({ + target: btnCopy.dom, + text: 'Copy', + align: 'bottom', + root: editor.call('layout.root') + }); + + // tooltip on hover for paste + const tooltipPaste = LegacyTooltip.attach({ + target: btnPaste.dom, + text: 'Paste', + align: 'bottom', + root: editor.call('layout.root') + }); + + // enabled/disable buttons when hovering on field + target.on('hover', () => { + const canCopy = editor.call('clipboard:validCopy', attr.path, type); + if (!canCopy) { + btnCopy.hidden = true; + btnPaste.hidden = true; + } else { + btnCopy.hidden = false; + btnPaste.hidden = false; + btnCopy.enabled = true; + btnPaste.enabled = editor.call('clipboard:validPaste', attr.path, type); + + const humanReadableType = editor.call('clipboard:typeToHuman', type); + tooltipCopy.text = `Copy ${humanReadableType}`; + + const clipboardType = editor.call('clipboard:typeToHuman', editor.call('clipboard:type')); + tooltipPaste.text = `Paste${clipboardType ? ` ${clipboardType}` : ''}`; + tooltipPaste.innerElement.classList.toggle('pcui-tooltip-disabled', !btnPaste.enabled); + } + }); + } + + // TODO: + // extra rule for array of assets, to copy individual assets within the group + }); + } else if (attr.type && attr.paths) { + // TODO: + // implement copy-paste for multi-path fields + } + } const key = this._getFieldKey(attr); if (key) { diff --git a/src/editor/storage/clipboard-context-menu.ts b/src/editor/storage/clipboard-context-menu.ts new file mode 100644 index 000000000..0b67946ed --- /dev/null +++ b/src/editor/storage/clipboard-context-menu.ts @@ -0,0 +1,392 @@ +import { Menu, MenuItem, Label } from '@playcanvas/pcui'; + + +// list of asset types +const assetTypes = config.schema.asset.type.$enum; + +// supported types for a context menu +const types = new Set([ + 'boolean', + 'label', + 'string', + 'array:string', + 'text', + 'tags', + 'select', + 'number', + 'slider', + 'vec2', + 'vec3', + 'vec4', + 'rgb', + 'rgba', + 'gradient', + 'tags', + 'batchgroup', + 'layers', + 'entity', + 'array:entity', + 'asset', + 'assets', + 'array:asset' +]); + +// add support for specific asset types +for (const type of assetTypes) { + types.add(`asset:${type}`); + types.add(`array:asset:${type}`); +} + + +editor.method('clipboard:types', () => { + return types; +}); + + +editor.once('load', () => { + const root = editor.call('layout.root'); + const hasWriteAccess = () => editor.call('permissions:write'); + + // use in-built clipboard (uses localStorage) + const clipboard = editor.api.globals.clipboard; + + let path: string | null = null; + let schemaType: string | null = null; + let elementHighlighted = null; + + if (!clipboard) { + return; + } + + // types of selected objects currently supported + const objTypes = new Set([ + 'entity' + ]); + + // TODO: + // add support for 'asset' inspector + + + // list of exceptions + // if object type and path matches, + // copy/paste will not be provided for such field + const pathsExceptions = new Set([ + 'entity:components.render.materialAssets' + ]); + + + // check if in clipboard we have a valid object + const isValidClipboardObject = (value) => { + return value && + (typeof value) === 'object' && + !Array.isArray(value) && + value.hasOwnProperty('type') && + value.hasOwnProperty('value') && + value.type; + }; + + + // TODO: + // type to other type conversions, e.g.: + // asset:* > asset + // asset > asset:* - if copied asset is of desired type + // rgb <> rgba + + + // context menu + const menu = new Menu({ + class: 'pcui-contextmenu-clipboard' + }); + + // copy + const menuItemCopy = new MenuItem({ + text: 'Copy', + icon: 'E351', + onSelect: () => { + editor.call('clipboard:copy', path, schemaType); + } + }); + + // copy posftix that shows the type to be copied + const menuItemCopyLabel = new Label({ + class: 'pcui-menu-item-postfix', + text: '' + }); + // container content is not exposed on menu item, but we need to access it + menuItemCopy._containerContent.append(menuItemCopyLabel); + + menu.append(menuItemCopy); + + // paste + const menuItemPaste = new MenuItem({ + text: 'Paste', + icon: 'E348', + onIsVisible: hasWriteAccess, // visible only if user has write access + onIsEnabled: () => { + return editor.call('clipboard:validPaste', path, schemaType); + }, + onSelect: () => { + editor.call('clipboard:paste', path, schemaType); + } + }); + + // paste postfix - shows what is in the clipboard + const menuItemPasteLabel = new Label({ + class: 'pcui-menu-item-postfix', + text: '' + }); + menuItemPaste._containerContent.append(menuItemPasteLabel); + + menu.append(menuItemPaste); + root.append(menu); + + + // when clipboard menu is hidden + menu.on('hide', () => { + if (!elementHighlighted) { + return; + } + + // remove highlighting + elementHighlighted.classList.remove('pcui-highlight'); + elementHighlighted = null; + }); + + + // convert type string to more human-friendly version + editor.method('clipboard:typeToHuman', (type: string) => { + if (!type) { + return ''; + } + + if (type.startsWith('array:')) { + type = `${type.slice(6)}[]`; + } + + return type; + }); + + + // return current clipboard type + editor.method('clipboard:type', () => { + const paste = clipboard.value; + if (isValidClipboardObject(paste)) { + return paste.type; + } + return null; + }); + + + // check if it is possible to copy value + editor.method('clipboard:validCopy', (path: string, type: string) => { + if (!path || !type) { + return false; + } + + // selector should have type + const selectionType = editor.call('selector:type') ?? null; + if (!selectionType) { + return false; + } + + // we should support that selection type + if (!objTypes.has(selectionType)) { + return false; + } + + // respect exceptions + if (pathsExceptions.has(`${selectionType}:${path}`)) { + return false; + } + + return true; + }); + + + // check if path and type are valid to be pasted in the current selection + editor.method('clipboard:validPaste', (path: string, type: string) => { + if (!path || !type) { + return false; + } + + if (type === 'label') { + return false; + } + + // selector should have type + const selectionType = editor.call('selector:type') ?? null; + if (!selectionType) { + return false; + } + + // we should support that selection type + if (!objTypes.has(selectionType)) { + return false; + } + + const paste = clipboard.value; + if (!isValidClipboardObject(paste)) { + return false; + } + + return paste.type === type; + }); + + + // method to open context menu + editor.method('clipboard:contextmenu:open', (x: number, y: number, newPath: string, type: string, element: Element) => { + // it might not have a path + if (!newPath) { + schemaType = null; + path = null; + return; + } + + // selector should have type + const selectionType = editor.call('selector:type') ?? null; + if (!selectionType) { + return; + } + + // we should support that selection type + if (!objTypes.has(selectionType)) { + return; + } + + // respect exceptions + if (pathsExceptions.has(`${selectionType}:${newPath}`)) { + return; + } + + // remember target path and value type + path = newPath; + schemaType = type; + menuItemCopyLabel.text = editor.call('clipboard:typeToHuman', schemaType); + + // highlight field + elementHighlighted = element; + elementHighlighted?.classList?.add('pcui-highlight'); + + // check if paste is possible + const paste = clipboard.value; + if (isValidClipboardObject(paste)) { + // if possible, update paste postfix + menuItemPasteLabel.text = editor.call('clipboard:typeToHuman', paste.type); + menuItemPasteLabel.enabled = true; + } else { + menuItemPasteLabel.enabled = false; + } + + // show context menu + menu.hidden = false; + menu.position(x + 1, y); + }); + + // copy to clipoard value by path from current selection + editor.method('clipboard:copy', (path: string, type: string) => { + if (!editor.call('clipboard:validCopy', path, type)) { + return false; + } + + const items = editor.call('selector:items'); + if (!items.length) { + return false; + } + + clipboard.value = { + type: type, + value: items[0].get(path) + }; + + if (elementHighlighted) { + editor.call('clipboard:flashElement', elementHighlighted); + } + + return true; + }); + + editor.method('clipboard:paste', (path: string, type: string) => { + if (!editor.call('clipboard:validPaste', path, type)) { + return false; + } + + // should have at least one item in selector + const items = editor.call('selector:items'); + if (!items.length) { + return false; + } + + const paste = clipboard.value; + + // TODO: + // verify if value is actually valid based on type + + // store list of records and their values before modifying for history undo/redo + const records = []; + + for (let i = 0; i < items.length; i++) { + // create history records + records.push({ + item: items[i], + path: path, + valueOld: items[i].get(path), + valueNew: paste.value + }); + + // paste new value + items[i].history.enabled = false; + items[i].set(path, paste.value); + items[i].history.enabled = true; + + // TODO: + // setting render-component asset does not update materials - bug in render component inspector + } + + // custom undo/redo to support multi-selection + editor.api.globals.history.add({ + name: 'clipboard.paste', + combine: false, + undo: () => { + for (let i = 0; i < records.length; i++) { + const item = records[i].item.latest(); + if (!item) { + continue; + } + item.history.enabled = false; + item.set(records[i].path, records[i].valueOld); + item.history.enabled = true; + } + }, + redo: () => { + for (let i = 0; i < records.length; i++) { + const item = records[i].item.latest(); + if (!item) { + continue; + } + item.history.enabled = false; + item.set(records[i].path, records[i].valueNew); + item.history.enabled = true; + } + } + }); + + if (elementHighlighted) { + editor.call('clipboard:flashElement', elementHighlighted); + } + + return true; + }); + + // flash dom element when copied/pasted + editor.method('clipboard:flashElement', (domElement: Element) => { + domElement.classList.add('pcui-highlight-flash'); + setTimeout(() => { + domElement.classList.remove('pcui-highlight-flash'); + }, 250); + }); +}); + +// Edge Cases: +// 1. entity.components.anim.stateGraphAsset - created without path, dynamically linked, when changed it changes slots under +// 2. entity.components.render.materialAssets - is a fixed length array of asset ID's, the array length should not be changed, and is defined by a number of meshInstances on a render asset +// 3. entity.components.particlesystem.%curves% - curvesets are more complex types, with multi-paths for fields