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