Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,10 @@ shell colour. One `SlotDrop.exe`, nothing to install.
Click any game to see its cartridge. For games on the card you can pick a shell
colour (**GBA Grey** is what new games get; Slot draws a game with no colour in
plain black), choose a finish, redo the label by dragging a box on RomM's
cartridge photo, or *Use my own image…*. If games on the card have no label,
cartridge photo, or *Use my own image…* and frame it the same way. The box
keeps the label's shape: drag it to move it, and drag a corner or use the
mouse wheel to resize it. On your own image it can go past the edges, which
come out white. If games on the card have no label,
*Make labels* does them all. When you're done, the eject button next to the
card in the top bar makes it safe to pull out.

Expand Down
60 changes: 44 additions & 16 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -357,31 +357,41 @@ func (a *App) SaveLabel(platform, stem string, romID int, box label.Box, shell *
return friendly(a.core.SaveLabel(platform, stem, romID, &box, shell))
}

// ChooseLabel asks for a picture and makes it the game's label. It returns
// false if you cancelled.
func (a *App) ChooseLabel(platform, stem string) (bool, error) {
// ChooseLabel asks for a picture for a platform's label and gets it ready to
// frame. It returns nil if you cancelled; SaveOwnLabel writes it.
func (a *App) ChooseLabel(platform string) (*core.LabelPlan, error) {
path, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "Choose a label picture",
Filters: []runtime.FileFilter{{DisplayName: "Pictures (*.png, *.jpg, *.webp)", Pattern: "*.png;*.jpg;*.jpeg;*.webp;*.gif"}},
})
if err != nil || path == "" {
return false, friendly(err)
return nil, friendly(err)
}
f, err := os.Open(path)
if err != nil {
return false, friendly(err)
return nil, friendly(err)
}
defer f.Close()
data, err := io.ReadAll(io.LimitReader(f, romm.MaxImageBytes+1))
if err != nil {
return false, friendly(err)
return nil, friendly(err)
}
if len(data) > romm.MaxImageBytes {
return false, errors.New("That picture is too big.")
return nil, errors.New("That picture is too big.")
}
return true, friendly(a.core.LabelFromImage(platform, stem, data))
plan, err := a.core.PlanOwnLabel(platform, data)
return plan, friendly(err)
}

// SaveOwnLabel writes the picture you chose, cut at box, as a card game's
// label. A null shell leaves the game's colour as it is.
func (a *App) SaveOwnLabel(platform, stem, token string, box label.Box, shell *sdcard.Shell) error {
return friendly(a.core.SaveOwnLabel(platform, stem, token, box, shell))
}

// DropOwnLabel forgets the picture you chose, when you cancel.
func (a *App) DropOwnLabel(token string) { a.core.DropOwnLabel(token) }

