blockbench/js/texturing/layers.js
2024-03-26 01:12:56 +01:00

776 lines
23 KiB
JavaScript

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('image/png', 1);
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.filter = `opacity(${this.opacity / 100})`;
down_layer.ctx.globalCompositeOperation = Painter.getBlendModeCompositeOperation(this.blend_mode);
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);
down_layer.ctx.filter = '';
down_layer.ctx.globalCompositeOperation = 'source-over';
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();
if (undo) {
Undo.finishEdit('Flip layer');
this.texture.updateChangesAfterEdit();
} else {
this.texture.updateLayerChanges();
this.texture.saved = false;
}
}
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();
if (undo) {
Undo.finishEdit('Rotate layer');
this.texture.updateChangesAfterEdit();
} else {
this.texture.updateLayerChanges();
this.texture.saved = false;
}
UVEditor.vue.$forceUpdate();
}
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: '<div class="layer_icon_wrapper"></div>',
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,
resizable: 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.updateChangesAfterEdit();
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: `
<ul
id="layers_list"
class="list mobile_scrollbar"
@contextmenu.stop.prevent="openMenu($event)"
@mousedown="dragLayer($event)"
@touchstart="dragLayer($event)"
>
<li
v-for="layer in layers"
:class="{ selected: layer.selected, in_limbo: layer.in_limbo }"
:key="layer.uuid"
:layer_id="layer.uuid"
class="texture_layer"
@click.stop="layer.select()"
@dblclick.stop="layer.propertiesDialog()"
@contextmenu.prevent.stop="layer.showContextMenu($event)"
>
<texture-layer-icon :layer="layer" />
<label>
{{ layer.name }}
</label>
<div class="in_list_button" @click.stop="layer.toggleVisibility()" @dblclick.stop>
<i v-if="layer.visible" class="material-icons icon">visibility</i>
<i v-else class="material-icons icon toggle_disabled">visibility_off</i>
</div>
</li>
</ul>
`
},
menu: new Menu([
'create_empty_layer',
])
})
})