From f8ecc3e3d24b30fa42911574cd8697aafdc41298 Mon Sep 17 00:00:00 2001 From: Florian Motlik Date: Fri, 25 Sep 2026 16:53:46 +0000 Subject: [PATCH] Bildausschnitt und gruene Flaeche fuer Text auf Foto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hintergrundbilder sind laut CD ein zentrales Gestaltungsmittel. Zwei Dinge fehlten dafuer. BILDAUSSCHNITT Das Foto wird formatfuellend skaliert, eine Achse steht ueber und wird beschnitten. Die App laesst diese Achse bewusst beweglich, damit ein Mensch den Ausschnitt waehlen kann (Kommentar in canvas-utils.js: "leave the overflowing axis free so the user can still pan the image"). Die Fassade konnte nur zentriert — bei einem Portraet in einer breiten Vorlage also zwangsweise ohne Kopf. CanvasUtils.backgroundFocus, gesetzt ueber setBackground(url, {focusX, focusY}). 0 bis 1, Vorgabe 0,5 — das bisherige Verhalten bleibt damit unveraendert. Die llms.txt nennt Faustregeln statt nur der Parameter. Kein einziger der drei Reviewer hat diese Luecke erwaehnt. Mein Review-Prompt hat Bilder nur als Untergrund fuer anderes aufgezaehlt, nie als eigenstaendiges Gestaltungsmittel — die Reviewer sind meiner Aufzaehlung gefolgt. GRUENE FLAECHE Die zentrale CD-Regel "Typografie steht immer in Kombination mit Gruen" war mit einem Hintergrundfoto gar nicht einhaltbar: Es gab keine Moeglichkeit, eine gruene Flaeche unter den Text zu legen. Alle drei Reviewer nennen das. Als echter Knopf in der Oberflaeche, nicht nur in der Fassade — damit "kein zweiter Pfad" gilt, der Abdeckungstest weiter greift und Menschen die Faehigkeit auch bekommen. Dazu addText(text, { panel: true }) als bequemer Weg. Ein Fehler, den erst das gerenderte Bild zeigte: Die erste Fassung koppelte Flaeche und Text nicht. Die Tests waren gruen, das Bild nicht — place('bottom') verschob nur den Text, die Flaeche blieb in der Mitte, der Text sass halb daneben. Schlechter als gar keine Flaeche. Die Flaeche haengt jetzt an ihrem Text und wird nach jedem Verschieben, Skalieren, Drehen und Aendern neu eingepasst. Nebenbei korrigiert: objects() stempelte JEDES Rechteck als Rolle "canvas" — also auch die neue Flaeche, die damit den Schutzstatus der Grundflaeche geerbt haette. Jetzt ist nur contentRect "canvas", andere Rechtecke sind "panel". VERSION auf 11. 132 Jest, 127 e2e gruen. Claude-Session: https://claude.ai/code/session_01Qtib9MPRZATQU3j9w7ymDk --- CLAUDE.md | 21 ++++ e2e/background-and-panel.spec.js | 195 +++++++++++++++++++++++++++++++ index.html | 3 + llms.txt | 67 ++++++++++- resources/js/api.js | 142 +++++++++++++++++++++- resources/js/canvas-utils.js | 37 +++++- resources/js/event-handlers.js | 35 ++++++ 7 files changed, 484 insertions(+), 16 deletions(-) create mode 100644 e2e/background-and-panel.spec.js diff --git a/CLAUDE.md b/CLAUDE.md index 3ae23e7..7b13971 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,6 +187,27 @@ controls and handlers a person does; it is NOT a second rendering path. `app.min.js` (from "Core utilities" through `qrcode-handlers.js`). A tag after it is served separately and goes stale. +### Background photographs and the green panel + +`CanvasUtils.backgroundFocus` decides which part of an overflowing background +photo survives the cover-crop. 0.5/0.5 is the centre and the long-standing +behaviour; `setBackgroundFocus(x, y)` moves it. The facade exposes this as +`setBackground(url, { focusX, focusY })` — a portrait in a landscape template +loses the head at the default, and a caller that cannot drag the image had no +way to fix that. + +`#add-panel` adds a green rectangle. It exists because the brand rule "type +always in combination with green" is otherwise unachievable over a photograph +— there was no way to put a green surface under a headline. It is a real UI +button, not a facade-only path, so the coverage test stays meaningful and +people get the capability too. + +`addText(text, { panel: true })` binds a panel to that text via +`text._gatPanel`, and `_refitPanel()` re-sizes and re-centres it after every +move, scale, rotate or update. Without that binding a later `place()` moves +the text and leaves the surface behind — the text ends up half off its own +panel, which is worse than no panel at all. + ### Logo region name: knockout or filled The region name on the white logo bar is cut OUT of the bar diff --git a/e2e/background-and-panel.spec.js b/e2e/background-and-panel.spec.js new file mode 100644 index 0000000..a79913d --- /dev/null +++ b/e2e/background-and-panel.spec.js @@ -0,0 +1,195 @@ +import { test, expect } from '@playwright/test'; + +/** + * Background photographs as a design element, and the green surface the brand + * rule requires under type. + * + * Both were gaps the external review named: the photo could only ever be + * centred, and there was no way at all to put type on green over a photo — + * which made the brand's central typography rule unachievable with a + * background image. + */ + +// Built in the page rather than pasted as base64, so the test cannot fail on +// a hand-assembled PNG. Wide and short: in a tall frame the width overflows, +// which is exactly the case the focus point exists for. +const WIDE_IMAGE = `(() => { + const c = document.createElement('canvas'); + c.width = 400; c.height = 100; + const x = c.getContext('2d'); + x.fillStyle = '#ff0000'; x.fillRect(0, 0, 200, 100); + x.fillStyle = '#0000ff'; x.fillRect(200, 0, 200, 100); + return c.toDataURL(); +})()`; + +const TINY_PNG = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FAP5FDvcfRYWgAAAAAElFTkSuQmCC'; + +test.describe('Background focus and green panel', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForFunction(() => typeof window.Bildgenerator !== 'undefined'); + page.on('dialog', (dialog) => dialog.accept()); + }); + + test('the focus point decides which part of the photo survives the crop', async ({ page }) => { + const result = await page.evaluate(async (source) => { + const B = window.Bildgenerator; + const png = eval(source); + const positions = {}; + for (const [name, focusX] of [['left', 0], ['centre', 0.5], ['right', 1]]) { + await B.setTemplate('story'); // tall frame, wide photo: x overflows + await B.setBackground(png, { focusX }); + positions[name] = Math.round(contentImage.left); + } + return positions; + }, WIDE_IMAGE); + + // focusX 0 pins the left edge of the photo to the frame; 1 pushes it left + // so the right edge shows. Centre sits between the two. + expect(result.left).toBeGreaterThan(result.centre); + expect(result.centre).toBeGreaterThan(result.right); + }); + + test('the default is still centred, exactly as before', async ({ page }) => { + const result = await page.evaluate(async (source) => { + const B = window.Bildgenerator; + const png = eval(source); + await B.setTemplate('story'); + await B.setBackground(png); + const withoutOption = Math.round(contentImage.left); + await B.setBackground(png, { focusX: 0.5 }); + return { withoutOption, explicitCentre: Math.round(contentImage.left) }; + }, WIDE_IMAGE); + + expect(result.withoutOption).toBe(result.explicitCentre); + }); + + test('a focus value outside 0..1 is refused', async ({ page }) => { + const message = await page.evaluate(async (png) => { + try { + await window.Bildgenerator.setBackground(png, { focusY: 1.5 }); + return null; + } catch (e) { + return e.message; + } + }, TINY_PNG); + + expect(message).toContain('0 to 1'); + }); + + test('the green panel exists as a button and in options()', async ({ page }) => { + const result = await page.evaluate(() => ({ + button: !!document.getElementById('add-panel'), + shapes: window.Bildgenerator.options().shapes, + })); + + // The facade drives the same control a person does — no second path. + expect(result.button).toBe(true); + expect(result.shapes).toContain('panel'); + }); + + test('addPanel puts a green surface on the canvas behind the text', async ({ page }) => { + const result = await page.evaluate(async () => { + const B = window.Bildgenerator; + await B.setTemplate('feed_post_45'); + await B.setLogoEnabled(false); + await B.addText('Rückenwind'); + await B.addPanel({ width: 0.9, height: 0.3 }); + + const objects = B.objects(); + const panel = objects.filter((o) => o.type === 'rect').pop(); + const text = objects.find((o) => o.type === 'text'); + return { panelIndex: panel.index, textIndex: text.index, colour: panel.color }; + }); + + expect(result.colour.toLowerCase()).toBe('#257639'); + // Panel behind the text, not over it. + expect(result.panelIndex).toBeLessThan(result.textIndex); + }); + + test('addText({ panel: true }) sizes the surface to the text and keeps text on top', async ({ page }) => { + const result = await page.evaluate(async () => { + const B = window.Bildgenerator; + await B.setTemplate('artikel_23'); + await B.setLogoEnabled(false); + await B.addText('Windpark kommt', { panel: true }); + + const objects = B.objects(); + const panel = objects.filter((o) => o.type === 'rect').pop(); + const text = objects.find((o) => o.type === 'text'); + return { + panel, text, + trackedIsText: B.lastAdded().type, + }; + }); + + // Surface larger than the type on both axes, and centred on it. + expect(result.panel.width).toBeGreaterThan(result.text.width); + expect(result.panel.height).toBeGreaterThan(result.text.height); + expect(result.panel.left + result.panel.width / 2) + .toBeCloseTo(result.text.left + result.text.width / 2, 0); + expect(result.panel.index).toBeLessThan(result.text.index); + + // The text remains the tracked element, so a following place() moves the + // text — which is what a caller writing this would expect. + expect(result.trackedIsText).toBe('text'); + }); + + test('the green surface follows its text when the text is moved or scaled', async ({ page }) => { + const result = await page.evaluate(async () => { + const B = window.Bildgenerator; + await B.setTemplate('artikel_23'); + await B.setLogoEnabled(false); + await B.addText('Windpark kommt', { panel: true }); + + const centre = (o) => ({ x: o.left + o.width / 2, y: o.top + o.height / 2 }); + const read = () => { + const objects = B.objects(); + return { + panel: centre(objects.find((o) => o.role === 'panel')), + text: centre(objects.find((o) => o.type === 'text')), + panelSize: objects.find((o) => o.role === 'panel'), + textSize: objects.find((o) => o.type === 'text'), + }; + }; + + await B.place('bottom'); + const moved = read(); + await B.resize(0.5); + const scaled = read(); + return { moved, scaled }; + }); + + // Moving the text used to leave the surface behind, so the text ended up + // half off its own panel — worse than having no panel at all. + expect(result.moved.panel.x).toBeCloseTo(result.moved.text.x, 0); + expect(result.moved.panel.y).toBeCloseTo(result.moved.text.y, 0); + + // And scaling must shrink the surface with the type, not leave a slab. + expect(result.scaled.panel.x).toBeCloseTo(result.scaled.text.x, 0); + expect(result.scaled.panelSize.width).toBeLessThan(result.moved.panelSize.width); + expect(result.scaled.panelSize.width).toBeGreaterThan(result.scaled.textSize.width); + }); + + test('type over a photo can be put on green in one call', async ({ page }) => { + const objects = await page.evaluate(async (png) => { + const B = window.Bildgenerator; + await B.render({ + template: 'artikel_23', + background: png, + logo: 'HERZOGENBURG', + texts: [{ text: 'Windpark kommt', panel: true, position: 'center' }], + dpi: 72, + }); + return B.objects().map((o) => o.role); + }, TINY_PNG); + + // background photo, green panel, text, logo — the brand rule satisfied + // with a photograph, which was impossible before. + expect(objects).toContain('background'); + expect(objects).toContain('logo'); + expect(objects).toContain('panel'); + expect(objects).toContain('text'); + }); +}); diff --git a/index.html b/index.html index 38cdfa2..2292121 100644 --- a/index.html +++ b/index.html @@ -1079,6 +1079,9 @@

