8bitworkshop/src/ide/views/asseteditor.ts

390 lines
14 KiB
TypeScript

import { newDiv, ProjectView } from "./baseviews";
import { platform_id, current_project, projectWindows } from "../ui";
import { FileData } from "../../common/workertypes";
import { hex, safeident, rgb2bgr } from "../../common/util";
import * as pixed from "../pixeleditor";
import Mousetrap = require('mousetrap');
export class AssetEditorView implements ProjectView, pixed.EditorContext {
maindiv : JQuery;
cureditordiv : JQuery;
cureditelem : JQuery;
cureditnode : pixed.PixNode;
rootnodes : pixed.PixNode[];
deferrednodes : pixed.PixNode[];
createDiv(parent : HTMLElement) {
this.maindiv = newDiv(parent, "vertical-scroll");
return this.maindiv[0];
}
clearAssets() {
this.rootnodes = [];
this.deferrednodes = [];
}
registerAsset(type:string, node:pixed.PixNode, deferred:number) {
this.rootnodes.push(node);
if (deferred) {
if (deferred > 1)
this.deferrednodes.push(node);
else
this.deferrednodes.unshift(node);
} else {
node.refreshRight();
}
}
getPalettes(matchlen : number) : pixed.SelectablePalette[] {
var result = [];
this.rootnodes.forEach((node) => {
while (node != null) {
if (node instanceof pixed.PaletteFormatToRGB) {
// TODO: move to node class?
var palette = node.palette;
// match full palette length?
if (matchlen == palette.length) {
result.push({node:node, name:"Palette", palette:palette});
}
// look at palette slices
if (node.layout) {
node.layout.forEach(([name, start, len]) => {
if (start < palette.length) {
if (len == matchlen) {
var rgbs = palette.slice(start, start+len);
result.push({node:node, name:name, palette:rgbs});
} else if (-len == matchlen) { // reverse order
var rgbs = palette.slice(start, start-len);
rgbs.reverse();
result.push({node:node, name:name, palette:rgbs});
} else if (len+1 == matchlen) {
var rgbs = new Uint32Array(matchlen);
rgbs[0] = palette[0];
rgbs.set(palette.slice(start, start+len), 1);
result.push({node:node, name:name, palette:rgbs});
}
}
});
}
break;
}
node = node.right;
}
});
return result;
}
getTilemaps(matchlen : number) : pixed.SelectableTilemap[] {
var result = [];
this.rootnodes.forEach((node) => {
while (node != null) {
if (node instanceof pixed.Palettizer) {
var rgbimgs = node.rgbimgs;
if (rgbimgs && rgbimgs.length >= matchlen) {
result.push({node:node, name:"Tilemap", images:node.images, rgbimgs:rgbimgs}); // TODO
}
}
node = node.right;
}
});
return result;
}
isEditing() {
return this.cureditordiv != null;
}
getCurrentEditNode() {
return this.cureditnode;
}
setCurrentEditor(div:JQuery, editing:JQuery, node:pixed.PixNode) {
const timeout = 250;
if (this.cureditordiv != div) {
if (this.cureditordiv) {
this.cureditordiv.hide(timeout);
this.cureditordiv = null;
}
if (div) {
this.cureditordiv = div;
this.cureditordiv.show();
this.cureditordiv[0].scrollIntoView({behavior: "smooth", block: "center"});
//setTimeout(() => { this.cureditordiv[0].scrollIntoView({behavior: "smooth", block: "center"}) }, timeout);
}
}
if (this.cureditelem) {
this.cureditelem.removeClass('selected');
this.cureditelem = null;
}
if (editing) {
this.cureditelem = editing;
this.cureditelem.addClass('selected');
}
while (node.left) {
node = node.left;
}
this.cureditnode = node;
}
scanFileTextForAssets(id : string, data : string) {
// scan file for assets
// /*{json}*/ or ;;{json};;
// TODO: put before ident, look for = {
var result = [];
var re1 = /[/;][*;]([{].+[}])[*;][/;]/g;
var m;
while (m = re1.exec(data)) {
var start = m.index + m[0].length;
var end;
// TODO: verilog end
if (platform_id == 'verilog') {
end = data.indexOf("end", start); // asm
} else if (m[0].startsWith(';;')) {
end = data.indexOf(';;', start); // asm
} else {
end = data.indexOf(';', start); // C
}
//console.log(id, start, end, m[1], data.substring(start,end));
if (end > start) {
try {
var jsontxt = m[1].replace(/([A-Za-z]+):/g, '"$1":'); // fix lenient JSON
var json = JSON.parse(jsontxt);
// TODO: name?
result.push({fileid:id,fmt:json,start:start,end:end});
} catch (e) {
console.log(e);
}
}
}
// look for DEF_METASPRITE_2x2(playerRStand, 0xd8, 0)
// TODO: could also look in ROM
var re2 = /DEF_METASPRITE_(\d+)x(\d+)[(](\w+),\s*(\w+),\s*(\w+)/gi;
while (m = re2.exec(data)) {
var width = parseInt(m[1]);
var height = parseInt(m[2]);
var ident = m[3];
var tile = parseInt(m[4]);
var attr = parseInt(m[5]);
var metadefs = [];
for (var x=0; x<width; x++) {
for (var y=0; y<height; y++) {
metadefs.push({x:x*8, y:y*8, tile:tile, attr:attr});
}
}
var meta = {defs:metadefs,width:width*8,height:height*8};
result.push({fileid:id,label:ident,meta:meta});
}
return result;
}
// TODO: move to pixeleditor.ts?
addPaletteEditorViews(parentdiv:JQuery, pal2rgb:pixed.PaletteFormatToRGB, callback) {
var adual = $('<div class="asset_dual"/>').appendTo(parentdiv);
var aeditor = $('<div class="asset_editor"/>').hide(); // contains editor, when selected
// TODO: they need to update when refreshed from right
var allrgbimgs = [];
pal2rgb.getAllColors().forEach((rgba) => { allrgbimgs.push(new Uint32Array([rgba])); }); // array of array of 1 rgb color (for picker)
var atable = $('<table/>').appendTo(adual);
aeditor.appendTo(adual);
// make default layout if not exists
var layout = pal2rgb.layout;
if (!layout) {
var len = pal2rgb.palette.length;
var imgsperline = len > 32 ? 8 : 4; // TODO: use 'n'?
layout = [];
for (var i=0; i<len; i+=imgsperline) {
layout.push(["", i, Math.min(len-i,imgsperline)]);
}
}
function updateCell(cell, j) {
var val = pal2rgb.words[j];
var rgb = pal2rgb.palette[j];
var hexcol = '#'+hex(rgb2bgr(rgb),6);
var textcol = (rgb & 0x008000) ? 'black' : 'white';
cell.text(hex(val,2)).css('background-color',hexcol).css('color',textcol);
}
// iterate over each row of the layout
layout.forEach( ([name, start, len]) => {
if (start < pal2rgb.palette.length) { // skip row if out of range
var arow = $('<tr/>').appendTo(atable);
$('<td/>').text(name).appendTo(arow);
var inds = [];
for (var k=start; k<start+Math.abs(len); k++)
inds.push(k);
if (len < 0)
inds.reverse();
inds.forEach( (i) => {
var cell = $('<td/>').addClass('asset_cell asset_editable').appendTo(arow);
updateCell(cell, i);
cell.click((e) => {
var chooser = new pixed.ImageChooser();
chooser.rgbimgs = allrgbimgs;
chooser.width = 1;
chooser.height = 1;
chooser.recreate(aeditor, (index, newvalue) => {
callback(i, index);
updateCell(cell, i);
});
this.setCurrentEditor(aeditor, cell, pal2rgb);
});
});
}
});
}
addPixelEditor(parentdiv:JQuery, firstnode:pixed.PixNode, fmt:pixed.PixelEditorImageFormat) {
// data -> pixels
fmt.xform = 'scale(2)';
var mapper = new pixed.Mapper(fmt);
// TODO: rotate node?
firstnode.addRight(mapper);
// pixels -> RGBA
var palizer = new pixed.Palettizer(this, fmt);
mapper.addRight(palizer);
// add view objects
palizer.addRight(new pixed.CharmapEditor(this, newDiv(parentdiv), fmt));
}
addPaletteEditor(parentdiv:JQuery, firstnode:pixed.PixNode, palfmt?) {
// palette -> RGBA
var pal2rgb = new pixed.PaletteFormatToRGB(palfmt);
firstnode.addRight(pal2rgb);
// TODO: refresh twice?
firstnode.refreshRight();
// TODO: add view objects
// TODO: show which one is selected?
this.addPaletteEditorViews(parentdiv, pal2rgb,
(index, newvalue) => {
console.log('set entry', index, '=', newvalue);
// TODO: this forces update of palette rgb colors and file data
firstnode.words[index] = newvalue;
pal2rgb.words = null;
pal2rgb.updateRight();
pal2rgb.refreshLeft();
});
}
ensureFileDiv(fileid : string) : JQuery<HTMLElement> {
var divid = this.getFileDivId(fileid);
var body = $(document.getElementById(divid));
if (body.length === 0) {
var filediv = newDiv(this.maindiv, 'asset_file');
var header = newDiv(filediv, 'asset_file_header').text(fileid);
body = newDiv(filediv).attr('id',divid).addClass('disable-select');
}
return body;
}
refreshAssetsInFile(fileid : string, data : FileData) : number {
let nassets = 0;
// TODO: check fmt w/h/etc limits
// TODO: defer editor creation
// TODO: only refresh when needed
if (platform_id.startsWith('nes') && fileid.endsWith('.chr') && data instanceof Uint8Array) {
// is this a NES CHR?
let node = new pixed.FileDataNode(projectWindows, fileid);
const neschrfmt = {w:8,h:8,bpp:1,count:(data.length>>4),brev:true,np:2,pofs:8,remap:[0,1,2,4,5,6,7,8,9,10,11,12]}; // TODO
this.addPixelEditor(this.ensureFileDiv(fileid), node, neschrfmt);
this.registerAsset("charmap", node, 1);
nassets++;
} else if (platform_id.startsWith('nes') && fileid.endsWith('.pal') && data instanceof Uint8Array) {
// is this a NES PAL?
let node = new pixed.FileDataNode(projectWindows, fileid);
const nespalfmt = {pal:"nes",layout:"nes"};
this.addPaletteEditor(this.ensureFileDiv(fileid), node, nespalfmt);
this.registerAsset("palette", node, 0);
nassets++;
} else if (typeof data === 'string') {
let textfrags = this.scanFileTextForAssets(fileid, data);
for (let frag of textfrags) {
if (frag.fmt) {
let label = fileid; // TODO: label
let node : pixed.PixNode = new pixed.TextDataNode(projectWindows, fileid, label, frag.start, frag.end);
let first = node;
// rle-compressed? TODO: how to edit?
if (frag.fmt.comp == 'rletag') {
node = node.addRight(new pixed.Compressor());
}
// is this a nes nametable?
if (frag.fmt.map == 'nesnt') {
node = node.addRight(new pixed.NESNametableConverter(this));
node = node.addRight(new pixed.Palettizer(this, {w:8,h:8,bpp:4}));
const fmt = {w:8*(frag.fmt.w||32),h:8*(frag.fmt.h||30),count:1}; // TODO: can't do custom sizes
node = node.addRight(new pixed.MapEditor(this, newDiv(this.ensureFileDiv(fileid)), fmt));
this.registerAsset("nametable", first, 2);
nassets++;
}
// is this a bitmap?
else if (frag.fmt.w > 0 && frag.fmt.h > 0) {
this.addPixelEditor(this.ensureFileDiv(fileid), node, frag.fmt);
this.registerAsset("charmap", first, 1);
nassets++;
}
// is this a palette?
else if (frag.fmt.pal) {
this.addPaletteEditor(this.ensureFileDiv(fileid), node, frag.fmt);
this.registerAsset("palette", first, 0);
nassets++;
}
else {
// TODO: other kinds of resources?
}
}
}
}
return nassets;
}
getFileDivId(id : string) {
return '__asset__' + safeident(id);
}
// TODO: recreate editors when refreshing
// TODO: look for changes, not moveCursor
refresh(moveCursor : boolean) {
// clear and refresh all files/nodes?
if (moveCursor) {
this.maindiv.empty();
this.clearAssets();
current_project.iterateFiles((fileid, data) => {
try {
var nassets = this.refreshAssetsInFile(fileid, data);
} catch (e) {
console.log(e);
this.ensureFileDiv(fileid).text(e+""); // TODO: error msg?
}
});
console.log("Found " + this.rootnodes.length + " assets");
this.deferrednodes.forEach((node) => {
try {
node.refreshRight();
} catch (e) {
console.log(e);
alert(e+"");
}
});
this.deferrednodes = [];
} else {
// only refresh nodes if not actively editing
// since we could be in the middle of an operation that hasn't been committed
for (var node of this.rootnodes) {
if (node !== this.getCurrentEditNode()) {
node.refreshRight();
}
}
}
}
setVisible?(showing : boolean) : void {
// TODO: make into toolbar?
if (showing) {
if (Mousetrap.bind) Mousetrap.bind('ctrl+z', projectWindows.undoStep.bind(projectWindows));
} else {
if (Mousetrap.unbind) Mousetrap.unbind('ctrl+z');
}
}
}