diff --git a/docs/feature-specs/program-certificates.md b/docs/feature-specs/program-certificates.md index e16eb236b..be2519591 100644 --- a/docs/feature-specs/program-certificates.md +++ b/docs/feature-specs/program-certificates.md @@ -12,14 +12,30 @@ Фон PNG/JPG, прозрачная подпись PNG, директор и должность. Элементы перемещаются мышью/касанием или стрелками, размер меняется за угол. Подпись сохраняет -пропорции. Нет формы координат. Доступны центрирование, возврат к сохраненному -размещению, увеличенный редактор, проверка длинного имени и пробный PDF. +пропорции. Нет формы координат. Доступны центрирование по обеим осям, +отмена/повтор действий, увеличенный редактор с масштабом, проверка длинного +имени и пробный PDF. Размер текста задается в панели выбранного элемента. + +Обязательно только имя участника. Все восемь других элементов можно независимо +удалить и вернуть через список без потери расположения. «Только имя» позволяет +загрузить готовый дизайн фоном и подставлять только персональное ФИО. Новые +шаблоны начинают с имени по центру; старые сохраняют видимость и геометрию. +Контракт `hidden_elements` / `hiddenElements` описан в парном API PR; legacy +`show_text=false` нормализуется в скрытые заголовки. Два режима: все зарегистрированные либо участники команд со сданным решением в текущей программе. Нет дополнительной проверки почты. Запуск вручную или по дате после завершения; подтверждение, пробное письмо только себе, пауза. Все адресаты и документы поступают из API, без демонстрационных участников. +Несохраненные изменения не блокируют переход к запуску: действие «Перейти к +запуску» сначала сохраняет revision, затем открывает отдельное +подтверждение. При ошибке сохранения форма остается заполненной, выдача не +запускается. Активная очередь ведет к отправкам; ноль получателей и неверная +дата объясняются рядом с действием. Пробный PDF и пробное письмо также сначала +сохраняют изменения. Подписи действий остаются компактными; сохранение перед +действием объясняется рядом с кнопкой. Сохранение само по себе не отправляет письма. + Журнал: независимые готовность PDF и отправка, Все/Отправлено/Ошибки, серверный поиск/пагинация, повтор одной или всех ошибок, архив по фильтру. Повтор не создает новый PDF и не затрагивает отправленные письма. diff --git a/docs/qa/certificates-editor-20261024.md b/docs/qa/certificates-editor-20261024.md new file mode 100644 index 000000000..1f51d9aea --- /dev/null +++ b/docs/qa/certificates-editor-20261024.md @@ -0,0 +1,125 @@ + + +# Certificate editor — verification 2026-10-24 + +Implementation: `feat/dev-certificate-editor-usability-20261024`, base dev `3ca4c5ec`. +Paired API: [#821](https://github.com/PROCOLLAB-github/api/pull/821), base dev `562d0727`. +Review scope: required name, ready-made background, optional layers, usable canvas, +save-before-confirmation and explicit launch restrictions. Existing eligibility, +permissions, delivery journal and immutable issued documents remain authoritative. + +## Requirement audit + +| AC | Evidence | Verdict | +| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | +| AC1: background + personal name only | Real Angular/Django fixture: background uploaded, only name saved and reloaded, preview downloaded. Two native PDFs from this saved template contain only different Cyrillic names; pypdf confirms identical image bytes, one landscape A4 page each. Poppler PNG inspected for clipping. | PASS: integration/PDF | +| AC2: remove/restore eight optional elements | Browser added and removed all eight individually, saved only name and reloaded. Signature restored with its uploaded file and saved placement; unit/API assertions preserve the full layout. | PASS: browser/tests | +| AC3: name required | UI has no remove action for name; keyboard handler protects it. Direct invalid hidden-elements PUT returns 400 and leaves revision unchanged. | PASS: source/tests | +| AC4: existing draft/batch and old client | API compatibility tests cover omitted field, legacy show_text and preserved geometry; native legacy renderer runs. Angular normalization does not mark unchanged settings dirty. | PASS: tests | +| AC5: drag, resize, center, history, keyboard | Browser dragged name, centered both axes, resized signature, used undo/redo, ArrowRight/ArrowDown and Delete. Geometry tests cover bounds, center snap, vertical resize, aspect ratio and extreme image ratios. Uploaded image initially fits inside its existing area; file and geometry undo together. | PASS: browser/tests | +| AC6: long name and responsive canvas | Browser DOM rectangles at 320/390/1030/1440: text remains inside its box, document width does not exceed viewport. Expanded editor and zoom scroll within canvas viewport. Long name also checked in a native two-line PDF. | PASS: browser/PDF | +| AC7: dirty settings → save → confirmation | Real audience change produced enabled save-and-continue action, successful PUT and separate confirmation with audience/count/mail/timing. No issuance confirmation or participant mail sent. Unit test asserts activate occurs only after separate confirmation with saved revision. | PASS: browser/tests | +| AC8: save error/retry | Parameterized 400/409/500/network tests retain draft, release busy state and suppress confirmation/activate/preview/test-mail. Activation failure remains visible inside its confirmation and retry succeeds. | PASS: component tests | +| AC9: active/zero/date | Real active batch shows Open deliveries, which opens journal; local queue paused for further QA. Missing/before-completion dates show reason; zero recipients covered by component test. Date input exposes associated error and aria-invalid. | PASS: browser/tests | +| AC10: permissions | Existing manager/foreign program/foreign asset/private download rejection tests included in native backend run. No permission code was relaxed. | PASS: API tests/source | + +## Checks + +- Tests: `npm run test:ci -- projects/social_platform/src/app/utils/certificate-layout.spec.ts projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.spec.ts` — 26 passed. +- Build: `npm run build:pr` — Angular development build; existing Sass deprecation warnings. +- Checks: ESLint for changed TS, Prettier for changed TS/HTML/Markdown, Stylelint for the two changed SCSS files, `git diff --check`. +- API: `manage.py test partner_programs.tests.test_certificates` with isolated SQLite, + native WeasyPrint and Mont — 26 tests, 24 passed, 2 PostgreSQL locking tests skipped locally. + Black/flake8 pass for changed Python files. PostgreSQL is covered by the API PR workflow. +- Native PDF fitting uses actual Mont metrics and available width/height. A discovered + WeasyPrint flex alignment clipping defect was fixed and the downloaded endpoint PDF re-rendered. +- Mobile fieldset intrinsic width overflow was reproduced, fixed with min-width: 0, + and rechecked at all four widths. + +## Design and accessibility review + +The editor uses existing Mont, UI foundation Button/Field/Choice and management +compact controls. No global UI Kit changes. Layers/background are one settings +column; the canvas and selected-element tools are the main workspace. Optional +settings appear for the selected layer. Narrow layout puts the canvas first in DOM +and visual order, tools wrap, and save controls stay visible. Selection is available +from the layer list; keyboard arrows/Shift, resize handle, Delete and undo/redo +offer alternatives to precise dragging. Required name, icon labels and visible focus +are explicit. No unexplained status badge or duplicate primary launch action. + +Bounded manual continuity/quality review completed. No unresolved blocker/major finding. +The full-bleed certificate intentionally has no inner document padding; the background +must fill A4. This is an exception to generic cramped-padding diagnostics. + +## Evidence and limits + +### Zoom control adjustment + +The scale control is one labeled UI foundation select: По размеру окна, 125%, +150%, 200%. Separate fit/minus/plus buttons are removed. Browser selection at all +four values scales the paper correctly and restores its original width; ArrowDown +and Home also change and reset the value. The select remains inside the page at +320/390. Its final visual size is 164 × 32px with 12px type, matching the canvas +tools; the label row is 44px tall on narrow screens. Physical touch and Safari +were not exercised. No API or certificate content change is involved. + +### UI consistency revision — 2026-10-25 + +This is a bounded correction of the approved flow, following feedback on the +whole section's inconsistent proportions. The visual references are the existing +management Experts and Criteria implementations: Mont, 12px controls, compact +32px actions, light containers and the existing UI foundation. The Experts screen +was also opened alongside certificate QA. Form fields keep their normal foundation +geometry; secondary canvas fields receive the existing compact management density. +No Figma screen, global token, shared primitive or API was changed in this pass. + +| Priority finding | Correction | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Canvas mixes 11/12/16px labels and 32/40/44px controls; scale dominates | Canvas type is 12px, visible tool controls are 32px. Scale is 164px wide. Scoped selectors prevent the foundation's mobile 16px rule from overriding these utilities. | +| Header actions have an inherited top margin and wrap unpredictably | Remove the extra action margin, keep alignment actions together, use two equal action columns on narrow editor headers and short visible labels with explicit accessible names. | +| Mobile save actions occupy multiple rows; modal content shows under the footer | Compact labels/padding keep all three actions together. The editor uses its natural content height and the sticky footer covers the dialog's bottom inset. | +| Journal filters shrink to 10px while mobile search grows to 16px | Search and filters use 12px. Filters wrap at narrow widths instead of shrinking their type. | +| Email textarea uses a pill-like radius and audience labels are too heavy | Use the neighboring management textarea's modest radius and a 600 weight for audience labels. | + +The PDF, test-mail and launch actions have stable compact labels. Adjacent help +explicitly explains save-before-action when the template is dirty. The expanded +fit mode reserves room for tools, the PDF action and the save footer, including +1280 × 720. The required-name lock has accessible text without squeezing its label. +Required-name protection, optional +layers, zoom values, draft/history and separate issuance confirmation are unchanged. + +Verification: targeted Stylelint/Prettier and diff checks, the 26 relevant component +and layout tests, Angular build and actual Angular/Django browser checks. The full +template, issuance settings and delivery journal are reviewed together, including +320/390 narrow layouts, 1030 intermediate and 1440 wide layouts. Updated full-view +evidence: [editor](certificates-editor-20261024/desktop.jpg), +[mobile editor](certificates-editor-20261024/mobile.jpg), +[template in page context](certificates-editor-20261024/template-page.jpg), +[issuance settings](certificates-editor-20261024/automation.jpg), +[mobile issuance settings](certificates-editor-20261024/mobile-automation.jpg), +[delivery journal](certificates-editor-20261024/deliveries.jpg). + +### Outline button focus adjustment + +Certificate secondary buttons keep the focus outline on their existing contour +instead of adding a separate outer ring. Keyboard focus has a 2px inset outline, +the existing light accent fill and dark text. The ordinary state keeps its 1px +border. Other button variants and the global Button foundation are unchanged. +The preview button was checked before and after Tab: its dimensions remain the +same, focus stays visible, and there is no second contour. The focused preview +state is included in the separate keyboard evidence. This CSS-only adjustment was +verified with the Angular build, targeted Stylelint, diff check and browser states; +the behavioral test suite was not rerun for this styling change. +Desktop keyboard state: [focused preview button](certificates-editor-20261024/button-focus-desktop.jpg). + +Screenshots: [desktop](certificates-editor-20261024/desktop.jpg), +[mobile](certificates-editor-20261024/mobile.jpg), +[launch confirmation](certificates-editor-20261024/confirmation.jpg). +They use disposable localhost program 1 and synthetic fixture data. The text in the +test background is raster artwork, not an extra element added by the renderer. + +Actual real dev program 6 was not modified for this verification. Production, +participant email delivery and the updated editor on dev await merge/deploy acceptance. +Native touch gestures and other browser engines were not exercised; pointer/keyboard +behavior was tested in the in-app Chromium browser. PostgreSQL concurrency is not +claimed from SQLite. No migration or regeneration of existing PDFs is required. diff --git a/docs/qa/certificates-editor-20261024/automation.jpg b/docs/qa/certificates-editor-20261024/automation.jpg new file mode 100644 index 000000000..e94ef1a3f Binary files /dev/null and b/docs/qa/certificates-editor-20261024/automation.jpg differ diff --git a/docs/qa/certificates-editor-20261024/button-focus-desktop.jpg b/docs/qa/certificates-editor-20261024/button-focus-desktop.jpg new file mode 100644 index 000000000..5c3285dfc Binary files /dev/null and b/docs/qa/certificates-editor-20261024/button-focus-desktop.jpg differ diff --git a/docs/qa/certificates-editor-20261024/confirmation.jpg b/docs/qa/certificates-editor-20261024/confirmation.jpg new file mode 100644 index 000000000..99e0b6458 Binary files /dev/null and b/docs/qa/certificates-editor-20261024/confirmation.jpg differ diff --git a/docs/qa/certificates-editor-20261024/deliveries.jpg b/docs/qa/certificates-editor-20261024/deliveries.jpg new file mode 100644 index 000000000..60e2cb0f3 Binary files /dev/null and b/docs/qa/certificates-editor-20261024/deliveries.jpg differ diff --git a/docs/qa/certificates-editor-20261024/desktop.jpg b/docs/qa/certificates-editor-20261024/desktop.jpg new file mode 100644 index 000000000..0bebf665a Binary files /dev/null and b/docs/qa/certificates-editor-20261024/desktop.jpg differ diff --git a/docs/qa/certificates-editor-20261024/mobile-automation.jpg b/docs/qa/certificates-editor-20261024/mobile-automation.jpg new file mode 100644 index 000000000..1b606e97d Binary files /dev/null and b/docs/qa/certificates-editor-20261024/mobile-automation.jpg differ diff --git a/docs/qa/certificates-editor-20261024/mobile.jpg b/docs/qa/certificates-editor-20261024/mobile.jpg new file mode 100644 index 000000000..18c4ea3cc Binary files /dev/null and b/docs/qa/certificates-editor-20261024/mobile.jpg differ diff --git a/docs/qa/certificates-editor-20261024/template-page.jpg b/docs/qa/certificates-editor-20261024/template-page.jpg new file mode 100644 index 000000000..5916e1370 Binary files /dev/null and b/docs/qa/certificates-editor-20261024/template-page.jpg differ diff --git a/projects/social_platform/src/app/domain/program/program-certificates.model.ts b/projects/social_platform/src/app/domain/program/program-certificates.model.ts index 9e12cc23a..86274252b 100644 --- a/projects/social_platform/src/app/domain/program/program-certificates.model.ts +++ b/projects/social_platform/src/app/domain/program/program-certificates.model.ts @@ -9,6 +9,28 @@ export type CertificateElement = | "director" | "position" | "signature"; +export const CERTIFICATE_ELEMENTS: CertificateElement[] = [ + "name", + "title", + "subtitle", + "program", + "date", + "number", + "director", + "position", + "signature", +]; +export const CERTIFICATE_LABELS: Record = { + name: "Имя участника", + title: "Заголовок", + subtitle: "Подзаголовок", + program: "Название программы", + date: "Дата выдачи", + number: "Номер сертификата", + director: "Имя директора", + position: "Должность", + signature: "Подпись", +}; export interface CertificateBox { x: number; y: number; @@ -28,6 +50,7 @@ export interface CertificateDraft { director: string; position: string; showText: boolean; + hiddenElements?: Exclude[]; audience: "all" | "submitted"; timing: "manual" | "scheduled"; scheduledAt: string | null; diff --git a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.html b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.html index 68f01d151..0bfa7267e 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.html +++ b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.html @@ -1,60 +1,132 @@ -
- @if (background()) { - Фон сертификата +
+ +
+ + +
+ @if (selected() !== "name") { + } - - @for (key of keys; track key) { - @if (visible(key)) { -
- @if (key === "signature") { - Подпись - } @else { - {{ text(key) }} - } - @if (selected() === key) { - {{ labels[key] }} - - } -
+
+
+
+ @if (background()) { + Фон сертификата } - } + + + @for (key of keys; track key) { + @if (visible(key)) { +
+ @if (key === "signature") { + @if (signature()) { + Подпись + } @else if (!preview()) { + Загрузите подпись + } + } @else { + {{ fits()[key].text }} + } + @if (selected() === key && !preview()) { + {{ labels[key] }} + + } +
+ } + } +
-
- Выбрано: {{ labels[selected()] }} - - - - Перетащите элемент · потяните за угол для размера +
+ +
+

+ Перетащите элемент · тяните за угол для размера · стрелки для точного перемещения +

diff --git a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.scss b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.scss index 3d7357953..22f1d1d6b 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.scss @@ -1,20 +1,54 @@ /** @format */ +@use "styles/ui-foundation"; +@use "styles/program-management-controls"; + :host { display: block; min-width: 0; + font-size: 12px; + line-height: 1.5; +} + +.ui-button.ui-button { + @include ui-foundation.button; + @include program-management-controls.compact-action; +} + +.ui-field { + @include ui-foundation.control; + + min-height: 32px; + padding: 5px 8px; + font-size: 12px; + line-height: 20px; + border-radius: 8px; +} + +.ui-choice { + @include ui-foundation.choice; } .canvas { position: relative; width: 100%; aspect-ratio: 297 / 210; - overflow: hidden; + margin-inline: auto; background: white; - box-shadow: 0 2px 16px #0000000d; + box-shadow: var(--ui-panel-shadow); container-type: inline-size; + &__viewport { + max-width: 100%; + padding: 24px; + overflow: auto; + overscroll-behavior: contain; + background: var(--white); + border-radius: 12px; + } + &__background { position: absolute; + inset: 0; width: 100%; height: 100%; object-fit: cover; @@ -23,6 +57,11 @@ &__element { position: absolute; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + padding: 0.0891cqw; line-height: 1.3; color: var(--black); text-align: center; @@ -31,20 +70,6 @@ user-select: none; outline: 1px solid transparent; - span:not(.canvas__label) { - display: block; - height: 100%; - overflow: hidden; - overflow-wrap: anywhere; - } - - img { - width: 100%; - height: 100%; - object-fit: contain; - pointer-events: none; - } - &:active { cursor: grabbing; } @@ -55,80 +80,200 @@ outline-color: var(--accent); } + &:focus-visible { + outline-width: 2px; + } + &--bold { font-weight: 600; } + + &--selected { + z-index: 2; + } + + img { + width: 100%; + height: 100%; + object-fit: contain; + pointer-events: none; + } } - &__element--selected { - z-index: 2; + &__text { + max-width: 100%; + white-space: pre; + pointer-events: none; + } + + &__placeholder { + font-size: 1.5cqw; + color: var(--grey-for-text); } &__label { position: absolute; - top: -20px; - left: 0; - padding: 2px 5px; + top: -22px; + left: -1px; + padding: 2px 6px; font-size: 10px; - line-height: 16px; + font-weight: 400; + line-height: 18px; color: white; white-space: nowrap; pointer-events: none; background: var(--accent); - border-radius: 4px; + border-radius: 4px 4px 0 0; } &__resize { position: absolute; right: -7px; bottom: -7px; - width: 16px; - height: 16px; + width: 14px; + height: 14px; padding: 0; touch-action: none; cursor: nwse-resize; background: white; - border: 2px solid var(--accent); + border: 1px solid var(--accent); + + &:focus-visible { + outline: 2px solid var(--accent-dark); + outline-offset: 2px; + } } &__guide { position: absolute; - left: 50%; - height: 100%; + z-index: 1; pointer-events: none; - border-left: 1px dashed var(--accent-light); opacity: 0; + + &--x { + top: 0; + bottom: 0; + left: 50%; + border-left: 1px dashed var(--accent); + } + + &--y { + top: 50%; + right: 0; + left: 0; + border-top: 1px dashed var(--accent); + } + + &--visible { + opacity: 1; + } } - &:focus-within &__guide { - opacity: 0.6; + &--preview &__element { + cursor: default; + outline-color: transparent; } - &__tools { + &__selection-tools { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: center; + min-height: 32px; + margin-bottom: 12px; + + .ui-button { + padding-inline: 8px; + } + } + + &__font { + display: flex; + gap: 6px; + align-items: center; + min-height: 32px; + + input { + width: 56px; + font-size: 12px; + } + + span { + color: var(--grey-for-text); + } + } + + &__alignment { + display: flex; + gap: 4px; + align-items: center; + } + + &__bottom { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; - margin-top: 14px; - font-size: 10px; + justify-content: space-between; + margin-top: 12px; + } + + &__check, + &__zoom { + display: flex; + gap: 8px; + align-items: center; + min-height: 32px; + } + + &__zoom { + max-width: 100%; color: var(--grey-for-text); - button { - padding: 8px; - font: inherit; - color: var(--accent); - cursor: pointer; - background: none; - border: 0; + select { + width: 164px; + padding-right: 28px; + font-size: 12px; + background-position: right 8px center; + background-size: 12px; } } + + &__instruction { + margin: 8px 0 0; + font-size: 11px; + color: var(--grey-for-text); + } } -@media (width <= 600px) { +@media (width <= 749px) { + .canvas__viewport { + padding: 16px 12px; + } + + .canvas__selection-tools { + gap: 0 8px; + } + + .canvas__font { + flex-basis: 100%; + } + + .canvas__font, + .canvas__check, + .canvas__zoom { + min-height: 44px; + } + + .canvas__bottom { + gap: 0 16px; + margin-top: 8px; + } + .canvas__resize { right: -10px; bottom: -10px; - width: 22px; - height: 22px; + width: 20px; + height: 20px; } } diff --git a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.ts b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.ts index d43560762..287ce45f5 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.ts @@ -1,14 +1,35 @@ /** @format */ -import { ChangeDetectionStrategy, Component, input, output, signal } from "@angular/core"; import { + ChangeDetectionStrategy, + Component, + DestroyRef, + ElementRef, + afterNextRender, + computed, + inject, + input, + output, + signal, + viewChild, +} from "@angular/core"; +import { ButtonDirective, ChoiceDirective, FieldDirective } from "@uilib"; +import { + CERTIFICATE_ELEMENTS, + CERTIFICATE_LABELS, CertificateBox, CertificateDraft, CertificateElement, } from "@domain/program/program-certificates.model"; -import { moveCertificateBox, resizeCertificateBox } from "@utils/certificate-layout"; +import { + fitCertificateText, + hiddenCertificateElements, + moveCertificateBox, + resizeCertificateBox, +} from "@utils/certificate-layout"; @Component({ selector: "app-certificate-canvas", + imports: [ButtonDirective, ChoiceDirective, FieldDirective], templateUrl: "./certificate-canvas.component.html", styleUrl: "./certificate-canvas.component.scss", changeDetection: ChangeDetectionStrategy.OnPush, @@ -19,31 +40,59 @@ export class CertificateCanvasComponent { readonly background = input(null); readonly signature = input(null); readonly signatureRatio = input(2); + readonly selected = input("name"); + readonly selectedChange = output(); readonly longName = input(false); + readonly longNameChange = output(); + readonly preview = input(false); + readonly disabled = input(false); + readonly expanded = input(false); readonly layoutChange = output(); - readonly selected = signal("name"); - readonly keys: CertificateElement[] = [ - "title", - "subtitle", - "name", - "program", - "date", - "number", - "director", - "position", - "signature", - ]; - readonly labels: Record = { - title: "Заголовок", - subtitle: "Подзаголовок", - name: "Имя участника", - program: "Название программы", - date: "Дата выдачи", - number: "Номер", - director: "Имя директора", - position: "Должность", - signature: "Подпись", - }; + readonly interactionStart = output(); + readonly removeElement = output(); + readonly keys = CERTIFICATE_ELEMENTS; + readonly labels = CERTIFICATE_LABELS; + readonly zoom = signal(1); + readonly dragging = signal(false); + readonly viewport = viewChild>("viewport"); + private readonly availableWidth = signal(0); + private readonly viewportHeight = signal(900); + private readonly fontsReady = signal(false); + private context: CanvasRenderingContext2D | null = null; + private readonly destroy = inject(DestroyRef); + readonly paperWidth = computed(() => { + const width = this.availableWidth(); + // Leave room for tools, the PDF action and the sticky save footer in the dialog. + const fit = this.expanded() + ? Math.min(width, (Math.max(200, this.viewportHeight() - 480) * 297) / 210) + : width; + return fit * this.zoom(); + }); + readonly fits = computed(() => { + this.fontsReady(); + const result = {} as Record; + const cache = new Map(); + for (const key of this.keys) { + const weight = key === "name" || key === "title" ? 600 : 400; + const measure = (value: string) => { + const id = `${weight}:${value}`; + if (!cache.has(id)) { + if (this.context) this.context.font = `${weight} 1000px Mont`; + cache.set(id, this.context?.measureText(value).width ?? Array.from(value).length * 550); + } + return cache.get(id)!; + }; + const fit = fitCertificateText(this.text(key), this.draft().layout[key], measure); + result[key] = { fontSize: fit.fontSize, text: fit.lines.join("\n") }; + } + return result; + }); + readonly guideX = computed( + () => this.dragging() && Math.abs(this.box().x + this.box().width / 2 - 50) < 0.05, + ); + readonly guideY = computed( + () => this.dragging() && Math.abs(this.box().y + this.box().height / 2 - 50) < 0.05, + ); private drag: { key: CertificateElement; box: CertificateBox; @@ -53,14 +102,42 @@ export class CertificateCanvasComponent { resize: boolean; pointer: number; } | null = null; + + constructor() { + afterNextRender(() => { + if (typeof CanvasRenderingContext2D !== "undefined") + this.context = document.createElement("canvas").getContext("2d"); + const measure = () => { + const element = this.viewport()?.nativeElement; + if (!element) return; + const style = getComputedStyle(element); + this.availableWidth.set( + element.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight), + ); + this.viewportHeight.set(window.innerHeight); + }; + const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(measure) : null; + if (this.viewport()) observer?.observe(this.viewport()!.nativeElement); + window.addEventListener("resize", measure); + measure(); + void document.fonts?.ready.then(() => { + if (!this.destroy.destroyed) this.fontsReady.set(true); + }); + this.destroy.onDestroy(() => { + observer?.disconnect(); + window.removeEventListener("resize", measure); + }); + }); + } + box(): CertificateBox { + return this.draft().layout[this.selected()]; + } visible(key: CertificateElement): boolean { - return ( - !((key === "title" || key === "subtitle") && !this.draft().showText) && - (key !== "signature" || !!this.signature()) + return !hiddenCertificateElements(this.draft()).includes( + key as Exclude, ); } text(key: CertificateElement): string { - const draft = this.draft(); switch (key) { case "title": return "СЕРТИФИКАТ"; @@ -75,32 +152,24 @@ export class CertificateCanvasComponent { case "number": return "№ ПРОБНЫЙ"; case "director": - return draft.director || "Имя директора"; + return this.draft().director; case "position": - return draft.position || "Должность"; + return this.draft().position; default: return ""; } } - font(key: CertificateElement): string { - const box = this.draft().layout[key]; - const size = Math.max( - 8, - Math.min( - box.fontSize, - (box.height * 7.937) / 1.35, - (box.width * 11.22 * 2.5) / Math.max(this.text(key).length, 1), - ), - ); - return `${size / 11.22}cqw`; + select(key: CertificateElement): void { + if (this.selected() !== key) this.selectedChange.emit(key); } start(event: PointerEvent, key: CertificateElement, resize = false): void { - if (event.button !== 0) return; + if (this.preview() || this.disabled() || event.button !== 0) return; event.preventDefault(); event.stopPropagation(); - this.selected.set(key); - const target = event.currentTarget as HTMLElement; - const canvas = target.closest(".canvas"); + this.select(key); + this.interactionStart.emit(); + const target = event.currentTarget as HTMLElement, + canvas = target.closest(".canvas"); if (!canvas) return; target.setPointerCapture(event.pointerId); this.drag = { @@ -112,12 +181,13 @@ export class CertificateCanvasComponent { resize, pointer: event.pointerId, }; + this.dragging.set(true); } move(event: PointerEvent): void { const drag = this.drag; if (!drag || drag.pointer !== event.pointerId) return; - const dx = ((event.clientX - drag.x) / drag.rect.width) * 100; - const dy = ((event.clientY - drag.y) / drag.rect.height) * 100; + const dx = ((event.clientX - drag.x) / drag.rect.width) * 100, + dy = ((event.clientY - drag.y) / drag.rect.height) * 100; this.update( drag.key, drag.resize @@ -132,42 +202,64 @@ export class CertificateCanvasComponent { } end(): void { this.drag = null; + this.dragging.set(false); } - keydown(event: KeyboardEvent, key: CertificateElement): void { - const directions: Record = { - ArrowLeft: [-1, 0], - ArrowRight: [1, 0], - ArrowUp: [0, -1], - ArrowDown: [0, 1], - }; - const vector = directions[event.key]; + keydown(event: KeyboardEvent, key: CertificateElement, resize = false): void { + if (this.preview() || this.disabled()) return; + if ((event.key === "Delete" || event.key === "Backspace") && !resize) { + event.preventDefault(); + event.stopPropagation(); + if (key !== "name") this.removeElement.emit(key); + return; + } + const vector = ( + { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] } as Record< + string, + number[] + > + )[event.key]; if (!vector) return; event.preventDefault(); - this.selected.set(key); - const step = event.shiftKey ? 2 : 0.25; + event.stopPropagation(); + this.select(key); + this.interactionStart.emit(); + const amount = event.shiftKey ? 2 : 0.25, + box = this.draft().layout[key]; this.update( key, - moveCertificateBox(this.draft().layout[key], vector[0] * step, vector[1] * step), + resize + ? resizeCertificateBox( + box, + vector[0] * amount, + vector[1] * amount, + key === "signature" ? this.signatureRatio() : undefined, + ) + : moveCertificateBox(box, vector[0] * amount, vector[1] * amount, false), ); } - center(): void { - const key = this.selected(), - box = this.draft().layout[key]; - this.update(key, { ...box, x: (100 - box.width) / 2 }); - } - resizeSelected(scale: number): void { - const key = this.selected(), - box = this.draft().layout[key]; + center(vertical = false): void { + if (this.disabled()) return; + this.interactionStart.emit(); + const box = this.box(); this.update( - key, - resizeCertificateBox( - box, - box.width * scale, - box.height * scale, - key === "signature" ? this.signatureRatio() : undefined, - ), + this.selected(), + vertical ? { ...box, y: (100 - box.height) / 2 } : { ...box, x: (100 - box.width) / 2 }, ); } + fontChanged(event: Event): void { + if (this.disabled()) return; + const target = event.target as HTMLInputElement, + value = Number(target.value); + if (!Number.isFinite(value) || value < 8 || value > 72) { + target.value = String(this.box().fontSize); + return; + } + this.interactionStart.emit(); + this.update(this.selected(), { ...this.box(), fontSize: value }); + } + setZoom(value: number): void { + this.zoom.set(Math.max(1, Math.min(2, value))); + } private update(key: CertificateElement, box: CertificateBox): void { this.layoutChange.emit({ ...this.draft().layout, [key]: box }); } diff --git a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.html b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.html index b89c314c0..a5e615d85 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.html +++ b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.html @@ -31,18 +31,95 @@

Сертификаты

Загружаем настройки сертификатов…
} @else if (draft(); as d) { @if (step() === 1) { +
+
+
+

Шаблон сертификата

+

А4 · альбомный · 297 × 210 мм

+
+
+ + + + +
+
+ @if (!editorOpen()) { + + } +
+ } +
-
-

Шаблон сертификата

-

А4 · альбомный · 297 × 210 мм

-
+
+ +
+ +

+ {{ + dirty() + ? "Перед скачиванием сохраним изменения шаблона." + : "Проверьте расположение элементов в готовом PDF." + }} +

+
+
+
- -

Выключите, если текст уже есть на фоне.

- - -
-

Подпись

-

{{ assets().signature?.name || "PNG с прозрачным фоном до 10 МБ" }}

- - - @if (assets().signature) { - +

+ Перетащите подпись и потяните за угол. Пропорции сохраняются. +

} -
-
-
-
-

Предпросмотр

- -
-
- -
- -

- Имя, программа, дата и номер подставляются автоматически. Пропорции подписи сохраняются - при изменении размера. -

-
- -
- @if (dirty()) { -

Сохраните изменения, чтобы проверить их в PDF.

- } -
+ +
- } +
@if (step() === 2) {

Кому выдавать сертификаты

@@ -222,7 +298,7 @@

Когда выдать

value="manual" [ngModel]="d.timing" (ngModelChange)="patch({ timing: $event })" - />Вручную — после нажатия «Запустить выдачу»Вручную — после подтверждения запуска + />Не ранее даты окончания программы. + @if (scheduleProblem()) { + {{ + scheduleProblem() + }} + } + }

Подстановки: {{ placeholders }}

- +

+ {{ + dirty() + ? "Сохраним изменения и отправим письмо только вам." + : "Письмо будет отправлено только вам." + }} +

}
@@ -288,12 +388,25 @@

Когда выдать

+ @if (!snapshot()?.batch?.active) { + @if (launchProblem()) { +

{{ launchProblem() }}

+ } @else { +

+ {{ + dirty() + ? "Есть изменения. Сначала сохраним их, затем вы проверите параметры запуска." + : "После нажатия вы проверите параметры запуска." + }} +

+ } + } @if (snapshot()?.batch?.active) {

Выдача уже работает. Управление очередью — на шаге «Отправки». @@ -471,10 +584,16 @@

Когда выдать

} - @if (step() !== 3) { +
- {{ dirty() ? "Есть несохраненные изменения" : "Все изменения сохранены" }} + {{ + busy() + ? "Сохраняем…" + : dirty() + ? "Есть несохраненные изменения" + : "Все изменения сохранены" + }} @if (snapshot()?.batch) { Изменения применятся к следующей выдаче. } @@ -486,15 +605,32 @@

Когда выдать

[disabled]="busy() || !dirty()" (click)="discard()" > - Отменить + @if (step() === 1) { - + }
+ + @if (step() !== 3 && !editorOpen()) { + } Когда выдать size="large" labelledBy="certificate-editor-heading" > -
-
-
-

Размещение элементов

-

Перетащите элемент. Потяните за угол, чтобы изменить размер.

-
- + @if (editorOpen()) { +
+
+
+

Шаблон сертификата

+

А4 · альбомный

+
+
+ + + + +
+
+ @if (error()) { +

{{ error() }}

+ } + +
- -
+ } Размещение элементов >

Запустить выдачу сертификатов?

+ @if (error()) { + + }

{{ d.audience === "all" @@ -545,11 +709,12 @@

Запустить выдачу серти }}

- {{ - d.timing === "manual" - ? "Запуск сразу после подтверждения." - : "Запуск по указанной дате после окончания программы." - }} + @if (d.timing === "manual") { + Запуск сразу после подтверждения. + } @else { + Запуск {{ d.scheduledAt | date: "dd.MM.yyyy, HH:mm" : "+0300" }} (МСК), после окончания + программы. + }