// SetShell changes just a game's shell colour.
func (a *App) SetShell(stem string, shell sdcard.Shell) error {
return friendly(a.core.SetShell(stem, shell))
Expand All @@ -408,6 +418,8 @@ func (a *App) SetMatch(platform, stem string, romID int) { a.core.SetMatch(platf
// /img/cart/<romId> cartridge photo
// /img/label/<GBA>/<stem> a label already on the card
// /img/crop/<romId>?box=... the label cut from the cartridge photo
// /img/own/<token> the picture you chose for a label
// /img/owncrop/<token>?box= the label cut from it
//
// platformIcon is RomM's own icon for a console, fetched once per server.
func (a *App) platformIcon(key string) ([]byte, error) {
Expand Down Expand Up @@ -435,6 +447,19 @@ func (a *App) platformIcon(key string) ([]byte, error) {
return data, nil
}

// boxParam reads ?box=l,t,r,b, or nil if there isn't one.
func boxParam(r *http.Request) *label.Box {
f := strings.Split(r.URL.Query().Get("box"), ",")
if len(f) != 4 {
return nil
}
var b label.Box
for i := range f {
b[i], _ = strconv.Atoi(f[i])
}
return &b
}

func (a *App) imageHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
parts := strings.SplitN(strings.TrimPrefix(r.URL.EscapedPath(), "/img/"), "/", 2)
Expand Down Expand Up @@ -470,15 +495,18 @@ func (a *App) imageHandler() http.Handler {
http.NotFound(w, r)
return
}
var box *label.Box
if f := strings.Split(r.URL.Query().Get("box"), ","); len(f) == 4 {
var b label.Box
for i := range f {
b[i], _ = strconv.Atoi(f[i])
}
box = &b
data, err = a.core.CropPNG(id, boxParam(r))
case "own":
data, err = a.core.OwnImage(parts[1])
w.Header().Set("Cache-Control", "no-store")
case "owncrop":
box := boxParam(r)
if box == nil {
http.NotFound(w, r)
return
}
data, err = a.core.CropPNG(id, box)
data, err = a.core.OwnCropPNG(parts[1], *box)
w.Header().Set("Cache-Control", "no-store")
case "platform":
// /img/platform/GBA: the console icon from your RomM, if it has one.
data, err = a.platformIcon(parts[1])
Expand Down
115 changes: 85 additions & 30 deletions frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -862,13 +862,13 @@ document.addEventListener("keydown", (e) => {

function openDrawer(g) {
state.drawer = g;
state.editor = null;
closeEditor();
state.pendingShell = null;
refreshMain();
}
function closeDrawer() {
state.drawer = null;
state.editor = null;
closeEditor();
refreshMain();
}

Expand All @@ -885,9 +885,10 @@ function drawerEl() {
// The big picture: the finished cartridge, drawn like Slot shows it.
const hero = h("div", { class: "hero" });
const showHero = () => {
const box = state.editor ? state.editor.box : null;
const ed = state.editor;
let src = null;
if (box) src = `/img/crop/${g.romId}?box=${box.join(",")}`;
if (ed && ed.token) src = `/img/owncrop/${ed.token}?box=${ed.box.join(",")}`;
else if (ed) src = `/img/crop/${g.romId}?box=${ed.box.join(",")}`;
else if (g.onCard && g.hasLabel) src = cardLabelURL(g);
else if (g.romId && g.hasCart) src = `/img/crop/${g.romId}`;
if (!src && !g.onCard && g.hasCover) {
Expand Down Expand Up @@ -990,7 +991,9 @@ function labelGroup(g, shell, showHero) {
// Label position on the photo.
if (ed) {
group.append(boxEditor(g, ed, showHero));
group.append(h("p", { class: "faint", style: { margin: "0 0 12px", fontSize: "12.5px" } }, "Drag the box to move it, or a corner to resize. The preview above updates when you let go."));
group.append(h("p", { class: "faint", style: { margin: "0 0 12px", fontSize: "12.5px" } },
"Drag the box to move it, or a corner or the mouse wheel to resize. The preview above updates when you let go.",
ed.token ? " Past the picture's edge comes out white." : ""));
}

const fromPhoto = g.romId && g.hasCart; // RomM has a cartridge photo to cut the label from
Expand All @@ -1005,7 +1008,7 @@ function labelGroup(g, shell, showHero) {
if (!g.hasLabel && !ed && fromPhoto) {
actions.append(h("button", { class: "btn primary", onclick: (e) => saveLabel(g, null, state.pendingShell, e.currentTarget) }, icon("wand"), "Make label"));
} else if (ed) {
actions.append(h("button", { class: "btn ghost", onclick: () => { state.editor = null; refreshMain(); } }, "Cancel"));
actions.append(h("button", { class: "btn ghost", onclick: () => { closeEditor(); refreshMain(); } }, "Cancel"));
actions.append(h("button", { class: "btn primary", onclick: (e) => saveLabel(g, ed.box, state.pendingShell, e.currentTarget) }, "Save label"));
} else if (dirty) {
actions.append(h("button", { class: "btn primary", onclick: (e) => saveShell(g, shell, e.currentTarget) }, "Save colour"));
Expand All @@ -1026,17 +1029,52 @@ async function startEditor(g, btn) {
refreshMain();
}

// closeEditor stops framing a label, and lets go of a picture you chose.
function closeEditor() {
if (state.editor && state.editor.token) api().DropOwnLabel(state.editor.token).catch(() => {});
state.editor = null;
}

// boxEditor frames the label on a picture: drag the box to move it, a corner
// or the wheel to resize it, always in the label's shape so it isn't
// stretched. On a picture you chose the box may go past its edges (by
// ed.marginX and ed.marginY) to leave white around it.
function boxEditor(g, ed, showHero) {
const mx = ed.marginX || 0, my = ed.marginY || 0;
const L = -mx, T = -my, R = ed.width + mx, B = ed.height + my; // where the box may go
const ew = R - L, eh = B - T;
const minW = Math.max(16, 16 * ed.aspect);
const maxW = Math.min(ew, eh * ed.aspect);
const boxEl = h("div", { class: "box" }, ["nw", "ne", "sw", "se"].map((k) => h("div", { class: "h", "data-h": k })));
const wrap = h("div", { class: "editor" }, h("img", { src: `/img/cart/${g.romId}`, alt: "" }), boxEl);
const pic = h("img", {
src: ed.token ? `/img/own/${ed.token}` : `/img/cart/${g.romId}`, alt: "",
style: { left: (mx / ew) * 100 + "%", top: (my / eh) * 100 + "%", width: (ed.width / ew) * 100 + "%", height: (ed.height / eh) * 100 + "%" },
});
const wrap = h("div", {
class: "editor" + (ed.token ? " loose" : ""),
style: { aspectRatio: `${ew} / ${eh}`, width: `min(100%, ${((55 * ew) / eh).toFixed(2)}vh)` },
}, pic, boxEl);
const place = () => {
const [l, t, r, b] = ed.box;
Object.assign(boxEl.style, {
left: (l / ed.width) * 100 + "%", top: (t / ed.height) * 100 + "%",
width: ((r - l) / ed.width) * 100 + "%", height: ((b - t) / ed.height) * 100 + "%",
left: ((l - L) / ew) * 100 + "%", top: ((t - T) / eh) * 100 + "%",
width: ((r - l) / ew) * 100 + "%", height: ((b - t) / eh) * 100 + "%",
});
};
// fit puts a box of width w at (l, t), in the label's shape and inside the area.
const fit = (l, t, w) => {
w = Math.min(Math.max(w, minW), maxW);
const hgt = w / ed.aspect;
l = Math.min(Math.max(L, l), R - w);
t = Math.min(Math.max(T, t), B - hgt);
ed.box = [l, t, l + w, t + hgt].map(Math.round);
place();
};
place();
const done = () => {
ed.moved = true;
showHero();
};
let drag = null;
boxEl.addEventListener("pointerdown", (e) => {
e.preventDefault();
Expand All @@ -1046,38 +1084,54 @@ function boxEditor(g, ed, showHero) {
boxEl.addEventListener("pointermove", (e) => {
if (!drag) return;
const rect = wrap.getBoundingClientRect();
const dx = ((e.clientX - drag.x) / rect.width) * ed.width;
const dy = ((e.clientY - drag.y) / rect.height) * ed.height;
let [l, t, r, b] = drag.box;
const w = r - l, hgt = b - t;
const dx = ((e.clientX - drag.x) / rect.width) * ew;
const dy = ((e.clientY - drag.y) / rect.height) * eh;
const [l, t, r, b] = drag.box;
if (drag.handle === "move") {
l = Math.min(Math.max(0, l + dx), ed.width - w); t = Math.min(Math.max(0, t + dy), ed.height - hgt);
r = l + w; b = t + hgt;
} else {
if (drag.handle.includes("w")) l = Math.min(Math.max(0, l + dx), r - 10);
if (drag.handle.includes("e")) r = Math.max(Math.min(ed.width, r + dx), l + 10);
if (drag.handle.includes("n")) t = Math.min(Math.max(0, t + dy), b - 10);
if (drag.handle.includes("s")) b = Math.max(Math.min(ed.height, b + dy), t + 10);
fit(l + dx, t + dy, r - l);
return;
}
ed.box = [l, t, r, b].map(Math.round);
place();
// A corner: the opposite corner stays put, and the box grows to reach
// the pointer in whichever direction it went further.
const east = drag.handle.includes("e"), south = drag.handle.includes("s");
const ax = east ? l : r, ay = south ? t : b;
const wide = east ? r + dx - ax : ax - (l + dx);
const tall = south ? b + dy - ay : ay - (t + dy);
let w = Math.max(wide, tall * ed.aspect);
w = Math.min(Math.max(w, minW), east ? R - ax : ax - L, (south ? B - ay : ay - T) * ed.aspect);
fit(east ? ax : ax - w, south ? ay : ay - w / ed.aspect, w);
});
const end = () => {
if (!drag) return;
drag = null;
ed.moved = true;
showHero();
done();
};
boxEl.addEventListener("pointerup", end);
boxEl.addEventListener("pointercancel", end);
// The wheel zooms around the pointer: down shows more of the picture.
let settle = 0;
wrap.addEventListener("wheel", (e) => {
e.preventDefault();
const rect = wrap.getBoundingClientRect();
const px = L + ((e.clientX - rect.left) / rect.width) * ew;
const py = T + ((e.clientY - rect.top) / rect.height) * eh;
const [l, t, r, b] = ed.box;
const w = Math.min(Math.max((r - l) * Math.exp(e.deltaY * 0.0015), minW), maxW);
const fx = Math.min(Math.max((px - l) / (r - l), 0), 1);
const fy = Math.min(Math.max((py - t) / (b - t), 0), 1);
fit(px - fx * w, py - fy * (w / ed.aspect), w);
clearTimeout(settle);
settle = setTimeout(done, 250);
}, { passive: false });
return wrap;
}

async function saveLabel(g, box, shell, btn) {
btn.disabled = true;
btn.replaceChildren(h("span", { class: "spinner" }), "Saving…");
try {
if (box) await api().SaveLabel(g.platform, g.stem, g.romId, box, shell);
if (box && state.editor && state.editor.token) await api().SaveOwnLabel(g.platform, g.stem, state.editor.token, box, shell);
else if (box) await api().SaveLabel(g.platform, g.stem, g.romId, box, shell);
else {
await api().SaveLabel(g.platform, g.stem, g.romId, (await api().PlanLabel(g.romId)).box, shell);
}
Expand All @@ -1092,14 +1146,15 @@ async function saveLabel(g, box, shell, btn) {
refreshMain();
}

// Pick a picture on the PC (a label scan, or a whole cartridge photo) for a game's label.
// Pick a picture on the PC (a label scan, or a whole cartridge photo) for a
// game's label, then frame it in the editor before saving.
async function chooseLabel(g, btn) {
btn.disabled = true;
try {
if (await api().ChooseLabel(g.platform, g.stem)) {
state.labelVersion = Date.now();
toast(`Saved your label for ${g.title}.`);
await reload();
const plan = await api().ChooseLabel(g.platform);
if (plan) {
closeEditor();
state.editor = { ...plan, box: plan.box.slice(), moved: false };
refreshMain();
return;
}
Expand Down
7 changes: 5 additions & 2 deletions frontend/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -329,8 +329,11 @@ body.selecting #toasts { bottom: 88px; }
.facts { display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; font-size: 13px; margin-bottom: 16px; }
.facts dt { color: var(--text-3); }
.facts dd { margin: 0; user-select: text; word-break: break-word; }
.editor { position: relative; border-radius: 12px; overflow: hidden; background: #101018; margin-bottom: 12px; touch-action: none; }
.editor img { width: 100%; display: block; pointer-events: none; }
.editor { position: relative; border-radius: 12px; overflow: hidden; background: #101018; margin: 0 auto 12px; touch-action: none; }
.editor img { position: absolute; display: block; pointer-events: none; }
/* A picture you chose: room around it, white like the label comes out. */
.editor.loose { background: #fff; }
.editor.loose img { outline: 1px dashed rgba(0,0,0,.3); }
.editor .box {
position: absolute; border: 2px solid var(--gbc); box-shadow: 0 0 0 9999px rgba(0,0,0,.45); cursor: move; border-radius: 2px;
}
Expand Down
Loading
Loading