Weitere Elemente

+ diff --git a/llms.txt b/llms.txt index 25100da..3b3c649 100644 --- a/llms.txt +++ b/llms.txt @@ -49,7 +49,7 @@ weitere Freigabe. Seite laden, warten bis `window.Bildgenerator` existiert, dann aufrufen. Alle Methoden sind asynchron und liefern erst zurück, wenn das Canvas seine -Ruhelage erreicht hat. Aktuelle Schnittstellenversion: `Bildgenerator.VERSION === 10`. +Ruhelage erreicht hat. Aktuelle Schnittstellenversion: `Bildgenerator.VERSION === 11`. ```js const bild = await window.Bildgenerator.render({ @@ -120,9 +120,10 @@ Objekt zurück, sind also verkettbar. | `setLogo(name)` | Organisationslogo setzen | | `setLogoEnabled(false)` | Organisationslogo entfernen | | `setLogoKnockout(bool)` | Ortsname im Logo durchsichtig oder grün gefüllt | -| `setBackground(url)` | Hintergrundbild setzen | +| `setBackground(url, opts)` | Hintergrundbild setzen, mit Bildausschnitt | | `addText(text, opts)` | Text hinzufügen, beliebig oft — Optionen siehe unten | -| `addShape(kind)` | `'pinkCircle'` oder `'cross'` hinzufügen | +| `addShape(kind)` | `'pinkCircle'`, `'cross'` oder `'panel'` hinzufügen | +| `addPanel(opts)` | grüne Fläche für Text | | `addImage(url)` | weiteres Bild auf das Motiv legen | | `addQRCode({text, color})` | QR-Code auf das Bild setzen | | `place(position)` | das zuletzt hinzugefügte Element positionieren | @@ -337,9 +338,27 @@ Text wird ausschließlich auf einer grünen Farbfläche platziert. Auf einem Hintergrundfoto ist Text nur zulässig, wenn er auf einer grünen Fläche liegt — oder wenn die abgebildete Person eindeutig vor Grün steht. -Das erzwingt der Generator **nicht**. Ohne Hintergrundbild ist die Fläche -ohnehin grün, dann passt es immer. Sobald du ein Foto setzt, liegt es an dir: -Entweder du lässt den Text weg, oder du sorgst dafür, dass er auf Grün sitzt. +Ohne Hintergrundbild ist die Fläche ohnehin grün, dann passt es immer. Sobald +du ein Foto setzt, gehört unter jeden Text eine grüne Fläche: + +```js +const B = window.Bildgenerator; +const foto = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FAP5FDvcfRYWgAAAAAElFTkSuQmCC'; + +await B.setTemplate('artikel_23'); +await B.setBackground(foto); +await B.addText('Windpark kommt', { panel: true }); +await B.place('bottom'); +``` + +`panel: true` legt die Fläche an, passt sie an den Text an und hält sie +darunter — auch wenn du den Text danach verschiebst oder skalierst. Der Text +bleibt dabei das aktive Element, `place()` und `resize()` meinen also weiter +ihn. + +Brauchst du die Fläche für sich, etwa als Farbblock ohne Text, gibt es +`addPanel({ width, height })` mit Anteilen der nutzbaren Fläche. + Weißer Text quer über ein Foto ist CD-widrig, auch wenn er lesbar ist. Hintergründe sind entweder Foto **oder** Farbfläche — nicht beides vermischt. @@ -544,6 +563,42 @@ Steuern lässt es sich über `setLogoKnockout(true|false)` oder In der Oberfläche ist es die Checkbox „Ortsname im Logo durchsichtig" in Schritt 1. +## Hintergrundbilder + +Das Foto wird formatfüllend skaliert. Eine Achse steht dabei fast immer über +und wird beschnitten — ein Hochformat in einer Querformat-Vorlage verliert +links und rechts, ein Querformat in einer hohen Vorlage oben und unten. + +**Welcher Teil überlebt, entscheidest du.** Ohne Angabe wird mittig +beschnitten, und das ist oft falsch: Bei einem Porträt in einer breiten +Vorlage fehlt dann der Kopf. + +```js +const B = window.Bildgenerator; +const foto = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FAP5FDvcfRYWgAAAAAElFTkSuQmCC'; +await B.setTemplate('event'); + +await B.setBackground(foto, { focusY: 0 }); // oberer Bildrand, Gesichter +await B.setBackground(foto, { focusX: 1 }); // rechter Bildrand +await B.setBackground(foto); // mittig, wie bisher +``` + +`focusX` und `focusY` laufen von `0` (links beziehungsweise oben) über `0.5` +(Mitte, Vorgabe) bis `1` (rechts beziehungsweise unten). Werte außerhalb +werden abgelehnt. + +Faustregeln: + +- **Porträt oder Person:** `focusY: 0` bis `0.3`, damit der Kopf im Bild + bleibt. +- **Landschaft mit Himmel:** `focusY: 0.6` bis `1`, damit nicht der halbe + Himmel das Bild füllt. +- **Gruppenbild:** mittig lassen und lieber ein Foto wählen, dessen + Seitenverhältnis zur Vorlage passt — ein starker Beschnitt schneidet sonst + immer jemanden ab. + +Steht Text auf dem Foto, gehört eine grüne Fläche darunter — siehe oben. + ## Formate | Name | Canvas | Wofür | diff --git a/resources/js/api.js b/resources/js/api.js index 8cf340c..19706ce 100644 --- a/resources/js/api.js +++ b/resources/js/api.js @@ -31,7 +31,7 @@ * breakage surfaces in e2e/api.spec.js instead of in somebody else's script. */ const Bildgenerator = { - VERSION: 10, + VERSION: 11, /** The object the most recent add* call put on the canvas. */ _lastAdded: null, @@ -58,7 +58,7 @@ const Bildgenerator = { fontStyles: AppConstants.FONTS.OPTIONS.map(function (o) { return o.id; }), lineHeights: this._optionValues("#line-height"), alignments: ["left", "center", "right"], - shapes: ["pinkCircle", "cross"], + shapes: ["pinkCircle", "cross", "panel"], clipSizes: this._optionValues("#circle-radius"), qrColorsOnImage: this._optionValues("#qr-color"), qrColors: this._optionValues("#qr-color-select"), @@ -152,11 +152,33 @@ const Bildgenerator = { * Set the background image from a data: URL or an absolute URL. Goes * straight to processMeme() rather than through the #meme-input file * picker, so callers never have to synthesise a File. + * + * The photo is cover-scaled, so one axis overflows and gets cropped. + * `focusX` / `focusY` decide which part survives: 0.5 is the centre and + * the long-standing default, 0 pins the left or top edge, 1 the right or + * bottom. A portrait in a landscape template usually wants focusY near 0 + * so the face is not cut off — centring is a guess, and often the wrong + * one. + * + * @param {string} url + * @param {Object} [options] + * @param {number} [options.focusX=0.5] + * @param {number} [options.focusY=0.5] */ - async setBackground(url) { + async setBackground(url, options) { if (typeof url !== "string" || !url) { throw new Error("setBackground() needs a data: URL or an absolute URL"); } + const opts = options || {}; + const focus = function (v) { + if (v === undefined) return undefined; + if (typeof v !== "number" || v < 0 || v > 1) { + throw new Error("setBackground() focus values run from 0 to 1"); + } + return v; + }; + // Set before the image loads: positionBackgroundImage() reads it. + CanvasUtils.setBackgroundFocus(focus(opts.focusX), focus(opts.focusY)); processMeme({ url: url }); await this._waitFor(() => !!window.contentImage, "background image to load"); await this._waitUntilQuiet(); @@ -177,6 +199,9 @@ const Bildgenerator = { * the block itself, use place(). * @param {string} [options.lineHeight] value from options().lineHeights * @param {number} [options.shadow] shadow depth + * @param {boolean|Object} [options.panel] put a green surface behind this + * text — required by the brand rule + * when a background photo is set */ async addText(text, options) { const opts = options || {}; @@ -197,6 +222,14 @@ const Bildgenerator = { jQuery("#text").val(text); jQuery("#add-text").trigger("click"); }, "text object to be added to the canvas"); + + // Convenience for the brand rule: put a green surface under this text, + // sized to it. Doing it here rather than leaving the arithmetic to the + // caller is the difference between a rule that gets followed and one + // that gets skipped. + if (opts.panel) { + await this._panelBehind(this.lastAdded(), opts.panel); + } return this; }, @@ -206,7 +239,11 @@ const Bildgenerator = { * entirely — see clipToCircle(). */ async addShape(kind) { - const buttons = { pinkCircle: "#add-pink-circle", cross: "#add-cross" }; + const buttons = { + pinkCircle: "#add-pink-circle", + cross: "#add-cross", + panel: "#add-panel", + }; const selector = buttons[kind]; if (!selector) { throw new Error( @@ -220,6 +257,91 @@ const Bildgenerator = { return this; }, + /** + * Put a green surface behind one text, sized to it with padding, and keep + * the text on top. The padding is a share of the text's own height, so it + * scales with the type rather than with the canvas. + */ + async _panelBehind(text, options) { + const opts = typeof options === "object" && options !== null ? options : {}; + await this.addPanel(); + const panel = this.lastAdded(); + + // Bind the two together. Without this a later place() moves the text + // and leaves the surface where it was — the text ends up half off its + // own panel, which is worse than having no panel at all. + text._gatPanel = panel; + text._gatPanelPadding = typeof opts.padding === "number" ? opts.padding : 0.35; + this._refitPanel(text); + + // The text stays the tracked element: a caller writing + // addText(t, { panel: true }) then place(...) means the text. + this._lastAdded = text; + return this; + }, + + /** + * Re-size and re-centre a text's green surface on the text. Called after + * anything that moves or scales the text, so the two never come apart. + */ + _refitPanel(text) { + const panel = text && text._gatPanel; + if (!panel || !canvas.getObjects().includes(panel)) return; + + const padding = text._gatPanelPadding || 0.35; + const width = text.getScaledWidth(); + const height = text.getScaledHeight(); + + panel.set({ + scaleX: (width + height * padding * 2) / panel.width, + scaleY: (height + height * padding) / panel.height, + }); + panel._gatBaseScale = panel.scaleX; + panel.setCoords(); + panel.set({ + left: text.left + width / 2 - panel.getScaledWidth() / 2, + top: text.top + height / 2 - panel.getScaledHeight() / 2, + }); + panel.setCoords(); + + canvas.bringToFront(text); + CanvasUtils.bringLogoToFront(); + canvas.renderAll(); + }, + + /** + * Add a green surface for type to sit on. + * + * The brand guide requires typography to be combined with green. Without + * a photo the whole canvas already is green, so this is only needed over + * a background image — but there it is not optional: white text laid + * straight over a photograph breaks the rule, however legible it looks. + * + * The panel lands behind every text already on the canvas. + * + * @param {Object} [options] + * @param {number} [options.width=0.8] share of the usable width + * @param {number} [options.height=0.28] share of the usable height + */ + async addPanel(options) { + const opts = options || {}; + this._openSection("elements-section"); + const panel = await this._addTracking(function () { + jQuery("#add-panel").trigger("click"); + }, "green panel to be added"); + + if (typeof opts.width === "number" || typeof opts.height === "number") { + const width = (typeof opts.width === "number" ? opts.width : 0.8) * contentRect.width; + const height = (typeof opts.height === "number" ? opts.height : 0.28) * contentRect.height; + panel.set({ scaleX: width / panel.width, scaleY: height / panel.height }); + panel._gatBaseScale = panel.scaleX; + panel.setCoords(); + canvas.centerObject(panel); + canvas.renderAll(); + } + return this; + }, + /** * Add a free-standing image on top of the design (not the background). * The handler reads a File from the input, so the URL is wrapped in one @@ -335,6 +457,7 @@ const Bildgenerator = { top: host.top + (host.getScaledHeight() - height) / 2, }); target.setCoords(); + this._refitPanel(target); canvas.renderAll(); return this; } @@ -356,6 +479,7 @@ const Bildgenerator = { top: margin + Math.max(0, usableHeight) * (position.y || 0), }); target.setCoords(); + this._refitPanel(target); canvas.renderAll(); return this; } @@ -395,6 +519,7 @@ const Bildgenerator = { target.set({ left: left, top: top }); target.setCoords(); + this._refitPanel(target); canvas.renderAll(); return this; }, @@ -428,6 +553,7 @@ const Bildgenerator = { } const base = target._gatBaseScale || target.scaleX || 1; target.scale(base * factor).setCoords(); + this._refitPanel(target); canvas.renderAll(); return this; }, @@ -474,6 +600,7 @@ const Bildgenerator = { } target.rotate(((degrees % 360) + 360) % 360); target.setCoords(); + this._refitPanel(target); canvas.renderAll(); return this; }, @@ -543,9 +670,13 @@ const Bildgenerator = { return { index: index, type: o.type, + // Only the ONE rect the app created as the canvas surface is + // "canvas". Any other rect is a green panel a caller added, + // and must not inherit the canvas's protected status. role: o === logoObject ? "logo" : o === contentImage ? "background" - : o.type === "rect" ? "canvas" + : o === contentRect ? "canvas" + : o.type === "rect" ? "panel" : o.type, text: o.type === "text" ? o.text : undefined, color: o.fill, @@ -634,6 +765,7 @@ const Bildgenerator = { target.initDimensions(); } target.setCoords(); + this._refitPanel(target); canvas.renderAll(); return this; }, diff --git a/resources/js/canvas-utils.js b/resources/js/canvas-utils.js index 26532f6..a74c320 100644 --- a/resources/js/canvas-utils.js +++ b/resources/js/canvas-utils.js @@ -259,6 +259,30 @@ const CanvasUtils = { }, // Background image positioning + /** + * Where the background photo is anchored when it overflows the frame. + * 0.5/0.5 is the centre — what the app has always done. 0 shows the left + * or top edge, 1 the right or bottom. + * + * The photo is cover-scaled, so one axis almost always overflows and + * something gets cropped. Centring is a guess: a portrait in a landscape + * template loses the top of the head, a group photo loses the people at + * the edges. A person can drag the image to fix that (the overflowing + * axis is left unlocked below); this is the same choice for callers who + * cannot drag. + */ + backgroundFocus: { x: 0.5, y: 0.5 }, + + setBackgroundFocus(x, y) { + const clamp = function (v, fallback) { + return typeof v === "number" && v >= 0 && v <= 1 ? v : fallback; + }; + this.backgroundFocus = { + x: clamp(x, this.backgroundFocus.x), + y: clamp(y, this.backgroundFocus.y), + }; + }, + positionBackgroundImage() { if (contentImage != null) { canvas.remove(contentRect); @@ -299,12 +323,15 @@ const CanvasUtils = { contentImage.clipPath = clipRect; canvas.add(contentImage); canvas.sendToBack(contentImage); - // Center the image over the CONTENT RECT (not the whole canvas, so - // bordered templates stay correct) on both axes. Splitting the - // overflow evenly keeps the rect fully covered with no gap. + // Anchor the image over the CONTENT RECT (not the whole canvas, so + // bordered templates stay correct). The overflow is distributed + // according to backgroundFocus: 0.5 splits it evenly, which is the + // long-standing centred behaviour; 0 pins the left/top edge, 1 the + // right/bottom. Either way the rect stays fully covered. + const focus = this.backgroundFocus; contentImage.set({ - left: contentRect.left + (contentRect.width - scaledWidth) / 2, - top: contentRect.top + (contentRect.height - scaledHeight) / 2, + left: contentRect.left - (scaledWidth - contentRect.width) * focus.x, + top: contentRect.top - (scaledHeight - contentRect.height) * focus.y, }); contentImage.setCoords(); } diff --git a/resources/js/event-handlers.js b/resources/js/event-handlers.js index b166c00..49d5b10 100644 --- a/resources/js/event-handlers.js +++ b/resources/js/event-handlers.js @@ -287,6 +287,40 @@ const EventHandlerUtils = { }); }, + // Green panel handler. + // + // The brand guide requires type to sit on a green surface. Without a + // photo the whole canvas already is one, so this is only needed over a + // background image — but there it is not optional: white text laid + // straight over a photograph breaks the rule, however legible it looks. + setupPanelHandler() { + this.bindHandler('#add-panel', 'click', function() { + const panel = new fabric.Rect({ + width: contentRect.width * 0.8, + height: contentRect.height * 0.28, + fill: AppConstants.COLORS.BACKGROUND_SECONDARY, + objectCaching: false, + }); + + CanvasUtils.relativeScalingControlsOnly(panel); + canvas.add(panel); + canvas.centerObject(panel); + + // Behind every text already on the canvas — a panel added after a + // headline is meant to go under it, not over it. + canvas.getObjects().forEach(function (object) { + if (object.get("type") === "text") { + canvas.bringToFront(object); + } + }); + CanvasUtils.bringLogoToFront(); + + canvas.setActiveObject(panel); + updateScale(panel); + canvas.renderAll(); + }); + }, + // Cross/check mark handler setupCrossHandler() { this.bindHandler('#add-cross', 'click', function() { @@ -451,6 +485,7 @@ const EventHandlerUtils = { this.setupCircleClipHandler(); this.setupPinkCircleHandler(); this.setupCrossHandler(); + this.setupPanelHandler(); this.setupQRCodeHandler(); this.setupDownloadHandler(); this.setupLogoToggleHandler();