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();