diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts
index e6fcaf8f7..d4f0c4039 100644
--- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts
+++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts
@@ -166,6 +166,8 @@ export class ContractDocumentViewModelBuilder {
year: 'numeric',
}),
contractYear: new Date().getFullYear(),
+ contractStartDate: this.formatDate(contract.contractValidFrom),
+ contractEndDate: this.formatDate(contract.contractValidUntil),
client: {
companyName: contract.company?.name ?? 'Client',
companyAddress: this.valueOrDash(contract.company?.address),
@@ -281,7 +283,8 @@ export class ContractDocumentViewModelBuilder {
private buildSchedule(contract: Contract): ContractViewModel['schedule'] {
const firstRoute = this.firstRoute(contract);
- const cargoScope = (contract.cargoScope ?? [])[0];
+ const scope = contract.cargoScope ?? [];
+ const cargoScope = scope[0];
const cargoName =
cargoScope?.cargoType?.cargoTypeName ||
cargoScope?.cargoFreeText ||
@@ -289,6 +292,28 @@ export class ContractDocumentViewModelBuilder {
? `${cargoScope.containerSize} container`
: 'Container cargo');
+ // A contract's scope can list several cargo lines (e.g. coffee in 20ft and
+ // 40ft); name each distinctly rather than collapsing to the first.
+ const containerType = [
+ ...new Set(scope.map((s) => s.containerSize ?? '').filter(Boolean)),
+ ].join(', ');
+ const cargoTypeName = [
+ ...new Set(
+ scope
+ .map((s) => s.cargoType?.cargoTypeName ?? s.cargoFreeText ?? '')
+ .filter(Boolean),
+ ),
+ ].join(', ');
+ const cargoSummary = scope
+ .map((s) => {
+ const name = s.cargoType?.cargoTypeName ?? s.cargoFreeText ?? null;
+ const size = s.containerSize ? `(${s.containerSize})` : null;
+ const cap = s.quantityCap ? `× ${Number(s.quantityCap)}` : null;
+ return [name, size, cap].filter(Boolean).join(' ');
+ })
+ .filter(Boolean)
+ .join('; ');
+
return {
originLabel: this.yardLabel(firstRoute?.originYard),
destinationLabel: this.yardLabel(firstRoute?.destinationYard),
@@ -302,6 +327,9 @@ export class ContractDocumentViewModelBuilder {
scheduledDate: this.formatDate(null),
contractType: this.valueOrDash(contract.contractType),
cargoDescription: this.valueOrDash(cargoName),
+ cargoTypeName: this.valueOrDash(cargoTypeName),
+ containerType: this.valueOrDash(containerType),
+ cargoSummary: this.valueOrDash(cargoSummary),
totalWeightVgm: '—',
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
// A hazardous contract names the declared class + UN number on the
diff --git a/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts b/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts
index 765c41142..b0e0e4d3d 100644
--- a/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts
+++ b/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts
@@ -117,6 +117,9 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
scheduledDate: '—',
contractType: 'GENERAL',
cargoDescription: 'Steel billets',
+ cargoTypeName: 'Steel billets',
+ containerType: '—',
+ cargoSummary: 'Steel billets × 2,800',
totalWeightVgm: '—',
equipmentReturn: '—',
hazardousLabel: 'No',
@@ -192,6 +195,24 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
expect(html).toContain('#1b9e7a');
});
+ it('shows cargo type and container type in the commercial schedule annex', () => {
+ const html = renderer.render(dynamicView());
+ expect(html).toContain('Cargo type');
+ expect(html).toContain('Container type');
+ expect(html).toContain('Cargo scope');
+ expect(html).toContain('Steel billets × 2,800');
+ });
+
+ it('interpolates the cargo/container placeholders inside article text', () => {
+ const view = dynamicView();
+ const body =
+ 'Cargo: {{schedule.cargoTypeName}} in {{schedule.containerType}} ' +
+ '({{schedule.freightType}}). Scope: {{schedule.cargoSummary}}.';
+ expect(interpolateTemplateText(body, view)).toBe(
+ 'Cargo: Steel billets in — (BULK). Scope: Steel billets × 2,800.',
+ );
+ });
+
it('renders the live rate schedule lane under the pricing article', () => {
const html = renderer.render(dynamicView());
expect(html).toContain('Rate Schedule');
diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts
index b0fc4c8ef..79f5e2f73 100644
--- a/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts
+++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts
@@ -43,6 +43,9 @@ describe('ContractRendererService', () => {
scheduledDate: '1 January 2026',
contractType: 'NEW',
cargoDescription: 'Container cargo',
+ cargoTypeName: 'Coffee',
+ containerType: '40ft',
+ cargoSummary: 'Coffee (40ft) × 12',
totalWeightVgm: '24 tons',
equipmentReturn: 'RETURN',
hazardousLabel: 'No',
diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts
index 07f4d25d3..d874fb792 100644
--- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts
+++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts
@@ -41,6 +41,13 @@ export interface ContractViewModel {
template: ContractTemplateMeta;
contractDate: string;
contractYear: number;
+ /**
+ * The contract's validity window (`contract_valid_from` / `_until`). Distinct
+ * from `contractDate`, which is the day the document is generated — these are
+ * the dates the contract is actually in force between. "—" when unset.
+ */
+ contractStartDate: string;
+ contractEndDate: string;
client: {
companyName: string;
companyAddress: string;
@@ -68,6 +75,16 @@ export interface ContractViewModel {
scheduledDate: string;
contractType: string;
cargoDescription: string;
+ /**
+ * The named cargo type on its own (e.g. "Coffee"), separate from
+ * `cargoDescription` which folds in free text and a container fallback.
+ * Lets a clause name the commodity without the surrounding prose.
+ */
+ cargoTypeName: string;
+ /** Container size alone, e.g. "20ft" / "40ft"; "—" for bulk. */
+ containerType: string;
+ /** Every cargo line on the contract, e.g. "Coffee (40ft) × 12". */
+ cargoSummary: string;
totalWeightVgm: string;
equipmentReturn: string;
hazardousLabel: string;
@@ -195,6 +212,21 @@ export class ContractViewModelBuilder {
'Bulk commodity'
: booking.cargoType?.cargoTypeName || 'Container cargo';
const totalWeight = Number(booking.cargoTotalWeightVgm || 0);
+ // A booking may carry both sizes; name each one once, in the order booked.
+ const containerType = [
+ ...new Set(
+ (booking.bookingContainers ?? [])
+ .map(
+ (line) =>
+ line.containerType?.label ??
+ (line.containerType?.sizeFt
+ ? `${line.containerType.sizeFt}ft`
+ : line.containerSize) ??
+ '',
+ )
+ .filter(Boolean),
+ ),
+ ].join(', ');
return {
originLabel: this.yardLabel(booking.originYard),
@@ -207,6 +239,13 @@ export class ContractViewModelBuilder {
scheduledDate: this.formatDate(booking.scheduledDate),
contractType: this.valueOrDash(booking.contractType),
cargoDescription: this.valueOrDash(cargoName),
+ cargoTypeName: this.valueOrDash(booking.cargoType?.cargoTypeName),
+ containerType: this.valueOrDash(containerType),
+ cargoSummary: this.valueOrDash(
+ [cargoName, containerType ? `(${containerType})` : null]
+ .filter(Boolean)
+ .join(' '),
+ ),
totalWeightVgm:
totalWeight > 0 ? `${totalWeight.toLocaleString()} tons` : '—',
equipmentReturn: this.valueOrDash(booking.equipmentReturn),
diff --git a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs
index 9c4957b2b..5c4c4052b 100644
--- a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs
+++ b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs
@@ -125,6 +125,18 @@
Hazardous cargo |
{{schedule.hazardousLabel}} |
+
+ | Cargo type |
+ {{schedule.cargoTypeName}} |
+ Container type |
+ {{schedule.containerType}} |
+
+
+ | Cargo scope |
+ {{schedule.cargoSummary}} |
+ Freight type |
+ {{schedule.freightType}} |
+
| Equipment return |
{{schedule.equipmentReturn}} |
diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts
index cb956878d..e94f9470f 100644
--- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts
+++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts
@@ -239,6 +239,11 @@ export class ContractTemplatesService {
scheduledDate: "—",
contractType: "GENERAL",
cargoDescription: isBulk ? "Steel billets — 2,800 MT" : "40ft containers — FMCG cargo",
+ cargoTypeName: isBulk ? "Steel billets" : "Coffee",
+ containerType: isBulk ? "—" : "40ft",
+ cargoSummary: isBulk
+ ? "Steel billets × 2,800"
+ : "Coffee (40ft) × 12; Sesame (20ft) × 6",
totalWeightVgm: "—",
equipmentReturn: isBulk ? "—" : "With empty return",
hazardousLabel: "No",
diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts
index 782a5b6a0..a6c08a317 100644
--- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts
+++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Patch } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
-import { CurrentUser, ExchangeService } from "@edr/api-common";
+import { CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { FreightAdmin } from "../../common/booking-guards";
@@ -11,10 +11,7 @@ import { ExchangeSettingsService } from "./exchange-settings.service";
@ApiBearerAuth()
@Controller("exchange-settings")
export class ExchangeSettingsController {
- constructor(
- private readonly service: ExchangeSettingsService,
- private readonly exchangeService: ExchangeService,
- ) {}
+ constructor(private readonly service: ExchangeSettingsService) {}
@Get()
@FreightAdmin()
@@ -22,24 +19,15 @@ export class ExchangeSettingsController {
summary: "Current USD→ETB fallback rate and CBE feed health",
})
async get() {
- const [setting, status] = [
- await this.service.get(),
- this.exchangeService.getProviderStatus(),
- ];
+ const setting = await this.service.get();
+ const status = this.service.getFeedStatus();
return {
fallbackRate: setting.fallbackRate,
fallbackSource: setting.fallbackSource,
lastSyncedAt: setting.lastSyncedAt,
updatedById: setting.updatedById,
- feed: {
- rate: status.rate,
- source: status.source,
- lastSuccessAt: status.lastSuccessAt
- ? new Date(status.lastSuccessAt).toISOString()
- : null,
- lastError: status.lastError,
- },
+ feed: status,
};
}
diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts
index 4cccdf8ea..e0b670292 100644
--- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts
+++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts
@@ -10,6 +10,18 @@ import { ExchangeSetting } from "./entities/exchange-setting.entity";
*/
const SEED_FALLBACK_RATE = 162.4165;
+/** Health of the CBE feed, as surfaced to the backoffice. */
+export interface ExchangeFeedStatus {
+ /** Rate most recently observed, whatever its source. */
+ rate: number | null;
+ /** `live` means CBE answered; `stored`/`default` mean it is failing. */
+ source: "live" | "stored" | null;
+ /** ISO timestamp of the last successful fetch. */
+ lastSuccessAt: string | null;
+ /** Message from the most recent failure, cleared on success. */
+ lastError: string | null;
+}
+
/**
* Owns the single `exchange_settings` row: the USD→ETB fallback used when the
* CBE endpoint is unreachable.
@@ -22,11 +34,30 @@ const SEED_FALLBACK_RATE = 162.4165;
export class ExchangeSettingsService {
private readonly logger = new Logger(ExchangeSettingsService.name);
+ /**
+ * Feed health, recorded from the exchange provider's callbacks rather than
+ * read off an injected `ExchangeService`. The provider is registered several
+ * times (bookings, contracts, warehouses), so no single instance sees every
+ * fetch — and injecting one here would be circular, since those
+ * registrations inject *this* service.
+ */
+ private feed: ExchangeFeedStatus = {
+ rate: null,
+ source: null,
+ lastSuccessAt: null,
+ lastError: null,
+ };
+
constructor(
@InjectRepository(ExchangeSetting)
private readonly repository: Repository,
) {}
+ /** Health of the CBE feed as last observed by any provider instance. */
+ getFeedStatus(): ExchangeFeedStatus {
+ return { ...this.feed };
+ }
+
/** The settings row, created at the seed rate on first access. */
async get(): Promise {
const existing = await this.repository.findOne({ where: {} });
@@ -47,15 +78,22 @@ export class ExchangeSettingsService {
* than propagating a database error into a pricing call.
*/
async loadFallbackRate(): Promise {
+ // Only reached when the live fetch failed, so this call is itself the
+ // signal that the feed is down.
try {
const { fallbackRate } = await this.get();
- return Number.isFinite(fallbackRate) && fallbackRate > 0
- ? fallbackRate
- : null;
+ const usable = Number.isFinite(fallbackRate) && fallbackRate > 0;
+ this.feed = {
+ ...this.feed,
+ rate: usable ? fallbackRate : this.feed.rate,
+ source: "stored",
+ lastError: this.feed.lastError ?? "CBE endpoint unreachable",
+ };
+ return usable ? fallbackRate : null;
} catch (err) {
- this.logger.warn(
- `Could not read stored exchange fallback: ${(err as Error).message}`,
- );
+ const message = (err as Error).message;
+ this.feed = { ...this.feed, source: "stored", lastError: message };
+ this.logger.warn(`Could not read stored exchange fallback: ${message}`);
return null;
}
}
@@ -66,6 +104,14 @@ export class ExchangeSettingsService {
* down, so a working CBE feed takes precedence again.
*/
async saveFallbackRate(rate: number): Promise {
+ // Only called after a successful fetch, so the feed is confirmed healthy.
+ this.feed = {
+ rate,
+ source: "live",
+ lastSuccessAt: new Date().toISOString(),
+ lastError: null,
+ };
+
const current = await this.get();
await this.repository.update(current.id, {
fallbackRate: rate,
diff --git a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx
index 17977b743..240a92a13 100644
--- a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx
@@ -21,20 +21,23 @@ import {
Title,
Tooltip,
} from "@mantine/core";
+import ReactQuill from "react-quill-new";
+import "react-quill-new/dist/quill.snow.css";
+
import {
AlertTriangle,
ArrowDown,
+ ArrowLeftRight,
ArrowUp,
+ Boxes,
Building2,
+ Container,
CalendarClock,
CalendarDays,
CalendarRange,
ChevronDown,
Coins,
Hash,
- ListOrdered,
- ListPlus,
- ListTree,
Mail,
MapPin,
Package,
@@ -58,9 +61,10 @@ import {
useUpdateContractTemplate,
} from "@/hooks/contract-templates/useContractTemplates";
import type { ContractTemplateArticle } from "@/services/contract-templates.service";
+import { bodyToHtml, htmlToBody } from "./article-html";
const BODY_HINT =
- 'One clause per line. Use "New clause" for the next number (1., 2., …), "Sub-clause" for a nested number (1.1, then 1.1.1), and "Bullet" for a • point — the number or bullet is typed for you, just add the text. Placeholders are filled from the contract when the document is generated.';
+ "Each paragraph becomes a numbered clause (1., 2., …) — use Indent to nest it as a sub-clause (1.1, 1.1.1). The bullet list makes • points under the clause above. Numbering is assigned when the document is generated, so it always comes out sequential. Placeholders are filled from the contract.";
interface ArticleDraft {
id?: string;
@@ -170,6 +174,42 @@ const MORE_PLACEHOLDER_GROUPS: { label: string; items: PlaceholderDef[] }[] = [
icon: Package,
hint: "Description of the cargo",
},
+ {
+ token: "{{schedule.cargoTypeName}}",
+ label: "Cargo type",
+ icon: Package,
+ hint: "Named commodity on its own, e.g. Coffee",
+ },
+ {
+ token: "{{schedule.containerType}}",
+ label: "Container type",
+ icon: Container,
+ hint: "Container size, e.g. 20ft / 40ft — dash for bulk",
+ },
+ {
+ token: "{{schedule.cargoSummary}}",
+ label: "Cargo summary",
+ icon: Boxes,
+ hint: "Every cargo line, e.g. Coffee (40ft) × 12",
+ },
+ {
+ token: "{{schedule.tradeDirection}}",
+ label: "Trade direction",
+ icon: ArrowLeftRight,
+ hint: "IMPORT / EXPORT / DOMESTIC",
+ },
+ {
+ token: "{{schedule.freightType}}",
+ label: "Freight type",
+ icon: Boxes,
+ hint: "CONTAINER or BULK",
+ },
+ {
+ token: "{{schedule.hazardousLabel}}",
+ label: "Hazardous",
+ icon: AlertTriangle,
+ hint: "Declared hazard class + UN number, or No",
+ },
{
token: "{{schedule.totalWeightVgm}}",
label: "Total weight",
@@ -237,6 +277,22 @@ const ALL_PLACEHOLDERS: PlaceholderDef[] = [
...MORE_PLACEHOLDER_GROUPS.flatMap((g) => g.items),
];
+/**
+ * Deliberately narrow toolbar: the stored body carries STRUCTURE only (clause
+ * depth + bullets), which is what the contract renderer numbers and lays out.
+ * Bold/colour/font would be dropped on save, so they are not offered —
+ * an author never loses formatting they were allowed to apply.
+ */
+const QUILL_MODULES = {
+ toolbar: [
+ [{ list: "ordered" }, { list: "bullet" }],
+ [{ indent: "-1" }, { indent: "+1" }],
+ ["clean"],
+ ],
+};
+
+const QUILL_FORMATS = ["list", "indent"];
+
const KNOWN_TOKENS = new Set([
...ALL_PLACEHOLDERS.map((p) => p.token),
// Still filled by the renderer, just no longer offered as an insert button.
@@ -326,31 +382,6 @@ function parseArticleBody(body: string): ParsedBody {
return { clauses };
}
-/**
- * Rewrite the leading outline tokens in a body so every numbered clause line
- * carries its computed sequential number (stale numbers self-heal). Lines
- * without a number token and bullet lines pass through untouched.
- */
-function renumberBody(body: string): string {
- const counters: number[] = [];
- return body
- .split("\n")
- .map((raw) => {
- const line = raw.trim();
- if (!line || line.startsWith("- ")) return raw;
- const match = CLAUSE_NUMBER_RE.exec(line);
- let depth = matchDepth(match) ?? 1;
- depth = Math.min(depth, counters.length + 1);
- counters.splice(depth);
- while (counters.length < depth) counters.push(0);
- counters[depth - 1] += 1;
- if (!match) return raw;
- const number = counters.slice(0, depth).join(".");
- return `${number}. ${line.slice(match[0].length).trim()}`;
- })
- .join("\n");
-}
-
/** Render clause text with {{placeholders}} highlighted as green chips. */
function HighlightedText({ text }: { text: string }) {
const parts = text.split(/(\{\{[^{}]+\}\})/g);
@@ -674,88 +705,46 @@ function ArticleEditorModal({
}: ArticleEditorModalProps) {
const [title, setTitle] = useState(initial.title);
const [body, setBody] = useState(initial.body);
+ // Quill is uncontrolled-ish: it owns its own DOM, so seed it once from the
+ // stored body and let onChange convert edits back rather than re-deriving
+ // HTML from `body` on every keystroke (which would fight the caret).
+ const [html, setHtml] = useState(() => bodyToHtml(initial.body));
const titleRef = useRef(null);
- const bodyRef = useRef(null);
+ const quillRef = useRef(null);
// Placeholders drop into whichever field held the cursor last (body default).
const lastFocused = useRef<"title" | "body">("body");
- const insertAtCursor = (snippet: string) => {
- const isTitle = lastFocused.current === "title";
- const el = isTitle ? titleRef.current : bodyRef.current;
- const value = isTitle ? title : body;
- const start = el?.selectionStart ?? value.length;
- const end = el?.selectionEnd ?? start;
- const next = value.slice(0, start) + snippet + value.slice(end);
- if (isTitle) setTitle(next);
- else setBody(next);
- // Refocus and place the caret right after the inserted snippet once the
- // controlled re-render has flushed.
- requestAnimationFrame(() => {
- if (!el) return;
- el.focus();
- const caret = start + snippet.length;
- el.setSelectionRange(caret, caret);
- });
+ /** Body is the source of truth for saving/preview; HTML is the editor view. */
+ const applyHtml = (nextHtml: string) => {
+ setHtml(nextHtml);
+ setBody(htmlToBody(nextHtml));
};
- /**
- * Insert a structured line (clause / sub-clause / bullet) on a fresh line
- * below the one the caret is on. Clause lines get their outline number typed
- * in automatically ("3. ", "3.1. ", …) and every numbered line in the body is
- * renumbered so the text always matches the preview.
- */
- const insertStructuredLine = (kind: "clause" | "sub" | "bullet") => {
- const el = bodyRef.current;
- lastFocused.current = "body";
- const caret = el?.selectionStart ?? body.length;
- // Structured lines never split a sentence — insert after the caret's line.
- const lineEnd = body.indexOf("\n", caret);
- const insertAt = lineEnd === -1 ? body.length : lineEnd;
- const before = body.slice(0, insertAt);
- const after = body.slice(insertAt); // "" or starts with "\n"
-
- let prefix: string;
- if (kind === "bullet") {
- prefix = "- ";
- } else {
- // New clause always starts a fresh top-level number. Sub-clause nests
- // one level under a clause (1 → 1.1) but adds a SIBLING when the caret
- // is already on a sub-clause (1.1 → 1.2 → 1.3, not ever-deeper) — a
- // third level is reached by typing its number (e.g. "1.1.1 ") directly.
- const above = parseArticleBody(before);
- const lastDepth = above.paragraph
- ? 1
- : (above.clauses[above.clauses.length - 1]?.depth ?? 0);
- const depth =
- kind === "sub"
- ? lastDepth <= 1
- ? Math.min(lastDepth + 1, MAX_CLAUSE_DEPTH)
- : lastDepth
- : 1;
- // Digits are placeholders — renumberBody assigns the real value.
- prefix = `${Array.from({ length: depth }, () => "1").join(".")}. `;
+ const insertAtCursor = (snippet: string) => {
+ if (lastFocused.current === "title") {
+ const el = titleRef.current;
+ const start = el?.selectionStart ?? title.length;
+ const end = el?.selectionEnd ?? start;
+ setTitle(title.slice(0, start) + snippet + title.slice(end));
+ requestAnimationFrame(() => {
+ if (!el) return;
+ el.focus();
+ const caret = start + snippet.length;
+ el.setSelectionRange(caret, caret);
+ });
+ return;
}
-
- const beforeLines = before.length > 0 ? before.split("\n") : [];
- const afterLines =
- after.length > 0 ? after.slice(1).split("\n") : [];
- const insertedIdx = beforeLines.length;
- const joined = [...beforeLines, prefix, ...afterLines].join("\n");
- const next = kind === "bullet" ? joined : renumberBody(joined);
- setBody(next);
-
- // Caret lands at the end of the inserted line, ready for typing.
- const caretTarget = next
- .split("\n")
- .slice(0, insertedIdx + 1)
- .join("\n").length;
- requestAnimationFrame(() => {
- const field = bodyRef.current;
- if (!field) return;
- field.focus();
- field.setSelectionRange(caretTarget, caretTarget);
- });
+ // Quill tracks its own selection; insert there so the token lands where the
+ // author was typing instead of at the end of the document.
+ const editor = quillRef.current?.getEditor();
+ if (!editor) return;
+ const range = editor.getSelection(true);
+ const at = range?.index ?? editor.getLength();
+ editor.deleteText(at, range?.length ?? 0);
+ editor.insertText(at, snippet, "user");
+ editor.setSelection(at + snippet.length, 0);
+ applyHtml(editor.root.innerHTML);
};
const parsed = useMemo(() => parseArticleBody(body), [body]);
@@ -846,78 +835,24 @@ function ArticleEditorModal({
- Add structure
+ Article body
-
-
- }
- onMouseDown={(e) => e.preventDefault()}
- onClick={() => insertStructuredLine("clause")}
- >
- New clause
-
-
-
- }
- disabled={body.trim().length === 0}
- onMouseDown={(e) => e.preventDefault()}
- onClick={() => insertStructuredLine("sub")}
- >
- Sub-clause
-
-
-
- }
- disabled={body.trim().length === 0}
- onMouseDown={(e) => e.preventDefault()}
- onClick={() => insertStructuredLine("bullet")}
- >
- Bullet
-
-
-
+
+ {BODY_HINT}
+
+ (lastFocused.current = "body")}>
+
+
-