class TextureLayer { constructor(data, texture = Texture.selected, uuid) { this.uuid = (uuid && isUUID(uuid)) ? uuid : guid(); this.texture = texture; this.canvas = document.createElement('canvas'); this.ctx = this.canvas.getContext('2d', {willReadFrequently: true}); this.in_limbo = false; this.img = new Image(); this.img.onload = () => { this.canvas.width = this.img.naturalWidth; this.canvas.height = this.img.naturalHeight; this.ctx.drawImage(this.img, 0, 0); } for (var key in TextureLayer.properties) { TextureLayer.properties[key].reset(this); } if (data) this.extend(data); } get width() { return this.canvas.width; } get height() { return this.canvas.height; } get scaled_width() { return this.canvas.width * this.scale[0]; } get scaled_height() { return this.canvas.height * this.scale[1]; } get size() { return [this.canvas.width, this.canvas.height]; } get selected() { return this.texture.selected_layer == this; } extend(data) { for (var key in TextureLayer.properties) { TextureLayer.properties[key].merge(this, data) } if (data.image_data) { this.canvas.width = data.width || 16; this.canvas.height = data.height || 16; this.ctx.putImageData(data.image_data, 0, 0); } else if (data.data_url) { this.canvas.width = data.width || 16; this.canvas.height = data.height || 16; this.img.src = data.data_url; } } select() { this.texture.selected_layer = this; UVEditor.vue.layer = this; BarItems.layer_opacity.update(); BarItems.layer_blend_mode.set(this.blend_mode); } showContextMenu(event) { if (!this.selected) this.select(); this.menu.open(event, this); } remove(undo) { if (undo) { Undo.initEdit({textures: [this.texture], bitmap: true}); } let index = this.texture.layers.indexOf(this); this.texture.layers.splice(index, 1); if (this.texture.selected_layer == this) { let select_next = this.texture.layers[index-1] || this.texture.layers[index]; if (select_next) select_next.select(); } if (undo) { this.texture.updateChangesAfterEdit(); Undo.finishEdit('Remove layer'); } } getUndoCopy(image_data) { let copy = {}; copy.texture = this.texture.uuid; copy.uuid = this.uuid; for (var key in TextureLayer.properties) { TextureLayer.properties[key].copy(this, copy); } copy.width = this.width; copy.height = this.height; if (image_data) { copy.image_data = this.ctx.getImageData(0, 0, this.width, this.height); } return copy; } getSaveCopy() { let copy = {}; for (var key in TextureLayer.properties) { TextureLayer.properties[key].copy(this, copy); } delete copy.in_limbo; copy.width = this.width; copy.height = this.height; copy.data_url = this.canvas.toDataURL(); return copy; } setLimbo() { this.texture.layers.forEach(layer => layer.in_limbo = false); this.in_limbo = true; } resolveLimbo(keep_separate) { if (keep_separate) { if (this.scale[0] != 1 || this.scale[1] != 1) { let temp_canvas = this.canvas.cloneNode(); let temp_canvas_ctx = temp_canvas.getContext('2d'); temp_canvas_ctx.drawImage(this.canvas, 0, 0); Undo.initEdit({layers: [this], bitmap: true}); this.canvas.width = Math.round(this.canvas.width * this.scale[0]); this.canvas.height = Math.round(this.canvas.height * this.scale[1]); this.ctx.imageSmoothingEnabled = false; this.ctx.drawImage(temp_canvas, 0, 0, this.canvas.width, this.canvas.height); this.scale.V2_set(1, 1); this.texture.updateLayerChanges(true); TextureLayer.selected.in_limbo = false; Undo.finishEdit('Place selection as layer'); } else { TextureLayer.selected.in_limbo = false; } } else { if (this.texture.flags.has('temporary_layers') && this.texture.layers.length == 2) { Undo.initEdit({textures: [this.texture], bitmap: true}); TextureLayer.selected.mergeDown(false); this.texture.layers_enabled = false; this.texture.selected_layer = null; this.texture.layers.empty(); Undo.finishEdit('Disable layers on texture'); UVEditor.vue.layer = null; updateInterfacePanels(); BARS.updateConditions(); } else { TextureLayer.selected.mergeDown(true); } } this.texture.flags.delete('temporary_layers'); UVEditor.vue.$forceUpdate(); Texture.selected.selection.clear(); UVEditor.updateSelectionOutline(); Interface.removeSuggestedModifierKey('alt', 'modifier_actions.drag_to_duplicate'); } setSize(width, height) { this.canvas.width = width; this.canvas.height = height; return this; } toggleVisibility() { Undo.initEdit({layers: [this]}); this.visible = !this.visible; this.texture.updateChangesAfterEdit(); Undo.finishEdit('Toggle layer visibility'); return this; } scrollTo() { let el = document.querySelector(`#layers_list > li[layer_id="${this.uuid}"]`); if (el) { el.scrollIntoView({behavior: 'smooth', block: 'nearest'}); } return this; } addForEditing() { let i = this.texture.layers.indexOf(this.texture.selected_layer); if (i == -1) { this.texture.layers.push(this); } else { this.texture.layers.splice(i+1, 0, this); } this.select(); Vue.nextTick(() => { this.scrollTo(); }); return this; } mergeDown(undo = true) { let down_layer = this.texture.layers[this.texture.layers.indexOf(this) - 1]; if (!down_layer) { this.in_limbo = false; return; } if (undo) { Undo.initEdit({textures: [this.texture], bitmap: true}); } down_layer.expandTo(this.offset, this.offset.slice().V2_add(this.width, this.height)); down_layer.ctx.imageSmoothingEnabled = false; down_layer.ctx.drawImage(this.canvas, this.offset[0] - down_layer.offset[0], this.offset[1] - down_layer.offset[1], this.scaled_width, this.scaled_height); let index = this.texture.layers.indexOf(this); this.texture.layers.splice(index, 1); if (this.texture.selected_layer == this) { let select_next = this.texture.layers[index-1] || this.texture.layers[index]; if (select_next) select_next.select(); } if (undo) { this.texture.updateChangesAfterEdit(); Undo.finishEdit('Merge layers'); } } expandTo(...points) { let min = this.offset.slice(); let max = this.offset.slice().V2_add(this.width, this.height); points.forEach(point => { point = [ Math.clamp(point[0], 0, this.texture.width), Math.clamp(point[1], 0, this.texture.height), ] min[0] = Math.min(min[0], point[0]); min[1] = Math.min(min[1], point[1]); max[0] = Math.max(max[0], point[0]); max[1] = Math.max(max[1], point[1]); }); if (min[0] < this.offset[0] || min[1] < this.offset[1] || max[0] > this.offset[0]+this.width || max[1] > this.offset[1]+this.height) { let copy_canvas = Painter.copyCanvas(this.canvas); this.canvas.width = max[0] - min[0]; this.canvas.height = max[1] - min[1]; this.ctx.drawImage(copy_canvas, this.offset[0] - min[0], this.offset[1] - min[1]); this.offset.replace(min); } } flip(axis = 0, undo) { let temp_canvas = this.canvas.cloneNode(); let temp_canvas_ctx = temp_canvas.getContext('2d'); temp_canvas_ctx.drawImage(this.canvas, 0, 0); if (undo) Undo.initEdit({layers: [this], bitmap: true}); this.ctx.save(); this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); if (axis == 0) { this.ctx.translate(this.canvas.width, 0); this.ctx.scale(-1, 1); this.ctx.drawImage(temp_canvas, this.canvas.width, 0, -this.canvas.width, this.canvas.height); } else { this.ctx.translate(0, this.canvas.height); this.ctx.scale(1, -1); this.ctx.drawImage(temp_canvas, 0, this.canvas.height, this.canvas.width, -this.canvas.height); } this.ctx.restore(); this.texture.updateLayerChanges(undo); this.texture.saved = false; if (undo) Undo.finishEdit('Flip layer'); } rotate(angle = 90, undo) { let temp_canvas = this.canvas.cloneNode(); let temp_canvas_ctx = temp_canvas.getContext('2d'); temp_canvas_ctx.drawImage(this.canvas, 0, 0); if (undo) Undo.initEdit({layers: [this], bitmap: true}); [this.canvas.width, this.canvas.height] = [this.canvas.height, this.canvas.width]; this.ctx.save(); this.ctx.translate(this.canvas.width/2,this.canvas.height/2); this.ctx.rotate(Math.degToRad(angle)); this.ctx.drawImage(temp_canvas,-temp_canvas.width/2,-temp_canvas.height/2); this.ctx.restore(); this.texture.updateLayerChanges(undo); this.texture.saved = false; UVEditor.vue.$forceUpdate(); if (undo) Undo.finishEdit('Rotate layer'); } center() { this.offset[0] = Math.round(Math.max(0, this.texture.width - this.width ) / 2); this.offset[1] = Math.round(Math.max(0, this.texture.height - this.height) / 2); this.texture.updateLayerChanges(); } propertiesDialog() { let blend_mode_options = {}; TextureLayer.properties.blend_mode.enum_values.forEach(mode => { blend_mode_options[mode] = `action.blend_mode.${mode}` }); let dialog = new Dialog({ id: 'layer_properties', title: `${this.name} (${this.width}x${this.height})`, form: { name: {label: 'generic.name', value: this.name}, opacity: {label: 'Opacity', type: 'range', value: this.opacity}, blend_mode: {label: 'action.blend_mode', type: 'select', value: this.blend_mode, options: blend_mode_options}, }, onConfirm: form_data => { dialog.hide().delete(); if ( form_data.name != this.name || form_data.opacity != this.opacity || form_data.blend_mode != this.blend_mode ) { Undo.initEdit({layers: [this]}); this.extend(form_data); this.texture.updateChangesAfterEdit(); Blockbench.dispatchEvent('edit_layer_properties', {layer: this}); Undo.finishEdit('Edit layer properties'); } }, onCancel() { dialog.hide().delete(); } }) dialog.show(); } } TextureLayer.prototype.menu = new Menu([ new MenuSeparator('settings'), 'layer_blend_mode', new MenuSeparator('edit'), {id: 'transform', name: 'menu.transform', icon: 'transform', children: [ 'flip_texture_x', 'flip_texture_y', 'rotate_texture_cw', 'rotate_texture_ccw', ]}, 'crop_layer_to_selection', 'layer_to_texture_size', 'merge_layer_down', new MenuSeparator('copypaste'), 'copy', 'duplicate', 'delete', new MenuSeparator('properties'), { icon: 'list', name: 'menu.texture.properties', click(layer) { layer.propertiesDialog()} } /** * Merge * Copy * Duplicate * Delete */ ]) new Property(TextureLayer, 'string', 'name', {default: 'layer'}); new Property(TextureLayer, 'vector2', 'offset'); new Property(TextureLayer, 'vector2', 'scale', {default: [1, 1]}); new Property(TextureLayer, 'number', 'opacity', {default: 100}); new Property(TextureLayer, 'boolean', 'visible', {default: true}); new Property(TextureLayer, 'enum', 'blend_mode', {default: 'default', values: ['default', 'set_opacity', 'color', 'multiply', 'add', 'screen', 'difference']}); new Property(TextureLayer, 'boolean', 'in_limbo', {default: false}); Object.defineProperty(TextureLayer, 'all', { get() { return Texture.selected?.layers_enabled ? Texture.selected.layers : []; } }) Object.defineProperty(TextureLayer, 'selected', { get() { return Texture.selected?.selected_layer; } }) SharedActions.add('delete', { subject: 'layer', condition: () => Prop.active_panel == 'layers' && Texture.selected?.selected_layer, run() { if (Texture.selected.layers.length >= 2) { Texture.selected?.selected_layer.remove(true); } } }) SharedActions.add('delete', { subject: 'layer_priority', condition: () => Texture.selected?.selected_layer?.in_limbo, priority: 2, run() { if (Texture.selected.layers.length >= 2) { Texture.selected?.selected_layer.remove(true); } Texture.selected.selection.clear() UVEditor.updateSelectionOutline() } }) SharedActions.add('duplicate', { subject: 'layer', condition: () => Prop.active_panel == 'layers' && Texture.selected?.selected_layer, run() { let texture = Texture.selected; let original = texture.getActiveLayer(); let copy = original.getUndoCopy(true); copy.name += '-copy'; Undo.initEdit({textures: [texture]}); let layer = new TextureLayer(copy, texture); layer.addForEditing(); Undo.finishEdit('Duplicate layer'); } }) SharedActions.add('copy', { subject: 'layer', condition: () => Prop.active_panel == 'layers' && TextureLayer.selected, run() { let layer = TextureLayer.selected; let copy = layer.getUndoCopy(true); Clipbench.layer = copy; } }) SharedActions.add('paste', { subject: 'layer', condition: () => Prop.active_panel == 'layers' && Texture.selected && Clipbench.layer, run() { let texture = Texture.selected; Undo.initEdit({textures: [texture]}); let layer = new TextureLayer(Clipbench.layer, texture); layer.addForEditing(); Undo.finishEdit('Paste layer'); } }) BARS.defineActions(() => { new Action('create_empty_layer', { icon: 'new_window', category: 'layers', condition: () => Modes.paint && Texture.selected && Texture.selected.layers_enabled, click() { let texture = Texture.selected; Undo.initEdit({textures: [texture], bitmap: true}); let layer = new TextureLayer({ name: `layer #${texture.layers.length+1}` }, texture); layer.setSize(texture.width, texture.height); layer.addForEditing(); Undo.finishEdit('Create empty layer'); BARS.updateConditions(); } }) new Action('enable_texture_layers', { icon: 'library_add_check', category: 'layers', condition: () => Texture.selected && !Texture.selected.layers_enabled, click() { if (!Modes.paint) { Modes.options.paint.select(); } let texture = Texture.selected; texture.activateLayers(true); } }) new Action('disable_texture_layers', { icon: 'layers_clear', category: 'layers', condition: () => Texture.selected && Texture.selected.layers_enabled, click() { let texture = Texture.selected; Undo.initEdit({textures: [texture], bitmap: true}); texture.layers_enabled = false; texture.selected_layer = null; texture.layers.empty(); Undo.finishEdit('Disable layers on texture'); UVEditor.vue.layer = null; updateInterfacePanels(); BARS.updateConditions(); } }) new NumSlider('layer_opacity', { category: 'layers', condition: () => Modes.paint && Texture.selected && Texture.selected.layers_enabled && Texture.selected.getActiveLayer(), settings: { min: 0, max: 100, default: 100, show_bar: true }, getInterval(event) { return 1; }, get() { return Texture.selected.getActiveLayer().opacity; }, change(modify) { let layer = Texture.selected.getActiveLayer(); layer.opacity = Math.clamp(modify(layer.opacity), 0, 100); Texture.selected.updateLayerChanges(); }, onBefore() { Undo.initEdit({layers: [Texture.selected.getActiveLayer()]}); }, onAfter() { Undo.finishEdit('Change layer opacity'); Texture.selected.updateChangesAfterEdit(); } }) let blend_mode_options = {}; TextureLayer.properties.blend_mode.enum_values.forEach(mode => { blend_mode_options[mode] = `action.blend_mode.${mode}`; }) new BarSelect('layer_blend_mode', { name: 'action.blend_mode', category: 'animation', condition: () => Modes.paint && TextureLayer.selected, options: blend_mode_options, onChange(sel) { let mode = sel.value; let layer = TextureLayer.selected; Undo.initEdit({layers: [layer]}); layer.blend_mode = mode; layer.texture.updateChangesAfterEdit(); Undo.finishEdit('Change layer blend mode'); } }) new Action('layer_to_texture_size', { icon: 'fit_screen', category: 'layers', condition: () => TextureLayer.selected, click() { let layer = TextureLayer.selected; Undo.initEdit({layers: [layer], bitmap: true}); let copy = Painter.copyCanvas(layer.canvas); layer.canvas.width = layer.texture.width; layer.canvas.height = layer.texture.height; layer.ctx.drawImage(copy, layer.offset[0], layer.offset[1]); layer.offset.V2_set(0, 0); Undo.finishEdit('Expand layer to texture size'); layer.texture.updateLayerChanges(true); } }) new Action('merge_layer_down', { icon: 'fa-caret-square-down', category: 'layers', condition: () => TextureLayer.selected, click() { TextureLayer.selected.mergeDown(true); } }) new Action('crop_layer_to_selection', { icon: 'crop', category: 'layers', condition: () => TextureLayer.selected, click() { let layer = TextureLayer.selected; let rect = layer.texture.selection.getBoundingRect(); if (!rect.width || !rect.height) return; Undo.initEdit({layers: [layer], bitmap: true}); let copy = Painter.copyCanvas(layer.canvas) layer.canvas.width = rect.width; layer.canvas.height = rect.height; layer.ctx.drawImage(copy, layer.offset[0]-rect.start_x, layer.offset[1]-rect.start_y); layer.offset.V2_set(rect.start_x, rect.start_y); layer.texture.updateChangesAfterEdit(); Undo.finishEdit('Crop layer to selection'); } }) }) Interface.definePanels(function() { Vue.component('texture-layer-icon', { props: { layer: TextureLayer }, template: '
', mounted() { this.$el.append(this.layer.canvas); } }) function eventTargetToLayer(target, texture) { let target_node = target; let i = 0; while (target_node && target_node.classList && !target_node.classList.contains('texture_layer')) { if (i < 3 && target_node) { target_node = target_node.parentNode; i++; } else { return []; } } return [texture.layers.find(layer => layer.uuid == target_node.attributes.layer_id.value), target_node]; } function getOrder(loc, obj) { if (!obj) { return; } else { if (loc <= 20) return -1; return 1; } } new Panel('layers', { icon: 'layers', growable: true, condition: () => Modes.paint && ((Texture.selected && Texture.selected.layers_enabled) || Format.image_editor), default_position: { slot: 'left_bar', float_position: [0, 0], float_size: [300, 300], height: 300 }, toolbars: [ new Toolbar('layers', { children: [ 'create_empty_layer', 'enable_texture_layers', 'layer_opacity', 'layer_blend_mode' ] }) ], component: { name: 'panel-layers', data() { return { layers: [], }}, methods: { openMenu(event) { Interface.Panels.layers.menu.show(event) }, dragLayer(e1) { if (getFocusedTextInput()) return; if (e1.button == 1 || e1.button == 2) return; convertTouchEvent(e1); let texture = Texture.selected; if (!texture) return; let [layer] = eventTargetToLayer(e1.target, texture); if (!layer || layer.locked) return; let active = false; let helper; let timeout; let drop_target, drop_target_node, order; let last_event = e1; function move(e2) { convertTouchEvent(e2); let offset = [ e2.clientX - e1.clientX, e2.clientY - e1.clientY, ] if (!active) { let distance = Math.sqrt(Math.pow(offset[0], 2) + Math.pow(offset[1], 2)) if (Blockbench.isTouch) { if (distance > 20 && timeout) { clearTimeout(timeout); timeout = null; } else { document.getElementById('layers_list').scrollTop += last_event.clientY - e2.clientY; } } else if (distance > 6) { active = true; } } else { if (e2) e2.preventDefault(); if (Menu.open) Menu.open.hide(); if (!helper) { helper = document.createElement('div'); helper.id = 'animation_drag_helper'; let icon = document.createElement('i'); icon.className = 'material-icons'; icon.innerText = 'image'; helper.append(icon); let span = document.createElement('span'); span.innerText = layer.name; helper.append(span); document.body.append(helper); } helper.style.left = `${e2.clientX}px`; helper.style.top = `${e2.clientY}px`; // drag $('.drag_hover').removeClass('drag_hover'); $('.texture_layer[order]').attr('order', null); let target = document.elementFromPoint(e2.clientX, e2.clientY); [drop_target, drop_target_node] = eventTargetToLayer(target, texture); if (drop_target) { var location = e2.clientY - $(drop_target_node).offset().top; order = getOrder(location, drop_target) drop_target_node.setAttribute('order', order) drop_target_node.classList.add('drag_hover'); } } last_event = e2; } function off(e2) { if (helper) helper.remove(); removeEventListeners(document, 'mousemove touchmove', move); removeEventListeners(document, 'mouseup touchend', off); $('.drag_hover').removeClass('drag_hover'); $('.texture_layer[order]').attr('order', null); if (Blockbench.isTouch) clearTimeout(timeout); if (active && !open_menu) { convertTouchEvent(e2); let target = document.elementFromPoint(e2.clientX, e2.clientY); [target_layer] = eventTargetToLayer(target, texture); if (!target_layer || target_layer == layer ) return; let index = texture.layers.indexOf(target_layer); if (index == -1) return; if (texture.layers.indexOf(layer) < index) index--; if (order == -1) index++; if (texture.layers[index] == layer) return; Undo.initEdit({textures: [texture]}); texture.layers.remove(layer); texture.layers.splice(index, 0, layer); texture.updateLayerChanges(true); texture.saved = false; Undo.finishEdit('Reorder layers'); } } if (Blockbench.isTouch) { timeout = setTimeout(() => { active = true; move(e1); }, 320) } addEventListeners(document, 'mousemove touchmove', move, {passive: false}); addEventListeners(document, 'mouseup touchend', off, {passive: false}); } }, template: ` ` }, menu: new Menu([ 'create_empty_layer', ]) }) })