1933 lines
60 KiB
HTML
1933 lines
60 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="pl">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>Trakodag Web — Code Image Editor</title>
|
||
<style>
|
||
:root {
|
||
--bg: #14171c;
|
||
--bg2: #1b1f27;
|
||
--bg3: #232936;
|
||
--fg: #e6e6e6;
|
||
--fg-dim: #98a0ad;
|
||
--accent: #6cf;
|
||
--accent2: #ffb86c;
|
||
--danger: #ff6b6b;
|
||
--ok: #7ee787;
|
||
--border: #2a3142;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
html, body {
|
||
margin: 0; padding: 0; height: 100%;
|
||
background: var(--bg); color: var(--fg);
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
|
||
font-size: 13px;
|
||
overflow: hidden;
|
||
}
|
||
header {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 8px 12px;
|
||
background: var(--bg2);
|
||
border-bottom: 1px solid var(--border);
|
||
height: 46px;
|
||
}
|
||
header .title {
|
||
font-weight: 700; letter-spacing: 0.5px;
|
||
color: var(--accent);
|
||
margin-right: 8px;
|
||
}
|
||
header .title span { color: var(--accent2); }
|
||
header .sep { width: 1px; height: 24px; background: var(--border); margin: 0 4px; }
|
||
button, label.btn, select, input[type="number"], input[type="text"] {
|
||
background: var(--bg3);
|
||
color: var(--fg);
|
||
border: 1px solid var(--border);
|
||
padding: 5px 10px;
|
||
border-radius: 4px;
|
||
font-family: inherit;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
transition: background 0.15s, border 0.15s;
|
||
}
|
||
button:hover, label.btn:hover, select:hover { background: #2c3548; border-color: #3a455c; }
|
||
button:active { transform: translateY(1px); }
|
||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
button:disabled:hover { background: var(--bg3); border-color: var(--border); }
|
||
button.primary { background: #2557a7; border-color: #2e6fd1; color: #fff; }
|
||
button.primary:hover { background: #2e6fd1; }
|
||
button.danger { background: #5b2222; border-color: #7a3333; }
|
||
button.danger:hover { background: #7a3333; }
|
||
input[type="file"] { display: none; }
|
||
.spacer { flex: 1; }
|
||
.status { color: var(--fg-dim); font-size: 11px; padding: 0 8px; }
|
||
.status.err { color: var(--danger); }
|
||
.status.ok { color: var(--ok); }
|
||
|
||
main {
|
||
display: grid;
|
||
grid-template-columns: 1fr 460px;
|
||
height: calc(100vh - 46px);
|
||
width: 100vw;
|
||
}
|
||
|
||
/* === LEFT: canvas === */
|
||
#canvasPane {
|
||
position: relative;
|
||
background: #0c0e12;
|
||
overflow: hidden;
|
||
display: flex; align-items: center; justify-content: center;
|
||
background-image:
|
||
linear-gradient(45deg, #1a1d23 25%, transparent 25%),
|
||
linear-gradient(-45deg, #1a1d23 25%, transparent 25%),
|
||
linear-gradient(45deg, transparent 75%, #1a1d23 75%),
|
||
linear-gradient(-45deg, transparent 75%, #1a1d23 75%);
|
||
background-size: 20px 20px;
|
||
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
|
||
}
|
||
#canvasWrap {
|
||
position: relative;
|
||
box-shadow: 0 0 40px rgba(0,0,0,0.5);
|
||
}
|
||
#canvasOut, #canvasOrig {
|
||
display: block;
|
||
image-rendering: pixelated;
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
}
|
||
#canvasOrig {
|
||
position: absolute; inset: 0;
|
||
opacity: 0; pointer-events: none;
|
||
transition: opacity 0.05s;
|
||
}
|
||
#canvasWrap.showOrig #canvasOrig { opacity: 1; }
|
||
#canvasWrap.showOrig::after {
|
||
content: "ORIGINAL";
|
||
position: absolute; top: 8px; left: 8px;
|
||
background: rgba(0,0,0,0.7);
|
||
color: var(--accent2);
|
||
padding: 3px 8px;
|
||
border-radius: 3px;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
letter-spacing: 1px;
|
||
pointer-events: none;
|
||
}
|
||
#canvasInfo {
|
||
position: absolute; bottom: 8px; left: 8px;
|
||
background: rgba(0,0,0,0.78);
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
color: var(--fg);
|
||
pointer-events: none;
|
||
font-family: monospace;
|
||
max-width: calc(100% - 16px);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
#canvasWrap { cursor: crosshair; }
|
||
#canvasWrap.showOrig { cursor: zoom-in; }
|
||
#emptyHint {
|
||
color: var(--fg-dim);
|
||
text-align: center;
|
||
line-height: 1.7;
|
||
}
|
||
#emptyHint strong { color: var(--accent); }
|
||
|
||
/* === RIGHT: editor === */
|
||
#rightPane {
|
||
display: grid;
|
||
grid-template-rows: auto auto 1fr auto;
|
||
background: var(--bg2);
|
||
border-left: 1px solid var(--border);
|
||
min-width: 0;
|
||
}
|
||
.panelHead {
|
||
display: flex; align-items: center; gap: 6px;
|
||
padding: 6px 10px;
|
||
background: var(--bg3);
|
||
border-bottom: 1px solid var(--border);
|
||
font-size: 11px;
|
||
color: var(--fg-dim);
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
font-weight: 700;
|
||
}
|
||
.panelHead button { padding: 3px 8px; font-size: 11px; }
|
||
|
||
/* Layers */
|
||
#layersPanel {
|
||
max-height: 180px;
|
||
overflow-y: auto;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.layer {
|
||
display: flex; flex-direction: column; gap: 4px;
|
||
padding: 6px 10px;
|
||
border-bottom: 1px solid #1c2230;
|
||
cursor: pointer;
|
||
background: var(--bg2);
|
||
}
|
||
.layer:hover { background: #1f2532; }
|
||
.layer.active { background: #2a3a52; }
|
||
.layer .topRow {
|
||
display: flex; align-items: center; gap: 6px;
|
||
width: 100%;
|
||
}
|
||
.layer .vis {
|
||
width: 18px; height: 18px; flex: 0 0 18px;
|
||
text-align: center; line-height: 18px;
|
||
user-select: none;
|
||
color: var(--fg-dim);
|
||
}
|
||
.layer .vis.on { color: var(--ok); }
|
||
.layer .name {
|
||
flex: 1; min-width: 0;
|
||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||
font-size: 12px;
|
||
}
|
||
.layer .name input {
|
||
background: var(--bg3); border: 1px solid var(--border);
|
||
color: var(--fg); padding: 2px 4px; font-size: 12px;
|
||
width: 100%;
|
||
}
|
||
.layer select, .layer input[type="number"] {
|
||
padding: 2px 4px; font-size: 11px;
|
||
}
|
||
.layer .opacityWrap {
|
||
display: flex; align-items: center; gap: 8px;
|
||
width: 100%;
|
||
padding-left: 24px; /* align under name, past .vis */
|
||
}
|
||
.layer .opacityWrap .opLabel {
|
||
font-size: 10px;
|
||
color: var(--fg-dim);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
flex: 0 0 auto;
|
||
}
|
||
.layer .opacitySlider {
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
flex: 1;
|
||
height: 4px;
|
||
background: var(--bg3);
|
||
border-radius: 2px;
|
||
outline: none;
|
||
cursor: pointer;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
.layer .opacitySlider::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
width: 12px; height: 12px;
|
||
background: var(--accent);
|
||
border-radius: 50%;
|
||
border: 1px solid #1a2030;
|
||
cursor: pointer;
|
||
}
|
||
.layer .opacitySlider::-moz-range-thumb {
|
||
width: 12px; height: 12px;
|
||
background: var(--accent);
|
||
border-radius: 50%;
|
||
border: 1px solid #1a2030;
|
||
cursor: pointer;
|
||
}
|
||
.layer .opacityVal {
|
||
font-family: monospace;
|
||
font-size: 10.5px;
|
||
color: var(--fg-dim);
|
||
min-width: 28px;
|
||
text-align: right;
|
||
}
|
||
.layer .actions { display: flex; gap: 2px; }
|
||
.layer .actions button { padding: 2px 6px; font-size: 11px; }
|
||
|
||
/* Images panel */
|
||
#imagesPanel {
|
||
border-bottom: 1px solid var(--border);
|
||
max-height: 110px;
|
||
overflow-y: auto;
|
||
}
|
||
#imagesList {
|
||
display: flex; gap: 6px; padding: 8px 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.imgThumb {
|
||
position: relative;
|
||
width: 64px; height: 64px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
background: #000;
|
||
}
|
||
.imgThumb canvas { width: 100%; height: 100%; object-fit: contain; image-rendering: pixelated; }
|
||
.imgThumb .label {
|
||
position: absolute; bottom: 0; left: 0; right: 0;
|
||
background: rgba(0,0,0,0.75);
|
||
color: var(--accent);
|
||
font-size: 10px;
|
||
padding: 2px 4px;
|
||
text-align: center;
|
||
font-family: monospace;
|
||
}
|
||
.imgThumb .del {
|
||
position: absolute; top: 2px; right: 2px;
|
||
background: rgba(255,0,0,0.7);
|
||
color: #fff; border: none;
|
||
width: 16px; height: 16px; border-radius: 50%;
|
||
line-height: 14px; font-size: 11px; padding: 0;
|
||
cursor: pointer;
|
||
}
|
||
.imgEmpty { color: var(--fg-dim); padding: 8px 10px; font-size: 11px; }
|
||
|
||
/* Editor */
|
||
#editorWrap {
|
||
display: flex; flex-direction: column;
|
||
min-height: 0;
|
||
}
|
||
#editorBox {
|
||
position: relative;
|
||
flex: 1;
|
||
overflow: hidden;
|
||
background: #0e1117;
|
||
}
|
||
#editor, #highlight {
|
||
position: absolute;
|
||
top: 0; left: 0; right: 0; bottom: 0;
|
||
margin: 0;
|
||
padding: 10px 12px;
|
||
font-family: "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
tab-size: 2;
|
||
-moz-tab-size: 2;
|
||
white-space: pre;
|
||
border: none;
|
||
box-sizing: border-box;
|
||
overflow: auto;
|
||
}
|
||
#highlight {
|
||
color: #d6deeb;
|
||
pointer-events: none;
|
||
z-index: 1;
|
||
overflow: hidden;
|
||
}
|
||
#editor {
|
||
color: transparent;
|
||
background: transparent;
|
||
-webkit-text-fill-color: transparent;
|
||
caret-color: #ffffff;
|
||
resize: none;
|
||
outline: none;
|
||
z-index: 2;
|
||
}
|
||
#editor::selection { background: #2e6fd1; -webkit-text-fill-color: transparent; }
|
||
#editor::-moz-selection { background: #2e6fd1; -webkit-text-fill-color: transparent; }
|
||
|
||
/* syntax tokens */
|
||
.tk-cm { color: #6a737d; font-style: italic; }
|
||
.tk-str { color: #a5e075; }
|
||
.tk-num { color: var(--accent2); }
|
||
.tk-kw { color: #c792ea; font-weight: 600; }
|
||
.tk-api { color: var(--accent); font-weight: 600; }
|
||
.tk-fn { color: #82aaff; }
|
||
.tk-op { color: #89ddff; }
|
||
.tk-id { color: #d6deeb; }
|
||
|
||
/* Vars / API reference panel */
|
||
#varsPanel {
|
||
background: var(--bg2);
|
||
border-top: 1px solid var(--border);
|
||
border-bottom: 1px solid var(--border);
|
||
max-height: 240px;
|
||
overflow-y: auto;
|
||
display: none;
|
||
}
|
||
#varsPanel.open { display: block; }
|
||
.varsGroup {
|
||
padding: 6px 10px 8px;
|
||
border-bottom: 1px dashed #1c2230;
|
||
}
|
||
.varsGroup:last-child { border-bottom: none; }
|
||
.varsGroup h5 {
|
||
margin: 0 0 4px;
|
||
font-size: 10px;
|
||
color: var(--accent2);
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
font-weight: 700;
|
||
}
|
||
.varsList {
|
||
display: flex; flex-wrap: wrap; gap: 4px;
|
||
}
|
||
.varChip {
|
||
display: inline-flex; align-items: center;
|
||
background: #0e1117;
|
||
border: 1px solid var(--border);
|
||
border-radius: 3px;
|
||
padding: 2px 6px;
|
||
font-family: monospace;
|
||
font-size: 11px;
|
||
color: var(--accent);
|
||
cursor: pointer;
|
||
transition: background 0.1s, border 0.1s;
|
||
}
|
||
.varChip:hover {
|
||
background: #1a2030;
|
||
border-color: var(--accent);
|
||
}
|
||
.varChip .desc {
|
||
color: var(--fg-dim);
|
||
margin-left: 6px;
|
||
font-family: inherit;
|
||
font-size: 10.5px;
|
||
}
|
||
|
||
/* Console */
|
||
#console {
|
||
background: #0a0c10;
|
||
border-top: 1px solid var(--border);
|
||
padding: 6px 10px;
|
||
font-family: monospace;
|
||
font-size: 11px;
|
||
color: var(--fg-dim);
|
||
height: 100px;
|
||
overflow-y: auto;
|
||
white-space: pre-wrap;
|
||
}
|
||
#console .err { color: var(--danger); }
|
||
#console .ok { color: var(--ok); }
|
||
#console .info { color: var(--accent); }
|
||
|
||
/* misc */
|
||
.toggle {
|
||
display: inline-flex; align-items: center; gap: 5px;
|
||
color: var(--fg-dim); font-size: 11px;
|
||
}
|
||
.toggle input { accent-color: var(--accent); }
|
||
|
||
/* dialog popover */
|
||
.help {
|
||
position: absolute;
|
||
top: 50px; right: 470px;
|
||
background: var(--bg2);
|
||
border: 1px solid var(--border);
|
||
padding: 16px;
|
||
border-radius: 6px;
|
||
max-width: 460px;
|
||
max-height: 70vh;
|
||
overflow: auto;
|
||
z-index: 50;
|
||
font-size: 12px;
|
||
line-height: 1.55;
|
||
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
|
||
display: none;
|
||
}
|
||
.help.open { display: block; }
|
||
.help h3 { margin: 0 0 8px; color: var(--accent); }
|
||
.help h4 { margin: 12px 0 4px; color: var(--accent2); }
|
||
.help code { background: #0a0c10; padding: 1px 5px; border-radius: 3px; color: #d6deeb; font-size: 11.5px; }
|
||
.help pre {
|
||
background: #0a0c10;
|
||
padding: 8px;
|
||
border-radius: 4px;
|
||
overflow-x: auto;
|
||
font-size: 11.5px;
|
||
font-family: "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace;
|
||
line-height: 1.45;
|
||
color: #d6deeb;
|
||
white-space: pre;
|
||
tab-size: 2;
|
||
-moz-tab-size: 2;
|
||
}
|
||
.help .x { float: right; cursor: pointer; color: var(--fg-dim); }
|
||
|
||
/* modal */
|
||
.modal {
|
||
position: fixed; inset: 0;
|
||
background: rgba(0,0,0,0.7);
|
||
z-index: 100;
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.modal.open { display: flex; }
|
||
.modalCard {
|
||
background: var(--bg2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
max-width: 540px;
|
||
width: 90%;
|
||
max-height: 80vh;
|
||
overflow-y: auto;
|
||
box-shadow: 0 12px 50px rgba(0,0,0,0.7);
|
||
}
|
||
.modalCard h3 { margin: 0 0 4px; color: var(--accent); }
|
||
.modalCard .sub { color: var(--fg-dim); font-size: 11.5px; margin-bottom: 10px; }
|
||
.presetItem {
|
||
padding: 10px 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
margin: 6px 0;
|
||
cursor: pointer;
|
||
background: var(--bg3);
|
||
transition: background 0.1s, border 0.1s;
|
||
}
|
||
.presetItem:hover { background: #2c3548; border-color: var(--accent); }
|
||
.presetItem h4 { margin: 0 0 4px; color: var(--accent); font-size: 13px; }
|
||
.presetItem p { margin: 0; color: var(--fg-dim); font-size: 11.5px; line-height: 1.45; }
|
||
.modalActions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 10px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header>
|
||
<div class="title">trakodag<span>.web</span></div>
|
||
|
||
<label class="btn" for="fileMain">Load image</label>
|
||
<input type="file" id="fileMain" accept="image/*">
|
||
|
||
<label class="btn" for="fileExtra">+ Add image</label>
|
||
<input type="file" id="fileExtra" accept="image/*" multiple>
|
||
|
||
<div class="sep"></div>
|
||
|
||
<button id="runBtn" class="primary">Run ▶</button>
|
||
<label class="toggle"><input type="checkbox" id="autoRun" checked> auto-run</label>
|
||
|
||
<div class="sep"></div>
|
||
|
||
<button id="undoBtn" title="Undo (Ctrl/Cmd+Z)" disabled>↶ Undo</button>
|
||
<button id="redoBtn" title="Redo (Ctrl/Cmd+Y, Ctrl/Cmd+Shift+Z)" disabled>↷ Redo</button>
|
||
|
||
<div class="sep"></div>
|
||
|
||
<label class="toggle">scale
|
||
<select id="scaleSel">
|
||
<option value="1">1x</option>
|
||
<option value="0.5">0.5x</option>
|
||
<option value="0.25" selected>0.25x</option>
|
||
<option value="0.125">0.125x</option>
|
||
</select>
|
||
</label>
|
||
|
||
<label class="toggle">theme
|
||
<select id="themeSel">
|
||
<option value="blue">blue / orange</option>
|
||
<option value="purpleSea">purple / sea</option>
|
||
</select>
|
||
</label>
|
||
|
||
<div class="sep"></div>
|
||
|
||
<button id="exportBtn">Export PNG</button>
|
||
|
||
<div class="sep"></div>
|
||
|
||
<button id="saveProjectBtn" title="save layers + code as a .trakodag.json file">Save project</button>
|
||
<label class="btn" for="loadProjectFile" title="load layers + code from a .trakodag.json file">Load project</label>
|
||
<input type="file" id="loadProjectFile" accept=".json,application/json">
|
||
|
||
<button id="helpBtn">?</button>
|
||
|
||
<div class="spacer"></div>
|
||
<div id="status" class="status">no image loaded</div>
|
||
</header>
|
||
|
||
<main>
|
||
<div id="canvasPane">
|
||
<div id="emptyHint">
|
||
<strong>Load an image</strong> to start.<br>
|
||
Write code on the right — each layer is its own program.<br>
|
||
Hover the canvas to see the original.
|
||
</div>
|
||
<div id="canvasWrap" style="display:none">
|
||
<canvas id="canvasOut"></canvas>
|
||
<canvas id="canvasOrig"></canvas>
|
||
<div id="canvasInfo"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="rightPane">
|
||
<div class="panelHead">
|
||
Layers
|
||
<div style="flex:1"></div>
|
||
<button id="addLayer">+ Layer</button>
|
||
</div>
|
||
<div id="layersPanel"></div>
|
||
|
||
<div id="editorWrap">
|
||
<div class="panelHead">
|
||
Code <span style="text-transform:none;color:var(--fg-dim);font-weight:400;margin-left:6px" id="editorLabel"></span>
|
||
<div style="flex:1"></div>
|
||
<button id="varsBtn" title="show special variables you can use in code">ⓘ Vars</button>
|
||
<button id="resetCode">Reset to template</button>
|
||
</div>
|
||
<div id="editorBox">
|
||
<pre id="highlight" aria-hidden="true"></pre>
|
||
<textarea id="editor" spellcheck="false" wrap="off" autocomplete="off" autocorrect="off" autocapitalize="off"></textarea>
|
||
</div>
|
||
<div id="varsPanel"></div>
|
||
</div>
|
||
|
||
<div class="panelHead">
|
||
Images
|
||
<div style="flex:1"></div>
|
||
<span style="text-transform:none;font-weight:400;color:var(--fg-dim)">access in code as <code>images[i]</code></span>
|
||
</div>
|
||
<div id="imagesPanel">
|
||
<div id="imagesList"><div class="imgEmpty">no extra images — use "+ Add image"</div></div>
|
||
</div>
|
||
|
||
<div id="console"></div>
|
||
</div>
|
||
</main>
|
||
|
||
<div class="modal" id="modal">
|
||
<div class="modalCard">
|
||
<h3 id="modalTitle">Pick a preset</h3>
|
||
<div class="sub" id="modalSub"></div>
|
||
<div id="modalList"></div>
|
||
<div class="modalActions">
|
||
<button id="modalCancel">Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="help" id="help">
|
||
<span class="x" id="helpX">×</span>
|
||
<h3>Code API</h3>
|
||
<p>Each <b>layer</b> runs its own JS code as a step in a pipeline:<br>
|
||
<code>original → layer1 → layer2 → … → final</code><br>
|
||
A layer's <code>prev</code> is the previous layer's output (or the original for the bottom layer).
|
||
Write to <code>output</code> — the result is fed as <code>prev</code> to the next layer.
|
||
The "strength" number on each layer is a soft mix: <code>lerp(prev, output, strength)</code>.</p>
|
||
|
||
<h4>Available variables</h4>
|
||
<pre>width, height // image size (after scale)
|
||
original.pixels // Uint8ClampedArray RGBA of source
|
||
original.width, .height
|
||
original.getPixel(x,y) // -> {r,g,b,a}
|
||
prev // same shape — previous layer's output
|
||
// (= original for the bottom layer)
|
||
images[i] // additional images you added
|
||
// .width .height .pixels .getPixel(x,y)
|
||
output // Uint8ClampedArray you write to
|
||
// length = width*height*4 (RGBA)</pre>
|
||
|
||
<h4>Helpers</h4>
|
||
<pre>getPixel(x, y) // reads from prev
|
||
setPixel(x, y, r, g, b, a=255)
|
||
forEach((x, y, p) => { p.r += 5; return p; })
|
||
// iterates every pixel of prev, writes back
|
||
clamp(v, lo=0, hi=255)
|
||
lerp(a, b, t)
|
||
PI, TAU, E
|
||
sin, cos, tan, asin, acos, atan, atan2,
|
||
abs, sqrt, pow, exp, log, floor, ceil, round, min, max,
|
||
random, hypot, sign</pre>
|
||
|
||
<h4>Examples — basics</h4>
|
||
<pre>// brightness +5
|
||
forEach((x,y,p) => { p.r=clamp(p.r+5); p.g=clamp(p.g+5); p.b=clamp(p.b+5); });
|
||
|
||
// invert
|
||
forEach((x,y,p) => { p.r=255-p.r; p.g=255-p.g; p.b=255-p.b; });
|
||
|
||
// grayscale
|
||
forEach((x,y,p) => {
|
||
const g = (p.r * 0.299 + p.g * 0.587 + p.b * 0.114) | 0;
|
||
p.r = p.g = p.b = g;
|
||
});</pre>
|
||
|
||
<h4>Examples — pixel coordinates / index</h4>
|
||
<pre>// max blue at column x === 50
|
||
forEach((x, y, p) => {
|
||
if (x === 50) { p.r = 0; p.g = 0; p.b = 255; }
|
||
});
|
||
|
||
// horizontal red line at y === 100, 1px tall
|
||
forEach((x, y, p) => {
|
||
if (y === 100) { p.r = 255; p.g = 0; p.b = 0; }
|
||
});
|
||
|
||
// every 500th pixel (by ordinal index): green channel * 2
|
||
forEach((x, y, p) => {
|
||
const i = y * width + x; // ordinal pixel index
|
||
if (i % 500 === 0) p.g = clamp(p.g * 2);
|
||
});
|
||
|
||
// vertical stripes every 20 px
|
||
forEach((x, y, p) => {
|
||
if (x % 20 === 0) { p.r = 255; p.g = 255; p.b = 0; }
|
||
});
|
||
|
||
// checkerboard tint
|
||
forEach((x, y, p) => {
|
||
if (((x >> 4) + (y >> 4)) & 1) p.b = clamp(p.b + 80);
|
||
});</pre>
|
||
|
||
<h4>Examples — random</h4>
|
||
<pre>// pure noise
|
||
forEach((x, y, p) => {
|
||
p.r = random256(); p.g = random256(); p.b = random256();
|
||
});
|
||
|
||
// salt & pepper: 1% pixels become white, 1% black
|
||
forEach((x, y, p) => {
|
||
const r = random1();
|
||
if (r < 0.01) { p.r = p.g = p.b = 255; }
|
||
else if (r < 0.02) { p.r = p.g = p.b = 0; }
|
||
});
|
||
|
||
// random R/B channel swap on half the pixels
|
||
forEach((x, y, p) => {
|
||
if (randomBoolean()) { const t = p.r; p.r = p.b; p.b = t; }
|
||
});
|
||
|
||
// jittered brightness ±30
|
||
forEach((x, y, p) => {
|
||
const j = randomInt(-30, 30);
|
||
p.r = clamp(p.r + j); p.g = clamp(p.g + j); p.b = clamp(p.b + j);
|
||
});
|
||
|
||
// 30% chance: take pixel from random spot in original
|
||
forEach((x, y, p) => {
|
||
if (random1() < 0.3) {
|
||
const q = original.getPixel(randomInt(0, width-1), randomInt(0, height-1));
|
||
p.r = q.r; p.g = q.g; p.b = q.b;
|
||
}
|
||
});</pre>
|
||
|
||
<h4>Examples — geometry & math</h4>
|
||
<pre>// sine wave horizontal displacement
|
||
for (let y = 0; y < height; y++) {
|
||
for (let x = 0; x < width; x++) {
|
||
const dx = floor(sin(y * 0.05) * 20);
|
||
const p = prev.getPixel((x + dx + width) % width, y);
|
||
setPixel(x, y, p.r, p.g, p.b, p.a);
|
||
}
|
||
}
|
||
|
||
// radial gradient over original (darken edges)
|
||
const cx = width / 2, cy = height / 2;
|
||
const maxD = hypot(cx, cy);
|
||
forEach((x, y, p) => {
|
||
const d = hypot(x - cx, y - cy) / maxD; // 0..1
|
||
const k = 1 - d;
|
||
p.r = (p.r * k) | 0;
|
||
p.g = (p.g * k) | 0;
|
||
p.b = (p.b * k) | 0;
|
||
});</pre>
|
||
|
||
<h4>Examples — second image</h4>
|
||
<pre>// average with images[0] (added via "+ Add image")
|
||
const img = images[0];
|
||
forEach((x, y, p) => {
|
||
const q = img.getPixel(x % img.width, y % img.height);
|
||
p.r = (p.r + q.r) >> 1;
|
||
p.g = (p.g + q.g) >> 1;
|
||
p.b = (p.b + q.b) >> 1;
|
||
});
|
||
|
||
// difference (abs) — highlights what's different
|
||
const img = images[0];
|
||
forEach((x, y, p) => {
|
||
const q = img.getPixel(x % img.width, y % img.height);
|
||
p.r = abs(p.r - q.r); p.g = abs(p.g - q.g); p.b = abs(p.b - q.b);
|
||
});
|
||
|
||
// paste images[0] at coordinates (offsetX, offsetY)
|
||
const img = images[0];
|
||
const offsetX = 50, offsetY = 100;
|
||
for (let y = 0; y < img.height; y++) {
|
||
for (let x = 0; x < img.width; x++) {
|
||
const dx = x + offsetX, dy = y + offsetY;
|
||
if (dx < 0 || dx >= width || dy < 0 || dy >= height) continue;
|
||
const q = img.getPixel(x, y);
|
||
setPixel(dx, dy, q.r, q.g, q.b, q.a);
|
||
}
|
||
}
|
||
|
||
// paste with alpha blending (respects q.a as transparency)
|
||
const img = images[0];
|
||
const offsetX = 50, offsetY = 100;
|
||
for (let y = 0; y < img.height; y++) {
|
||
for (let x = 0; x < img.width; x++) {
|
||
const dx = x + offsetX, dy = y + offsetY;
|
||
if (dx < 0 || dx >= width || dy < 0 || dy >= height) continue;
|
||
const q = img.getPixel(x, y);
|
||
const p = prev.getPixel(dx, dy);
|
||
const a = q.a / 255;
|
||
setPixel(dx, dy,
|
||
p.r * (1 - a) + q.r * a,
|
||
p.g * (1 - a) + q.g * a,
|
||
p.b * (1 - a) + q.b * a,
|
||
255);
|
||
}
|
||
}
|
||
|
||
// paste centered on the canvas
|
||
const img = images[0];
|
||
const offsetX = ((width - img.width) / 2) | 0;
|
||
const offsetY = ((height - img.height) / 2) | 0;
|
||
for (let y = 0; y < img.height; y++) {
|
||
for (let x = 0; x < img.width; x++) {
|
||
const q = img.getPixel(x, y);
|
||
setPixel(x + offsetX, y + offsetY, q.r, q.g, q.b, q.a);
|
||
}
|
||
}
|
||
|
||
// paste with math: add images[0] to original at (offsetX, offsetY)
|
||
const img = images[0];
|
||
const offsetX = 50, offsetY = 100;
|
||
for (let y = 0; y < img.height; y++) {
|
||
for (let x = 0; x < img.width; x++) {
|
||
const dx = x + offsetX, dy = y + offsetY;
|
||
if (dx < 0 || dx >= width || dy < 0 || dy >= height) continue;
|
||
const q = img.getPixel(x, y);
|
||
const p = prev.getPixel(dx, dy);
|
||
setPixel(dx, dy, clamp(p.r + q.r), clamp(p.g + q.g), clamp(p.b + q.b));
|
||
}
|
||
}</pre>
|
||
|
||
<h4>Performance tip</h4>
|
||
<p>JS per-pixel loops are slow on huge images. Use the <b>scale</b> selector
|
||
(e.g. 0.25x) for live editing, then bump to 1x when exporting.</p>
|
||
</div>
|
||
|
||
<script>
|
||
"use strict";
|
||
|
||
// ---------------- State ----------------
|
||
const state = {
|
||
original: null, // {pixels, width, height} at full res
|
||
scaledOriginal: null, // {pixels, width, height} at current scale
|
||
scale: 0.25,
|
||
layers: [], // [{id, name, code, visible, blend, opacity}]
|
||
activeLayerId: null,
|
||
extraImages: [], // [{name, full:{...}, scaled:{...}, thumbCanvas}]
|
||
autoRun: true,
|
||
};
|
||
|
||
let layerIdSeq = 1;
|
||
let imgIdSeq = 1;
|
||
|
||
const DEFAULT_CODE = `// each layer runs this code.
|
||
// 'output' is your RGBA buffer; 'prev' is the previous layer's output (or original).
|
||
// see ? for full API.
|
||
|
||
forEach((x, y, p) => {
|
||
p.r = clamp(p.r + 5);
|
||
p.g = clamp(p.g + 5);
|
||
p.b = clamp(p.b + 5);
|
||
});
|
||
`;
|
||
|
||
// ---------------- DOM refs ----------------
|
||
const $ = id => document.getElementById(id);
|
||
const canvasOut = $("canvasOut");
|
||
const canvasOrig = $("canvasOrig");
|
||
const canvasWrap = $("canvasWrap");
|
||
const emptyHint = $("emptyHint");
|
||
const canvasInfo = $("canvasInfo");
|
||
const layersPanel = $("layersPanel");
|
||
const editor = $("editor");
|
||
const highlightEl = $("highlight");
|
||
const editorLabel = $("editorLabel");
|
||
const consoleEl = $("console");
|
||
const statusEl = $("status");
|
||
const imagesList = $("imagesList");
|
||
const varsPanel = $("varsPanel");
|
||
const ctxOut = canvasOut.getContext("2d");
|
||
const ctxOrig = canvasOrig.getContext("2d");
|
||
|
||
// ---------------- Logging ----------------
|
||
function log(msg, cls="") {
|
||
const line = document.createElement("div");
|
||
if (cls) line.className = cls;
|
||
line.textContent = msg;
|
||
consoleEl.appendChild(line);
|
||
consoleEl.scrollTop = consoleEl.scrollHeight;
|
||
if (consoleEl.children.length > 200) consoleEl.removeChild(consoleEl.firstChild);
|
||
}
|
||
function setStatus(text, cls="") {
|
||
statusEl.textContent = text;
|
||
statusEl.className = "status " + cls;
|
||
}
|
||
|
||
// ---------------- Image loading ----------------
|
||
function loadImageFile(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const url = URL.createObjectURL(file);
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
const c = document.createElement("canvas");
|
||
c.width = img.width; c.height = img.height;
|
||
const cx = c.getContext("2d");
|
||
cx.drawImage(img, 0, 0);
|
||
const data = cx.getImageData(0, 0, img.width, img.height);
|
||
URL.revokeObjectURL(url);
|
||
resolve({ pixels: data.data, width: img.width, height: img.height });
|
||
};
|
||
img.onerror = e => { URL.revokeObjectURL(url); reject(e); };
|
||
img.src = url;
|
||
});
|
||
}
|
||
|
||
function rescale(srcImg, scale) {
|
||
const w = Math.max(1, Math.round(srcImg.width * scale));
|
||
const h = Math.max(1, Math.round(srcImg.height * scale));
|
||
const c = document.createElement("canvas");
|
||
c.width = w; c.height = h;
|
||
const cx = c.getContext("2d");
|
||
// Put original into a temp canvas
|
||
const src = document.createElement("canvas");
|
||
src.width = srcImg.width; src.height = srcImg.height;
|
||
const scx = src.getContext("2d");
|
||
const id = scx.createImageData(srcImg.width, srcImg.height);
|
||
id.data.set(srcImg.pixels);
|
||
scx.putImageData(id, 0, 0);
|
||
cx.imageSmoothingEnabled = scale < 1 ? true : false;
|
||
cx.drawImage(src, 0, 0, w, h);
|
||
const dat = cx.getImageData(0, 0, w, h);
|
||
return { pixels: dat.data, width: w, height: h };
|
||
}
|
||
|
||
// ---------------- Layers ----------------
|
||
function newLayer(name="layer", code=DEFAULT_CODE) {
|
||
return {
|
||
id: layerIdSeq++,
|
||
name,
|
||
code,
|
||
visible: true,
|
||
blend: "normal",
|
||
opacity: 1.0,
|
||
};
|
||
}
|
||
|
||
function addLayerAndSelect() {
|
||
pushUndo();
|
||
const l = newLayer("layer " + layerIdSeq);
|
||
state.layers.push(l);
|
||
state.activeLayerId = l.id;
|
||
renderLayers();
|
||
loadEditorFromActive();
|
||
scheduleRun();
|
||
}
|
||
|
||
function deleteLayer(id) {
|
||
if (state.layers.length <= 1) { log("can't delete last layer", "err"); return; }
|
||
pushUndo();
|
||
const idx = state.layers.findIndex(l => l.id === id);
|
||
state.layers.splice(idx, 1);
|
||
if (state.activeLayerId === id) {
|
||
state.activeLayerId = state.layers[Math.max(0, idx-1)].id;
|
||
}
|
||
renderLayers();
|
||
loadEditorFromActive();
|
||
scheduleRun();
|
||
}
|
||
|
||
function moveLayer(id, dir) {
|
||
const i = state.layers.findIndex(l => l.id === id);
|
||
const j = i + dir;
|
||
if (j < 0 || j >= state.layers.length) return;
|
||
pushUndo();
|
||
const tmp = state.layers[i];
|
||
state.layers[i] = state.layers[j];
|
||
state.layers[j] = tmp;
|
||
renderLayers();
|
||
scheduleRun();
|
||
}
|
||
|
||
function getActiveLayer() {
|
||
return state.layers.find(l => l.id === state.activeLayerId);
|
||
}
|
||
|
||
function loadEditorFromActive() {
|
||
const l = getActiveLayer();
|
||
if (!l) { editor.value = ""; editorLabel.textContent = ""; updateHighlight(); return; }
|
||
editor.value = l.code;
|
||
editorLabel.textContent = "— " + l.name;
|
||
updateHighlight();
|
||
}
|
||
|
||
function renderLayers() {
|
||
layersPanel.innerHTML = "";
|
||
// top layer first in UI (composite order: bottom→top, so render reversed)
|
||
for (let i = state.layers.length - 1; i >= 0; i--) {
|
||
const l = state.layers[i];
|
||
const row = document.createElement("div");
|
||
row.className = "layer" + (l.id === state.activeLayerId ? " active" : "");
|
||
row.onclick = () => {
|
||
state.activeLayerId = l.id;
|
||
renderLayers();
|
||
loadEditorFromActive();
|
||
};
|
||
|
||
const top = document.createElement("div");
|
||
top.className = "topRow";
|
||
|
||
const vis = document.createElement("div");
|
||
vis.className = "vis " + (l.visible ? "on" : "");
|
||
vis.textContent = l.visible ? "●" : "○";
|
||
vis.title = "toggle visibility";
|
||
vis.onclick = (e) => { e.stopPropagation(); pushUndo(); l.visible = !l.visible; renderLayers(); scheduleRun(); };
|
||
top.appendChild(vis);
|
||
|
||
const startRename = (e) => {
|
||
if (e) e.stopPropagation();
|
||
const inp = document.createElement("input");
|
||
inp.value = l.name;
|
||
inp.onblur = () => {
|
||
const newName = (inp.value || "").trim() || l.name;
|
||
if (newName !== l.name) pushUndo();
|
||
l.name = newName;
|
||
renderLayers();
|
||
loadEditorFromActive();
|
||
};
|
||
inp.onkeydown = (ev) => {
|
||
if (ev.key === "Enter") inp.blur();
|
||
else if (ev.key === "Escape") { inp.value = l.name; inp.blur(); }
|
||
};
|
||
name.innerHTML = ""; name.appendChild(inp); inp.focus(); inp.select();
|
||
};
|
||
|
||
const name = document.createElement("div");
|
||
name.className = "name";
|
||
name.textContent = l.name;
|
||
name.title = "double-click to rename";
|
||
name.ondblclick = startRename;
|
||
top.appendChild(name);
|
||
|
||
const acts = document.createElement("div");
|
||
acts.className = "actions";
|
||
const ren = document.createElement("button");
|
||
ren.textContent = "✎"; ren.title = "rename layer";
|
||
ren.onclick = e => { e.stopPropagation(); startRename(); };
|
||
const up = document.createElement("button");
|
||
up.textContent = "▲"; up.title = "move up";
|
||
up.onclick = e => { e.stopPropagation(); moveLayer(l.id, +1); };
|
||
const dn = document.createElement("button");
|
||
dn.textContent = "▼"; dn.title = "move down";
|
||
dn.onclick = e => { e.stopPropagation(); moveLayer(l.id, -1); };
|
||
const dl = document.createElement("button");
|
||
dl.textContent = "✕"; dl.title = "delete";
|
||
dl.className = "danger";
|
||
dl.onclick = e => { e.stopPropagation(); deleteLayer(l.id); };
|
||
acts.appendChild(ren); acts.appendChild(up); acts.appendChild(dn); acts.appendChild(dl);
|
||
top.appendChild(acts);
|
||
|
||
row.appendChild(top);
|
||
|
||
const opWrap = document.createElement("div");
|
||
opWrap.className = "opacityWrap";
|
||
opWrap.title = "strength: lerp(prev, this layer's output, strength)";
|
||
opWrap.onclick = e => e.stopPropagation();
|
||
const opLabel = document.createElement("span");
|
||
opLabel.className = "opLabel";
|
||
opLabel.textContent = "strength";
|
||
const op = document.createElement("input");
|
||
op.type = "range"; op.min = "0"; op.max = "1"; op.step = "0.01";
|
||
op.value = l.opacity; op.className = "opacitySlider";
|
||
const opVal = document.createElement("span");
|
||
opVal.className = "opacityVal";
|
||
opVal.textContent = Number(l.opacity).toFixed(2);
|
||
op.oninput = () => {
|
||
beginBurst();
|
||
l.opacity = Math.max(0, Math.min(1, parseFloat(op.value)||0));
|
||
opVal.textContent = l.opacity.toFixed(2);
|
||
scheduleRun();
|
||
};
|
||
opWrap.appendChild(opLabel);
|
||
opWrap.appendChild(op);
|
||
opWrap.appendChild(opVal);
|
||
row.appendChild(opWrap);
|
||
|
||
layersPanel.appendChild(row);
|
||
}
|
||
}
|
||
|
||
// ---------------- Sandbox API ----------------
|
||
function buildContext(prevPixels, w, h, extraImages) {
|
||
const out = new Uint8ClampedArray(w * h * 4);
|
||
// start with prev as the base — user can read prev OR overwrite output freely
|
||
out.set(prevPixels);
|
||
|
||
function makeImageObj(px, ww, hh) {
|
||
return {
|
||
pixels: px, width: ww, height: hh,
|
||
getPixel(x, y) {
|
||
x = x|0; y = y|0;
|
||
if (x<0||x>=ww||y<0||y>=hh) return {r:0,g:0,b:0,a:0};
|
||
const i = (y*ww + x) * 4;
|
||
return { r:px[i], g:px[i+1], b:px[i+2], a:px[i+3] };
|
||
}
|
||
};
|
||
}
|
||
|
||
const original = makeImageObj(state.scaledOriginal.pixels, w, h);
|
||
const prev = makeImageObj(prevPixels, w, h);
|
||
|
||
function getPixel(x, y) { return prev.getPixel(x, y); }
|
||
function setPixel(x, y, r, g, b, a) {
|
||
x = x|0; y = y|0;
|
||
if (x<0||x>=w||y<0||y>=h) return;
|
||
const i = (y*w + x) * 4;
|
||
if (typeof r === "object" && r !== null) {
|
||
// setPixel(x,y,{r,g,b,a})
|
||
out[i] = r.r|0;
|
||
out[i+1] = r.g|0;
|
||
out[i+2] = r.b|0;
|
||
out[i+3] = (r.a==null?255:r.a)|0;
|
||
} else {
|
||
out[i] = r|0;
|
||
out[i+1] = g|0;
|
||
out[i+2] = b|0;
|
||
out[i+3] = (a==null?255:a)|0;
|
||
}
|
||
}
|
||
function forEach(fn) {
|
||
for (let y = 0; y < h; y++) {
|
||
for (let x = 0; x < w; x++) {
|
||
const i = (y*w + x) * 4;
|
||
const p = { r: prevPixels[i], g: prevPixels[i+1], b: prevPixels[i+2], a: prevPixels[i+3] };
|
||
const r = fn(x, y, p);
|
||
const q = (r === undefined) ? p : r;
|
||
out[i] = q.r|0;
|
||
out[i+1] = q.g|0;
|
||
out[i+2] = q.b|0;
|
||
out[i+3] = (q.a==null?255:q.a)|0;
|
||
}
|
||
}
|
||
}
|
||
function clamp(v, lo=0, hi=255) { return v < lo ? lo : v > hi ? hi : v; }
|
||
function lerp(a, b, t) { return a + (b - a) * t; }
|
||
function random1() { return Math.random(); }
|
||
function random256() { return Math.floor(Math.random() * 256); }
|
||
function randomBoolean() { return Math.random() < 0.5; }
|
||
function randomRange(lo, hi) { return lo + Math.random() * (hi - lo); }
|
||
function randomInt(lo, hi) { return Math.floor(lo + Math.random() * (hi - lo + 1)); }
|
||
|
||
const images = extraImages.map(im => makeImageObj(im.scaled.pixels, im.scaled.width, im.scaled.height));
|
||
|
||
return {
|
||
width: w, height: h,
|
||
output: out,
|
||
original, prev, images,
|
||
getPixel, setPixel, forEach,
|
||
clamp, lerp,
|
||
random1, random256, randomBoolean, randomRange, randomInt,
|
||
PI: Math.PI, TAU: Math.PI*2, E: Math.E,
|
||
sin: Math.sin, cos: Math.cos, tan: Math.tan,
|
||
asin: Math.asin, acos: Math.acos, atan: Math.atan, atan2: Math.atan2,
|
||
abs: Math.abs, sqrt: Math.sqrt, pow: Math.pow, exp: Math.exp, log: Math.log,
|
||
floor: Math.floor, ceil: Math.ceil, round: Math.round,
|
||
min: Math.min, max: Math.max, hypot: Math.hypot, sign: Math.sign,
|
||
random: Math.random,
|
||
Math,
|
||
log: (...a) => log("[layer] " + a.map(x => typeof x === "object" ? JSON.stringify(x) : String(x)).join(" "), "info"),
|
||
};
|
||
}
|
||
|
||
function runLayerCode(code, prevPixels, w, h) {
|
||
const ctx = buildContext(prevPixels, w, h, state.extraImages);
|
||
const keys = Object.keys(ctx);
|
||
const vals = keys.map(k => ctx[k]);
|
||
// wrap user code so they can use `return` if they want
|
||
const fn = new Function(...keys, '"use strict";\n' + code);
|
||
fn.apply(null, vals);
|
||
return ctx.output;
|
||
}
|
||
|
||
// ---------------- Compositing ----------------
|
||
// Pure pipeline: lerp(prev, layerOut, strength). strength=1 → use layer output as-is.
|
||
function compositePixels(prev, layerOut, strength, w, h) {
|
||
if (strength >= 1) return layerOut;
|
||
if (strength <= 0) return prev;
|
||
const out = new Uint8ClampedArray(w * h * 4);
|
||
const t = strength, it = 1 - strength;
|
||
for (let i = 0; i < prev.length; i++) {
|
||
out[i] = prev[i] * it + layerOut[i] * t;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ---------------- Render ----------------
|
||
let runScheduled = false;
|
||
function scheduleRun() {
|
||
if (!state.autoRun) return;
|
||
if (runScheduled) return;
|
||
runScheduled = true;
|
||
requestAnimationFrame(() => { runScheduled = false; runAllLayers(); });
|
||
}
|
||
|
||
function runAllLayers() {
|
||
if (!state.scaledOriginal) return;
|
||
const t0 = performance.now();
|
||
const w = state.scaledOriginal.width;
|
||
const h = state.scaledOriginal.height;
|
||
|
||
// sync editor → active layer code
|
||
const al = getActiveLayer();
|
||
if (al) al.code = editor.value;
|
||
|
||
// pure pipeline: each layer's input = previous layer's output (or original for first)
|
||
let current = new Uint8ClampedArray(state.scaledOriginal.pixels);
|
||
|
||
for (let i = 0; i < state.layers.length; i++) {
|
||
const layer = state.layers[i];
|
||
if (!layer.visible) continue;
|
||
|
||
let layerOut;
|
||
try {
|
||
layerOut = runLayerCode(layer.code, current, w, h);
|
||
} catch (err) {
|
||
log(`[${layer.name}] ERROR: ${err.message}`, "err");
|
||
continue;
|
||
}
|
||
|
||
current = compositePixels(current, layerOut, layer.opacity, w, h);
|
||
}
|
||
const composite = current;
|
||
|
||
// write to canvas
|
||
canvasOut.width = w;
|
||
canvasOut.height = h;
|
||
const id = ctxOut.createImageData(w, h);
|
||
id.data.set(composite);
|
||
ctxOut.putImageData(id, 0, 0);
|
||
|
||
// also draw original
|
||
canvasOrig.width = w;
|
||
canvasOrig.height = h;
|
||
const id2 = ctxOrig.createImageData(w, h);
|
||
id2.data.set(state.scaledOriginal.pixels);
|
||
ctxOrig.putImageData(id2, 0, 0);
|
||
|
||
fitCanvasToPane();
|
||
const ms = (performance.now() - t0).toFixed(1);
|
||
setStatus(`${state.original.width}×${state.original.height} • running at ${w}×${h} • ${ms}ms`, "ok");
|
||
setBaseCanvasInfo(`${w}×${h} (scale ${state.scale}x) • ${ms}ms • hover for pixel info • click&hold for original`);
|
||
}
|
||
|
||
function fitCanvasToPane() {
|
||
// Make canvas auto fit
|
||
const pane = $("canvasPane");
|
||
const W = pane.clientWidth - 24, H = pane.clientHeight - 24;
|
||
const cw = canvasOut.width, ch = canvasOut.height;
|
||
if (!cw || !ch) return;
|
||
const r = Math.min(W / cw, H / ch);
|
||
const dw = Math.max(1, Math.floor(cw * r));
|
||
const dh = Math.max(1, Math.floor(ch * r));
|
||
canvasOut.style.width = dw + "px";
|
||
canvasOut.style.height = dh + "px";
|
||
canvasOrig.style.width = dw + "px";
|
||
canvasOrig.style.height = dh + "px";
|
||
canvasWrap.style.width = dw + "px";
|
||
canvasWrap.style.height = dh + "px";
|
||
}
|
||
|
||
// ---------------- Canvas: hover = pixel info, click&hold = show original ----------------
|
||
let hoverPixel = null; // {x, y} in image-space, or null
|
||
let baseCanvasInfo = ""; // size/ms info to restore when not hovering
|
||
|
||
function setBaseCanvasInfo(text) {
|
||
baseCanvasInfo = text;
|
||
if (!hoverPixel) canvasInfo.textContent = text;
|
||
}
|
||
|
||
function updatePixelInfo(e) {
|
||
if (!canvasOut.width || !canvasOut.height) return;
|
||
const rect = canvasOut.getBoundingClientRect();
|
||
const x = Math.floor((e.clientX - rect.left) / rect.width * canvasOut.width);
|
||
const y = Math.floor((e.clientY - rect.top) / rect.height * canvasOut.height);
|
||
if (x < 0 || x >= canvasOut.width || y < 0 || y >= canvasOut.height) {
|
||
hoverPixel = null;
|
||
canvasInfo.textContent = baseCanvasInfo;
|
||
canvasInfo.style.borderLeft = "";
|
||
return;
|
||
}
|
||
hoverPixel = { x, y };
|
||
// pick from whichever canvas is currently visible
|
||
const showingOrig = canvasWrap.classList.contains("showOrig");
|
||
const src = showingOrig ? ctxOrig : ctxOut;
|
||
const d = src.getImageData(x, y, 1, 1).data;
|
||
const r = d[0], g = d[1], b = d[2], a = d[3];
|
||
const hex = "#" + ((r << 16) | (g << 8) | b).toString(16).padStart(6, "0");
|
||
const tag = showingOrig ? "ORIG" : "OUT";
|
||
canvasInfo.innerHTML =
|
||
`<span style="color:#${(r<<16|g<<8|b).toString(16).padStart(6,'0')};` +
|
||
`text-shadow:0 0 2px #000">■</span> ` +
|
||
`[${tag}] (${x}, ${y}) rgba(${r}, ${g}, ${b}, ${a}) ${hex}`;
|
||
}
|
||
|
||
canvasWrap.addEventListener("mousemove", updatePixelInfo);
|
||
canvasWrap.addEventListener("mouseleave", () => {
|
||
hoverPixel = null;
|
||
canvasWrap.classList.remove("showOrig");
|
||
canvasInfo.textContent = baseCanvasInfo;
|
||
});
|
||
|
||
// click-and-hold to view original
|
||
canvasWrap.addEventListener("mousedown", (e) => {
|
||
if (e.button !== 0) return;
|
||
canvasWrap.classList.add("showOrig");
|
||
updatePixelInfo(e);
|
||
});
|
||
window.addEventListener("mouseup", () => {
|
||
if (canvasWrap.classList.contains("showOrig")) {
|
||
canvasWrap.classList.remove("showOrig");
|
||
}
|
||
});
|
||
|
||
// ---------------- Export ----------------
|
||
async function exportPng() {
|
||
if (!state.original) { log("no image to export", "err"); return; }
|
||
log("rendering at full resolution for export…", "info");
|
||
// Render at scale=1 for export
|
||
const savedScaled = state.scaledOriginal;
|
||
state.scaledOriginal = state.original;
|
||
// also rescale extra images to full
|
||
const savedExtras = state.extraImages.map(im => im.scaled);
|
||
state.extraImages.forEach(im => im.scaled = im.full);
|
||
|
||
let current = new Uint8ClampedArray(state.original.pixels);
|
||
const w = state.original.width, h = state.original.height;
|
||
for (const layer of state.layers) {
|
||
if (!layer.visible) continue;
|
||
let out;
|
||
try { out = runLayerCode(layer.code, current, w, h); }
|
||
catch (err) { log(`[${layer.name}] export ERROR: ${err.message}`, "err"); continue; }
|
||
current = compositePixels(current, out, layer.opacity, w, h);
|
||
}
|
||
const composite = current;
|
||
// restore preview-state
|
||
state.scaledOriginal = savedScaled;
|
||
state.extraImages.forEach((im, i) => im.scaled = savedExtras[i]);
|
||
|
||
// make canvas, encode
|
||
const c = document.createElement("canvas");
|
||
c.width = w; c.height = h;
|
||
const cx = c.getContext("2d");
|
||
const id = cx.createImageData(w, h);
|
||
id.data.set(composite);
|
||
cx.putImageData(id, 0, 0);
|
||
c.toBlob((blob) => {
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url; a.download = "trakodag_export.png";
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
log("exported.", "ok");
|
||
}, "image/png");
|
||
}
|
||
|
||
// ---------------- Extra images UI ----------------
|
||
function renderExtraImages() {
|
||
imagesList.innerHTML = "";
|
||
if (state.extraImages.length === 0) {
|
||
const e = document.createElement("div");
|
||
e.className = "imgEmpty";
|
||
e.textContent = 'no extra images — use "+ Add image"';
|
||
imagesList.appendChild(e);
|
||
return;
|
||
}
|
||
state.extraImages.forEach((im, idx) => {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "imgThumb";
|
||
wrap.title = `${im.name} — images[${idx}] (${im.full.width}×${im.full.height})`;
|
||
const c = document.createElement("canvas");
|
||
c.width = im.full.width; c.height = im.full.height;
|
||
const cx = c.getContext("2d");
|
||
const id = cx.createImageData(im.full.width, im.full.height);
|
||
id.data.set(im.full.pixels);
|
||
cx.putImageData(id, 0, 0);
|
||
wrap.appendChild(c);
|
||
const lab = document.createElement("div");
|
||
lab.className = "label";
|
||
lab.textContent = `[${idx}]`;
|
||
wrap.appendChild(lab);
|
||
const del = document.createElement("button");
|
||
del.className = "del"; del.textContent = "×";
|
||
del.title = "remove";
|
||
del.onclick = () => {
|
||
state.extraImages.splice(idx, 1);
|
||
renderExtraImages();
|
||
scheduleRun();
|
||
};
|
||
wrap.appendChild(del);
|
||
imagesList.appendChild(wrap);
|
||
});
|
||
}
|
||
|
||
// ---------------- Syntax highlighting ----------------
|
||
const KEYWORDS = new Set([
|
||
"let","const","var","if","else","for","while","do","function","return",
|
||
"break","continue","switch","case","default","true","false","null","undefined",
|
||
"new","typeof","instanceof","in","of","try","catch","throw","finally","this"
|
||
]);
|
||
// API names (highlighted as built-ins)
|
||
const API_NAMES = new Set([
|
||
"width","height","output","original","prev","images","pixels",
|
||
"getPixel","setPixel","forEach","clamp","lerp","log",
|
||
"PI","TAU","E","Math",
|
||
"sin","cos","tan","asin","acos","atan","atan2",
|
||
"abs","sqrt","pow","exp","floor","ceil","round","min","max",
|
||
"random","random1","random256","randomBoolean","randomRange","randomInt",
|
||
"hypot","sign"
|
||
]);
|
||
|
||
function escHtml(s) {
|
||
return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
||
}
|
||
|
||
function highlightCode(code) {
|
||
let out = "";
|
||
let i = 0;
|
||
const n = code.length;
|
||
while (i < n) {
|
||
const ch = code[i];
|
||
// line comment
|
||
if (ch === "/" && code[i+1] === "/") {
|
||
let j = code.indexOf("\n", i);
|
||
if (j === -1) j = n;
|
||
out += `<span class="tk-cm">${escHtml(code.slice(i, j))}</span>`;
|
||
i = j;
|
||
continue;
|
||
}
|
||
// block comment
|
||
if (ch === "/" && code[i+1] === "*") {
|
||
let j = code.indexOf("*/", i + 2);
|
||
j = (j === -1) ? n : j + 2;
|
||
out += `<span class="tk-cm">${escHtml(code.slice(i, j))}</span>`;
|
||
i = j;
|
||
continue;
|
||
}
|
||
// strings
|
||
if (ch === '"' || ch === "'" || ch === "`") {
|
||
const q = ch;
|
||
let j = i + 1;
|
||
while (j < n) {
|
||
if (code[j] === "\\") { j += 2; continue; }
|
||
if (code[j] === q) { j++; break; }
|
||
j++;
|
||
}
|
||
out += `<span class="tk-str">${escHtml(code.slice(i, j))}</span>`;
|
||
i = j;
|
||
continue;
|
||
}
|
||
// numbers
|
||
if ((ch >= "0" && ch <= "9") || (ch === "." && code[i+1] >= "0" && code[i+1] <= "9")) {
|
||
const m = code.slice(i).match(/^(0[xX][0-9a-fA-F]+|[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?)/);
|
||
if (m) {
|
||
out += `<span class="tk-num">${escHtml(m[0])}</span>`;
|
||
i += m[0].length;
|
||
continue;
|
||
}
|
||
}
|
||
// identifiers / keywords / api
|
||
if (/[A-Za-z_$]/.test(ch)) {
|
||
const m = code.slice(i).match(/^[A-Za-z_$][A-Za-z_$0-9]*/);
|
||
if (m) {
|
||
const w = m[0];
|
||
let cls = "tk-id";
|
||
if (KEYWORDS.has(w)) cls = "tk-kw";
|
||
else if (API_NAMES.has(w)) cls = "tk-api";
|
||
else if (code[i + w.length] === "(") cls = "tk-fn";
|
||
out += `<span class="${cls}">${escHtml(w)}</span>`;
|
||
i += w.length;
|
||
continue;
|
||
}
|
||
}
|
||
// operators / punctuation
|
||
if ("+-*/%=<>!&|^~?:".indexOf(ch) >= 0) {
|
||
out += `<span class="tk-op">${escHtml(ch)}</span>`;
|
||
i++;
|
||
continue;
|
||
}
|
||
// whitespace / brackets / commas / etc.
|
||
out += escHtml(ch);
|
||
i++;
|
||
}
|
||
// trailing newline padding so the pre matches textarea height when caret is on last (empty) line
|
||
return out + "\n";
|
||
}
|
||
|
||
function updateHighlight() {
|
||
highlightEl.innerHTML = highlightCode(editor.value);
|
||
highlightEl.scrollTop = editor.scrollTop;
|
||
highlightEl.scrollLeft = editor.scrollLeft;
|
||
}
|
||
|
||
// ---------------- Vars / API reference panel ----------------
|
||
const VARS_REF = [
|
||
{ group: "Dimensions (current run resolution)", items: [
|
||
["width", "image width in pixels"],
|
||
["height", "image height in pixels"],
|
||
]},
|
||
{ group: "Pixel sources (read)", items: [
|
||
["original", "source image — { pixels, width, height, getPixel(x,y) }"],
|
||
["original.pixels", "Uint8ClampedArray RGBA (length = w*h*4)"],
|
||
["original.getPixel(x,y)", "→ {r,g,b,a} from the original image"],
|
||
["prev", "previous layer's output (or original for first layer)"],
|
||
["prev.pixels", "Uint8ClampedArray RGBA from previous layer"],
|
||
["prev.getPixel(x,y)","→ {r,g,b,a} from previous layer's output"],
|
||
["images", "extra images — array, each: {pixels, width, height, getPixel}"],
|
||
["images[0]", "first extra image (added with '+ Add image')"],
|
||
]},
|
||
{ group: "Output (write)", items: [
|
||
["output", "Uint8ClampedArray RGBA you write to (this layer's result)"],
|
||
["setPixel(x,y,r,g,b,a)", "write a pixel — a defaults to 255"],
|
||
["setPixel(x,y,p)", "write a pixel object {r,g,b,a}"],
|
||
["getPixel(x,y)", "shortcut for prev.getPixel(x,y)"],
|
||
["forEach((x,y,p)=>{…})", "iterate every pixel of prev; mutate p or return a new one"],
|
||
]},
|
||
{ group: "Math helpers", items: [
|
||
["clamp(v, lo=0, hi=255)", "clamp value to a range"],
|
||
["lerp(a, b, t)", "linear interpolation a→b by t∈[0,1]"],
|
||
]},
|
||
{ group: "Math constants", items: [
|
||
["PI", "Math.PI"],
|
||
["TAU", "Math.PI * 2"],
|
||
["E", "Math.E"],
|
||
]},
|
||
{ group: "Random", items: [
|
||
["random1()", "→ float in [0, 1)"],
|
||
["random256()", "→ int in [0, 255]"],
|
||
["randomBoolean()", "→ true or false (50/50)"],
|
||
["randomRange(lo,hi)", "→ float in [lo, hi)"],
|
||
["randomInt(lo,hi)", "→ int in [lo, hi] (inclusive)"],
|
||
["random()", "alias for Math.random() — same as random1()"],
|
||
]},
|
||
{ group: "Math functions", items: [
|
||
["sin","Math.sin"], ["cos","Math.cos"], ["tan","Math.tan"],
|
||
["asin","Math.asin"], ["acos","Math.acos"], ["atan","Math.atan"],
|
||
["atan2","Math.atan2(y,x)"],
|
||
["abs","Math.abs"], ["sqrt","Math.sqrt"], ["pow","Math.pow(b,e)"],
|
||
["exp","Math.exp"], ["floor","Math.floor"], ["ceil","Math.ceil"],
|
||
["round","Math.round"], ["min","Math.min"], ["max","Math.max"],
|
||
["random","Math.random() in [0,1)"], ["hypot","Math.hypot"],
|
||
["sign","Math.sign"], ["Math","full Math object"],
|
||
]},
|
||
{ group: "Logging", items: [
|
||
["log(...)", "print to the console pane below"],
|
||
]},
|
||
];
|
||
|
||
function renderVarsPanel() {
|
||
varsPanel.innerHTML = "";
|
||
for (const g of VARS_REF) {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "varsGroup";
|
||
const h = document.createElement("h5");
|
||
h.textContent = g.group;
|
||
wrap.appendChild(h);
|
||
const list = document.createElement("div");
|
||
list.className = "varsList";
|
||
for (const [name, desc] of g.items) {
|
||
const chip = document.createElement("span");
|
||
chip.className = "varChip";
|
||
chip.title = "click to insert at cursor";
|
||
const code = document.createElement("span");
|
||
code.textContent = name;
|
||
chip.appendChild(code);
|
||
if (desc) {
|
||
const d = document.createElement("span");
|
||
d.className = "desc";
|
||
d.textContent = "— " + desc;
|
||
chip.appendChild(d);
|
||
}
|
||
chip.onclick = () => insertAtCursor(name);
|
||
list.appendChild(chip);
|
||
}
|
||
wrap.appendChild(list);
|
||
varsPanel.appendChild(wrap);
|
||
}
|
||
}
|
||
|
||
function insertAtCursor(text) {
|
||
editor.focus();
|
||
const s = editor.selectionStart, e = editor.selectionEnd;
|
||
const v = editor.value;
|
||
editor.value = v.slice(0, s) + text + v.slice(e);
|
||
editor.selectionStart = editor.selectionEnd = s + text.length;
|
||
// trigger input handler
|
||
editor.dispatchEvent(new Event("input"));
|
||
}
|
||
|
||
// ---------------- Undo / Redo ----------------
|
||
const history = {
|
||
undo: [],
|
||
redo: [],
|
||
max: 80,
|
||
// burst-edit tracking: while user types, only one snapshot per burst
|
||
bursting: false,
|
||
burstTimer: null,
|
||
};
|
||
|
||
function snapshotState() {
|
||
// sync editor → active layer code first
|
||
const al = getActiveLayer();
|
||
if (al) al.code = editor.value;
|
||
return JSON.stringify({
|
||
layers: state.layers.map(l => ({
|
||
id: l.id, name: l.name, code: l.code,
|
||
visible: l.visible, opacity: l.opacity,
|
||
})),
|
||
activeLayerId: state.activeLayerId,
|
||
});
|
||
}
|
||
|
||
function applyState(snap) {
|
||
const obj = JSON.parse(snap);
|
||
state.layers = obj.layers.map(l => ({
|
||
id: l.id, name: l.name, code: l.code,
|
||
visible: l.visible, opacity: l.opacity,
|
||
blend: "normal",
|
||
}));
|
||
// keep id sequence past max id
|
||
const maxId = state.layers.reduce((m, l) => Math.max(m, l.id), 0);
|
||
if (layerIdSeq <= maxId) layerIdSeq = maxId + 1;
|
||
state.activeLayerId = obj.activeLayerId;
|
||
if (!state.layers.find(l => l.id === state.activeLayerId)) {
|
||
state.activeLayerId = state.layers[0]?.id;
|
||
}
|
||
renderLayers();
|
||
loadEditorFromActive();
|
||
scheduleRun();
|
||
}
|
||
|
||
function pushUndo() {
|
||
const snap = snapshotState();
|
||
if (history.undo.length > 0 && history.undo[history.undo.length - 1] === snap) return;
|
||
history.undo.push(snap);
|
||
if (history.undo.length > history.max) history.undo.shift();
|
||
history.redo = [];
|
||
updateUndoButtons();
|
||
}
|
||
|
||
// for burst edits (typing in editor, dragging slider): push only the FIRST
|
||
// pre-edit snapshot, then suppress further pushes until idle
|
||
function beginBurst() {
|
||
if (!history.bursting) {
|
||
pushUndo();
|
||
history.bursting = true;
|
||
}
|
||
clearTimeout(history.burstTimer);
|
||
history.burstTimer = setTimeout(() => { history.bursting = false; }, 700);
|
||
}
|
||
|
||
function doUndo() {
|
||
if (history.undo.length === 0) return;
|
||
history.bursting = false;
|
||
clearTimeout(history.burstTimer);
|
||
const cur = snapshotState();
|
||
const prev = history.undo.pop();
|
||
history.redo.push(cur);
|
||
applyState(prev);
|
||
updateUndoButtons();
|
||
}
|
||
|
||
function doRedo() {
|
||
if (history.redo.length === 0) return;
|
||
const cur = snapshotState();
|
||
const next = history.redo.pop();
|
||
history.undo.push(cur);
|
||
applyState(next);
|
||
updateUndoButtons();
|
||
}
|
||
|
||
function updateUndoButtons() {
|
||
$("undoBtn").disabled = history.undo.length === 0;
|
||
$("redoBtn").disabled = history.redo.length === 0;
|
||
}
|
||
|
||
// ---------------- Modal ----------------
|
||
function showPresetModal(title, subtitle, items, onPick) {
|
||
$("modalTitle").textContent = title;
|
||
$("modalSub").textContent = subtitle || "";
|
||
const list = $("modalList");
|
||
list.innerHTML = "";
|
||
items.forEach((it, idx) => {
|
||
const div = document.createElement("div");
|
||
div.className = "presetItem";
|
||
const h = document.createElement("h4");
|
||
h.textContent = it.name || `Preset #${idx+1}`;
|
||
const p = document.createElement("p");
|
||
p.textContent = it.description || "";
|
||
div.appendChild(h); div.appendChild(p);
|
||
div.onclick = () => { closeModal(); onPick(it, idx); };
|
||
list.appendChild(div);
|
||
});
|
||
$("modal").classList.add("open");
|
||
}
|
||
function closeModal() { $("modal").classList.remove("open"); }
|
||
|
||
// ---------------- Project save / load ----------------
|
||
// A "project" stores only layers + code (no image data).
|
||
const PROJECT_FORMAT = "trakodag.web";
|
||
const PROJECT_VERSION = 1;
|
||
|
||
function saveProject() {
|
||
// sync current editor buffer into active layer
|
||
const al = getActiveLayer();
|
||
if (al) al.code = editor.value;
|
||
|
||
const proj = {
|
||
format: PROJECT_FORMAT,
|
||
version: PROJECT_VERSION,
|
||
savedAt: new Date().toISOString(),
|
||
layers: state.layers.map(l => ({
|
||
name: l.name,
|
||
code: l.code,
|
||
visible: l.visible,
|
||
opacity: l.opacity,
|
||
})),
|
||
};
|
||
const json = JSON.stringify(proj, null, 2);
|
||
const blob = new Blob([json], { type: "application/json" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
// build a nice filename
|
||
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||
a.href = url;
|
||
a.download = `trakodag_${ts}.json`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
log(`saved project (${proj.layers.length} layer${proj.layers.length===1?"":"s"})`, "ok");
|
||
}
|
||
|
||
function applyProject(proj) {
|
||
if (!Array.isArray(proj.layers) || proj.layers.length === 0) {
|
||
throw new Error("project has no layers");
|
||
}
|
||
pushUndo(); // allow reverting the load
|
||
state.layers = proj.layers.map(l => ({
|
||
id: layerIdSeq++,
|
||
name: typeof l.name === "string" ? l.name : "layer",
|
||
code: typeof l.code === "string" ? l.code : DEFAULT_CODE,
|
||
visible: l.visible !== false,
|
||
blend: "normal",
|
||
opacity: typeof l.opacity === "number"
|
||
? Math.max(0, Math.min(1, l.opacity))
|
||
: 1.0,
|
||
}));
|
||
state.activeLayerId = state.layers[0].id;
|
||
renderLayers();
|
||
loadEditorFromActive();
|
||
scheduleRun();
|
||
}
|
||
|
||
function loadProject(proj) {
|
||
if (!proj || typeof proj !== "object") {
|
||
throw new Error("not a trakodag file");
|
||
}
|
||
// single project
|
||
if (proj.format === PROJECT_FORMAT) {
|
||
applyProject(proj);
|
||
return;
|
||
}
|
||
// presets bundle
|
||
if (proj.format === "trakodag.web.presets" && Array.isArray(proj.presets)) {
|
||
showPresetModal(
|
||
proj.name || "Pick a preset",
|
||
proj.description || `${proj.presets.length} presets — click one to load it`,
|
||
proj.presets,
|
||
(preset) => {
|
||
applyProject(preset);
|
||
log(`loaded preset: ${preset.name || "(unnamed)"}`, "ok");
|
||
}
|
||
);
|
||
return;
|
||
}
|
||
throw new Error("not a trakodag project file");
|
||
}
|
||
|
||
// ---------------- Wire up events ----------------
|
||
$("fileMain").onchange = async (e) => {
|
||
const f = e.target.files[0]; if (!f) return;
|
||
setStatus("loading…");
|
||
try {
|
||
state.original = await loadImageFile(f);
|
||
state.scaledOriginal = rescale(state.original, state.scale);
|
||
if (state.layers.length === 0) {
|
||
state.layers.push(newLayer("base", DEFAULT_CODE));
|
||
state.activeLayerId = state.layers[0].id;
|
||
}
|
||
emptyHint.style.display = "none";
|
||
canvasWrap.style.display = "block";
|
||
renderLayers();
|
||
loadEditorFromActive();
|
||
runAllLayers();
|
||
log(`loaded ${f.name} (${state.original.width}×${state.original.height})`, "ok");
|
||
} catch (err) {
|
||
log("load failed: " + err, "err");
|
||
}
|
||
e.target.value = "";
|
||
};
|
||
|
||
$("fileExtra").onchange = async (e) => {
|
||
for (const f of e.target.files) {
|
||
try {
|
||
const full = await loadImageFile(f);
|
||
const scaled = state.scaledOriginal
|
||
? rescale(full, state.scale)
|
||
: full;
|
||
state.extraImages.push({ name: f.name, full, scaled });
|
||
log(`+ image ${f.name} -> images[${state.extraImages.length - 1}]`, "ok");
|
||
} catch (err) { log("add image failed: " + err, "err"); }
|
||
}
|
||
renderExtraImages();
|
||
scheduleRun();
|
||
e.target.value = "";
|
||
};
|
||
|
||
$("addLayer").onclick = () => addLayerAndSelect();
|
||
|
||
$("runBtn").onclick = () => runAllLayers();
|
||
|
||
$("autoRun").onchange = (e) => { state.autoRun = e.target.checked; };
|
||
|
||
$("scaleSel").onchange = (e) => {
|
||
state.scale = parseFloat(e.target.value);
|
||
if (state.original) {
|
||
state.scaledOriginal = rescale(state.original, state.scale);
|
||
state.extraImages.forEach(im => { im.scaled = rescale(im.full, state.scale); });
|
||
runAllLayers();
|
||
}
|
||
};
|
||
|
||
// ---------------- Themes ----------------
|
||
const THEMES = {
|
||
blue: { accent: "#6cf", accent2: "#ffb86c" },
|
||
purpleSea: { accent: "#c4a5ff", accent2: "#14d6c0" },
|
||
};
|
||
|
||
function applyTheme(name) {
|
||
const t = THEMES[name] || THEMES.blue;
|
||
document.documentElement.style.setProperty("--accent", t.accent);
|
||
document.documentElement.style.setProperty("--accent2", t.accent2);
|
||
try { localStorage.setItem("trakodag.theme", name); } catch (_) {}
|
||
}
|
||
|
||
$("themeSel").onchange = (e) => applyTheme(e.target.value);
|
||
|
||
// load saved theme
|
||
(function initTheme() {
|
||
let name = "blue";
|
||
try { name = localStorage.getItem("trakodag.theme") || "blue"; } catch (_) {}
|
||
if (!THEMES[name]) name = "blue";
|
||
$("themeSel").value = name;
|
||
applyTheme(name);
|
||
})();
|
||
|
||
$("exportBtn").onclick = exportPng;
|
||
|
||
$("saveProjectBtn").onclick = saveProject;
|
||
|
||
$("loadProjectFile").onchange = async (e) => {
|
||
const f = e.target.files[0];
|
||
if (!f) return;
|
||
try {
|
||
const text = await f.text();
|
||
loadProject(JSON.parse(text));
|
||
log(`loaded project: ${f.name}`, "ok");
|
||
} catch (err) {
|
||
log("load project failed: " + err.message, "err");
|
||
}
|
||
e.target.value = "";
|
||
};
|
||
|
||
$("resetCode").onclick = () => {
|
||
const l = getActiveLayer();
|
||
if (!l) return;
|
||
if (!confirm("Reset this layer's code to the default template?")) return;
|
||
pushUndo();
|
||
l.code = DEFAULT_CODE;
|
||
loadEditorFromActive();
|
||
scheduleRun();
|
||
};
|
||
|
||
$("helpBtn").onclick = () => $("help").classList.toggle("open");
|
||
$("helpX").onclick = () => $("help").classList.remove("open");
|
||
|
||
// editor: tab insertion + auto run on input (debounced) + syntax highlight
|
||
let editTimer = null;
|
||
editor.addEventListener("input", () => {
|
||
beginBurst();
|
||
const l = getActiveLayer();
|
||
if (l) l.code = editor.value;
|
||
updateHighlight();
|
||
if (state.autoRun) {
|
||
clearTimeout(editTimer);
|
||
editTimer = setTimeout(runAllLayers, 250);
|
||
}
|
||
});
|
||
editor.addEventListener("scroll", () => {
|
||
highlightEl.scrollTop = editor.scrollTop;
|
||
highlightEl.scrollLeft = editor.scrollLeft;
|
||
});
|
||
editor.addEventListener("keydown", (e) => {
|
||
if (e.key === "Tab") {
|
||
e.preventDefault();
|
||
const s = editor.selectionStart, t = editor.selectionEnd;
|
||
editor.value = editor.value.slice(0,s) + " " + editor.value.slice(t);
|
||
editor.selectionStart = editor.selectionEnd = s + 2;
|
||
editor.dispatchEvent(new Event("input"));
|
||
} else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||
e.preventDefault();
|
||
runAllLayers();
|
||
}
|
||
});
|
||
|
||
$("varsBtn").onclick = () => varsPanel.classList.toggle("open");
|
||
|
||
// undo / redo
|
||
$("undoBtn").onclick = doUndo;
|
||
$("redoBtn").onclick = doRedo;
|
||
$("modalCancel").onclick = closeModal;
|
||
$("modal").onclick = (e) => { if (e.target.id === "modal") closeModal(); };
|
||
|
||
// global shortcuts
|
||
window.addEventListener("keydown", (e) => {
|
||
// ignore plain typing in inputs (but Ctrl+Z / Ctrl+Y still work everywhere)
|
||
const meta = e.ctrlKey || e.metaKey;
|
||
if (meta && e.key.toLowerCase() === "z" && !e.shiftKey) {
|
||
e.preventDefault(); doUndo();
|
||
} else if (meta && (e.key.toLowerCase() === "y" || (e.key.toLowerCase() === "z" && e.shiftKey))) {
|
||
e.preventDefault(); doRedo();
|
||
} else if (e.key === "Escape") {
|
||
closeModal();
|
||
$("help").classList.remove("open");
|
||
}
|
||
});
|
||
|
||
window.addEventListener("resize", fitCanvasToPane);
|
||
|
||
// ---------------- Init ----------------
|
||
state.layers.push(newLayer("base", DEFAULT_CODE));
|
||
state.activeLayerId = state.layers[0].id;
|
||
renderLayers();
|
||
renderVarsPanel();
|
||
loadEditorFromActive();
|
||
|
||
// syntax-highlight all <pre> code examples inside the help panel
|
||
document.querySelectorAll("#help pre").forEach(pre => {
|
||
pre.innerHTML = highlightCode(pre.textContent).replace(/\n$/, "");
|
||
});
|
||
|
||
updateUndoButtons();
|
||
|
||
log("trakodag.web ready. Load an image to begin.", "info");
|
||
log("Hint: Ctrl/Cmd + Enter to run, Ctrl/Cmd+Z to undo, ? for the API, ⓘ Vars for special variables.", "info");
|
||
|
||
</script>
|
||
</body>
|
||
</html>
|