@anthusai/papyrus 1.3.0 → 1.4.0-next.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/app/newsroom/[section]/page.tsx +8 -0
- package/app/newsroom/articles/page.tsx +10 -0
- package/components/news-desk-client-provider.tsx +17 -2
- package/components/news-desk-taxonomy-client.ts +86 -3
- package/components/newspaper.tsx +4 -4
- package/components/newsroom-board-view.tsx +70 -29
- package/components/newsroom-client-shell.tsx +8 -1
- package/components/newsroom-organizer-shell.tsx +13 -8
- package/components/newsroom-organizer.css +5 -5
- package/components/newsroom-references-view.tsx +18 -0
- package/components/reference-cyclotron-priming.tsx +85 -0
- package/components/topic-steering-workspace.tsx +35 -1
- package/lib/article-flow.ts +95 -0
- package/lib/article-frame-solver.ts +519 -0
- package/lib/chrome-text.ts +504 -0
- package/lib/content-item-lifecycle.ts +32 -5
- package/lib/front-mosaic.ts +1132 -0
- package/lib/furniture.ts +385 -0
- package/lib/labels.ts +37 -0
- package/lib/layout-config.ts +237 -0
- package/lib/layout-plan.ts +37 -9
- package/lib/layout-scenarios.ts +21 -11
- package/lib/layout-types.ts +457 -0
- package/lib/local-preview-seed.ts +95 -0
- package/lib/local-preview.ts +3 -0
- package/lib/newspaper-layout.ts +17 -3410
- package/lib/newsroom-board.ts +70 -47
- package/lib/newsroom-organizer-nav.ts +5 -5
- package/lib/pretext-layout.ts +22 -0
- package/lib/relevance-status.ts +73 -0
- package/lib/site-brand.ts +8 -1
- package/lib/text-columns.ts +251 -0
- package/package.json +13 -2
- package/renderers/pretext/presentation-shell.tsx +1 -1
- package/routes.manifest.json +1 -9
- package/app/newsroom/work/page.tsx +0 -10
package/lib/newsroom-board.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import type { KanbanConfig, KanbanIssue } from "@kanbus/ui";
|
|
2
2
|
import type { AssignmentRecord } from "./category-repository";
|
|
3
3
|
import {
|
|
4
|
+
ASSIGNMENT_PHASE_STAGE_KEYS,
|
|
5
|
+
PUBLISHED_STAGE_KEY,
|
|
6
|
+
canonicalContentItemStage,
|
|
4
7
|
contentItemStageKeys,
|
|
5
8
|
contentItemStageLabel,
|
|
6
9
|
latestVersionPerContentItem,
|
|
7
10
|
type ContentItemVersionRecord,
|
|
8
11
|
} from "./content-item-lifecycle";
|
|
12
|
+
import { assignmentStatusLabel } from "./newsroom-assignments";
|
|
9
13
|
import { getContentItemName, getContentItemStageDefinitions } from "./site-brand";
|
|
10
14
|
|
|
11
15
|
export type NativeBoardKind = "contentItem" | "assignment";
|
|
@@ -16,21 +20,22 @@ export interface NativeBoardIssue extends KanbanIssue {
|
|
|
16
20
|
href: string;
|
|
17
21
|
}
|
|
18
22
|
|
|
23
|
+
export function contentItemBoardPathSegment(): string {
|
|
24
|
+
return getContentItemName().plural.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "articles";
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
export function contentItemBoardType(): string {
|
|
20
28
|
return getContentItemName().singular.toLowerCase();
|
|
21
29
|
}
|
|
22
30
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
export const ASSIGNMENT_BOARD_TYPE = "assignment";
|
|
32
|
+
|
|
33
|
+
const ASSIGNMENT_STATUSES_BEFORE_OTHERS = ["open", "claimed"];
|
|
34
|
+
const ASSIGNMENT_STATUSES_AFTER_OTHERS = ["completed", "canceled"];
|
|
26
35
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
* native status. References are not shown on the board.
|
|
31
|
-
*/
|
|
32
|
-
export function projectNewsroomBoard(records: {
|
|
33
|
-
contentItemVersions?: readonly ContentItemVersionRecord[];
|
|
36
|
+
/** One card per content item, in the column for its lifecycle stage; its assignments are counted on the card. */
|
|
37
|
+
export function projectContentItemBoard(records: {
|
|
38
|
+
contentItemVersions: readonly ContentItemVersionRecord[];
|
|
34
39
|
assignments?: readonly AssignmentRecord[];
|
|
35
40
|
}): NativeBoardIssue[] {
|
|
36
41
|
const assignmentCountByContentItem = new Map<string, number>();
|
|
@@ -40,57 +45,75 @@ export function projectNewsroomBoard(records: {
|
|
|
40
45
|
}
|
|
41
46
|
}
|
|
42
47
|
const contentItemType = contentItemBoardType();
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
48
|
+
const stageDefinitions = getContentItemStageDefinitions();
|
|
49
|
+
const stageKeys = contentItemStageKeys(stageDefinitions);
|
|
50
|
+
const contentItemVersionsOnBoard = latestVersionPerContentItem(records.contentItemVersions)
|
|
51
|
+
.map(version => ({ version, stage: canonicalContentItemStage(version.stage ?? "idea", stageDefinitions) }))
|
|
52
|
+
.filter(({ stage }) => stageKeys.includes(stage) || !ASSIGNMENT_PHASE_STAGE_KEYS.includes(stage));
|
|
53
|
+
return contentItemVersionsOnBoard.map(({ version, stage }) => {
|
|
54
|
+
const workCount = assignmentCountByContentItem.get(version.lineageId) ?? 0;
|
|
55
|
+
return {
|
|
56
|
+
id: `contentItem:${version.lineageId}`, nativeId: version.lineageId, kind: "contentItem" as const, type: contentItemType,
|
|
57
|
+
title: version.headline || version.title || version.slug, nativeStatus: stage, status: `${contentItemType}/${stage}`,
|
|
58
|
+
priority: 2, description: workCount ? `${workCount} assignment${workCount === 1 ? "" : "s"} on this ${contentItemType}.` : undefined,
|
|
59
|
+
updated_at: version.updatedAt ?? undefined, href: `/newsroom/articles/${encodeURIComponent(version.id)}`,
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One card per assignment, in the column for its status. */
|
|
65
|
+
export function projectAssignmentBoard(assignments: readonly AssignmentRecord[]): NativeBoardIssue[] {
|
|
66
|
+
return assignments.map(row => ({
|
|
67
|
+
id: `assignment:${row.id}`, nativeId: row.id, kind: "assignment" as const, type: ASSIGNMENT_BOARD_TYPE,
|
|
68
|
+
title: row.title, nativeStatus: row.status, status: `${ASSIGNMENT_BOARD_TYPE}/${row.status}`,
|
|
69
|
+
priority: row.priority ?? 2, assignee: row.assigneeId || undefined,
|
|
70
|
+
description: row.summary || row.brief || undefined,
|
|
71
|
+
created_at: row.createdAt, updated_at: row.updatedAt,
|
|
72
|
+
href: `/newsroom/assignments/${encodeURIComponent(row.id)}`,
|
|
73
|
+
}));
|
|
63
74
|
}
|
|
64
75
|
|
|
65
76
|
export function contentItemColumnOrder(
|
|
66
77
|
stagesPresentOnBoard: readonly string[],
|
|
67
78
|
stageKeys: readonly string[] = contentItemStageKeys(getContentItemStageDefinitions()),
|
|
68
79
|
): string[] {
|
|
69
|
-
const lastStage = stageKeys[stageKeys.length - 1];
|
|
70
80
|
const unrecognizedStages = [...new Set(stagesPresentOnBoard)].filter(stage => !stageKeys.includes(stage));
|
|
71
|
-
|
|
81
|
+
const stageBeforeWhichUnrecognizedStagesSit = stageKeys.includes(PUBLISHED_STAGE_KEY) ? PUBLISHED_STAGE_KEY : stageKeys[stageKeys.length - 1];
|
|
82
|
+
const insertionIndex = stageKeys.indexOf(stageBeforeWhichUnrecognizedStagesSit);
|
|
83
|
+
return [...stageKeys.slice(0, insertionIndex), ...unrecognizedStages, ...stageKeys.slice(insertionIndex)];
|
|
72
84
|
}
|
|
73
85
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const statuses = [...contentItemStatuses, ...otherStatuses];
|
|
86
|
+
export function assignmentColumnOrder(statusesPresentOnBoard: readonly string[]): string[] {
|
|
87
|
+
const knownStatuses = [...ASSIGNMENT_STATUSES_BEFORE_OTHERS, ...ASSIGNMENT_STATUSES_AFTER_OTHERS];
|
|
88
|
+
const otherStatuses = [...new Set(statusesPresentOnBoard)].filter(status => !knownStatuses.includes(status));
|
|
89
|
+
return [...ASSIGNMENT_STATUSES_BEFORE_OTHERS, ...otherStatuses, ...ASSIGNMENT_STATUSES_AFTER_OTHERS];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function boardConfigForType(type: string, columns: readonly { key: string; name: string; collapsed?: boolean }[]): KanbanConfig {
|
|
93
|
+
const statuses = columns.map(column => ({ ...column, category: "native" }));
|
|
83
94
|
return {
|
|
84
|
-
hierarchy: [], types:
|
|
85
|
-
statuses,
|
|
95
|
+
hierarchy: [], types: [type], statuses,
|
|
86
96
|
categories: [{ name: "native" }], priorities: {}, type_colors: {},
|
|
87
|
-
workflows: Object.fromEntries(
|
|
88
|
-
const type = boardTypeForKind(kind);
|
|
89
|
-
return [type, Object.fromEntries(statuses.filter(status => status.key.startsWith(`${type}/`)).map(status => [status.key, []]))];
|
|
90
|
-
})),
|
|
97
|
+
workflows: { [type]: Object.fromEntries(statuses.map(status => [status.key, []])) },
|
|
91
98
|
};
|
|
92
99
|
}
|
|
93
100
|
|
|
101
|
+
/** Content items read left to right through the lifecycle; stages marked collapsed-until-opened start collapsed. */
|
|
102
|
+
export function contentItemBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConfig {
|
|
103
|
+
const contentItemType = contentItemBoardType();
|
|
104
|
+
return boardConfigForType(contentItemType, contentItemColumnOrder(issues.map(issue => issue.nativeStatus))
|
|
105
|
+
.map(stage => ({
|
|
106
|
+
key: `${contentItemType}/${stage}`,
|
|
107
|
+
name: contentItemStageLabel(stage, getContentItemStageDefinitions()),
|
|
108
|
+
collapsed: getContentItemStageDefinitions().some(definition => definition.key === stage && definition.collapsedUntilOpened),
|
|
109
|
+
})));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function assignmentBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConfig {
|
|
113
|
+
return boardConfigForType(ASSIGNMENT_BOARD_TYPE, assignmentColumnOrder(issues.map(issue => issue.nativeStatus))
|
|
114
|
+
.map(status => ({ key: `${ASSIGNMENT_BOARD_TYPE}/${status}`, name: assignmentStatusLabel(status) })));
|
|
115
|
+
}
|
|
116
|
+
|
|
94
117
|
export function canReadNewsroomBoard(phase: string | undefined): boolean {
|
|
95
118
|
return phase === "ready";
|
|
96
119
|
}
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
+
import { contentItemBoardPathSegment } from "./newsroom-board";
|
|
1
2
|
import { getContentItemName } from "./site-brand";
|
|
2
3
|
|
|
3
4
|
const contentItemPluralLowercase = getContentItemName().plural.toLowerCase();
|
|
4
5
|
export type OrganizerDestination = {
|
|
5
6
|
key: string; label: string; description: string; path: string;
|
|
6
|
-
access?: "admin"; primary?: boolean; unavailable?: string;
|
|
7
|
+
access?: "admin"; primary?: boolean; unavailable?: string; alsoActiveOnPaths?: readonly string[];
|
|
7
8
|
};
|
|
8
9
|
export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly OrganizerDestination[] }[] = [
|
|
9
10
|
{ title: "Editorial work", items: [
|
|
10
11
|
{ key: "overview", label: "Overview", description: "See the desk and choose where to begin.", path: "/newsroom", primary: true },
|
|
11
|
-
{ key: "
|
|
12
|
-
{ key: "
|
|
13
|
-
{ key: "assignments", label: "Assignments", description: "Claim work and follow research and reporting progress.", path: "/newsroom/assignments" },
|
|
12
|
+
{ key: "articles", label: getContentItemName().plural, description: `Follow ${contentItemPluralLowercase} across their stages and open one to write or revise it.`, path: `/newsroom/${contentItemBoardPathSegment()}`, alsoActiveOnPaths: ["/newsroom/articles"], primary: true },
|
|
13
|
+
{ key: "assignments", label: "Assignments", description: "Follow research and reporting work across its statuses and claim it.", path: "/newsroom/assignments" },
|
|
14
14
|
{ key: "messages", label: "Messages", description: "Read and reply to newsroom conversations.", path: "/newsroom/messages", primary: true },
|
|
15
15
|
{ key: "insights", label: "Insights", description: "Discuss findings and research threads.", path: "/newsroom/insights" },
|
|
16
16
|
] },
|
|
@@ -30,7 +30,7 @@ export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly Org
|
|
|
30
30
|
] },
|
|
31
31
|
];
|
|
32
32
|
const all = ORGANIZER_NAV_GROUPS.flatMap(group => group.items);
|
|
33
|
-
export const ORGANIZER_PRIMARY_NAV = ["overview", "
|
|
33
|
+
export const ORGANIZER_PRIMARY_NAV = ["overview", "articles", "references", "messages"].map(key => all.find(item => item.key === key)!);
|
|
34
34
|
|
|
35
35
|
export const ORGANIZER_SETTINGS_DESTINATION = {
|
|
36
36
|
label: "Settings", path: "/newsroom/settings", description: "Theme, motion and interface preferences.",
|
package/lib/pretext-layout.ts
CHANGED
|
@@ -9,6 +9,27 @@ import {
|
|
|
9
9
|
|
|
10
10
|
export { layoutNextLine, prepareWithSegments, type LayoutCursor, type PreparedTextWithSegments };
|
|
11
11
|
|
|
12
|
+
const preparedTextCache = new Map<string, PreparedTextWithSegments>();
|
|
13
|
+
let preparedTextCacheMissCount = 0;
|
|
14
|
+
|
|
15
|
+
export function getPreparedTextCacheMissCount(): number {
|
|
16
|
+
return preparedTextCacheMissCount;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function prepareWithSegmentsCached(
|
|
20
|
+
text: string,
|
|
21
|
+
font: string,
|
|
22
|
+
options?: { whiteSpace?: "normal" | "pre-wrap" },
|
|
23
|
+
): PreparedTextWithSegments {
|
|
24
|
+
const key = `${options?.whiteSpace ?? "normal"}\u0000${font}\u0000${text}`;
|
|
25
|
+
const cached = preparedTextCache.get(key);
|
|
26
|
+
if (cached) return cached;
|
|
27
|
+
preparedTextCacheMissCount += 1;
|
|
28
|
+
const prepared = prepareWithSegments(text, font, options);
|
|
29
|
+
preparedTextCache.set(key, prepared);
|
|
30
|
+
return prepared;
|
|
31
|
+
}
|
|
32
|
+
|
|
12
33
|
export type TextLine = {
|
|
13
34
|
text: string;
|
|
14
35
|
width: number;
|
|
@@ -184,5 +205,6 @@ function clamp(value: number, min: number, max: number): number {
|
|
|
184
205
|
}
|
|
185
206
|
|
|
186
207
|
export function clearPretextMeasurementCache(): void {
|
|
208
|
+
preparedTextCache.clear();
|
|
187
209
|
clearCache();
|
|
188
210
|
}
|
package/lib/relevance-status.ts
CHANGED
|
@@ -61,3 +61,76 @@ export const DEMO_CYCLOTRON_STATUS: CyclotronStatus = {
|
|
|
61
61
|
lastChange: null,
|
|
62
62
|
pending: { decisionsAwaitingReview: 1, staleSince: null },
|
|
63
63
|
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* What the Newsroom assumes until a decision names the cyclotron: the defaults
|
|
67
|
+
* of the steering config's relevanceCyclotron block (src/papyrus_content/relevance_cyclotron.py).
|
|
68
|
+
*/
|
|
69
|
+
export const DEFAULT_RELEVANCE_CYCLOTRON = { id: "papyrus-relevance", labels: ["include", "exclude"] } as const;
|
|
70
|
+
|
|
71
|
+
/** The payload kind of a priming request; the sweep lists the cyclotron's payloads and filters on it. */
|
|
72
|
+
export const PRIMING_REQUEST_KIND = "cyclotron-priming-request";
|
|
73
|
+
|
|
74
|
+
/** The attachment role the sweep writes a request's outcome under. */
|
|
75
|
+
export const PRIMING_OUTCOME_ROLE = "priming_outcome";
|
|
76
|
+
|
|
77
|
+
/** The KnowledgeRawPayload an editor writes to ask the next sweep to prime the cyclotron with one URL. */
|
|
78
|
+
export function primingRequestPayloadId(cyclotronId: string, requestId: string): string {
|
|
79
|
+
return `knowledge-raw-payload-cyclotron-priming-request-${safeId(cyclotronId)}-${safeId(requestId)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type PrimingRequest = {
|
|
83
|
+
requestId: string;
|
|
84
|
+
url: string;
|
|
85
|
+
label: string;
|
|
86
|
+
explanation: string;
|
|
87
|
+
requestedBy: string;
|
|
88
|
+
requestedAt: string;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type PrimingOutcome =
|
|
92
|
+
| { state: "applied"; version: number; at: string; itemId?: string }
|
|
93
|
+
| { state: "failed"; message: string; at: string };
|
|
94
|
+
|
|
95
|
+
/** A saved request with the sweep's outcome, once there is one. */
|
|
96
|
+
export type PrimingRequestView = { request: PrimingRequest; outcome: PrimingOutcome | null };
|
|
97
|
+
|
|
98
|
+
export function buildPrimingRequest(
|
|
99
|
+
input: { url: string; label: string; explanation?: string },
|
|
100
|
+
requestedBy: string,
|
|
101
|
+
labels: readonly string[],
|
|
102
|
+
now: Date = new Date(),
|
|
103
|
+
requestId: string = `${now.getTime().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
104
|
+
): PrimingRequest {
|
|
105
|
+
let parsed: URL | null = null;
|
|
106
|
+
try {
|
|
107
|
+
parsed = new URL(input.url.trim());
|
|
108
|
+
} catch {
|
|
109
|
+
parsed = null;
|
|
110
|
+
}
|
|
111
|
+
if (!parsed || parsed.protocol !== "https:") throw new Error("Paste an https URL.");
|
|
112
|
+
if (!labels.includes(input.label)) throw new Error(`Choose a label: ${labels.join(" or ")}.`);
|
|
113
|
+
return {
|
|
114
|
+
requestId,
|
|
115
|
+
url: parsed.toString(),
|
|
116
|
+
label: input.label,
|
|
117
|
+
explanation: (input.explanation ?? "").trim(),
|
|
118
|
+
requestedBy,
|
|
119
|
+
requestedAt: now.toISOString(),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function isPrimingOutcome(value: unknown): value is PrimingOutcome {
|
|
124
|
+
if (!value || typeof value !== "object") return false;
|
|
125
|
+
const outcome = value as Record<string, unknown>;
|
|
126
|
+
return (outcome.state === "applied" && typeof outcome.version === "number")
|
|
127
|
+
|| (outcome.state === "failed" && typeof outcome.message === "string");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** In words: waiting for the next sweep, applied as version N, or failed with the reason. */
|
|
131
|
+
export function primingStateText(view: PrimingRequestView): string {
|
|
132
|
+
if (!view.outcome) return "Waiting for the next sweep";
|
|
133
|
+
return view.outcome.state === "applied"
|
|
134
|
+
? `Applied as version ${view.outcome.version}`
|
|
135
|
+
: `Failed: ${view.outcome.message}`;
|
|
136
|
+
}
|
package/lib/site-brand.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isLocalPreviewEnabled } from "./local-preview";
|
|
1
2
|
import type { ComponentType } from "react";
|
|
2
3
|
import type { EditionPresentationFormat } from "./content-types";
|
|
3
4
|
import type { Article, ArticleVideoAsset } from "./articles";
|
|
@@ -184,11 +185,17 @@ const REFERENCE_SITE_BRANDS: Record<SiteBrandId, SiteBrand> = {
|
|
|
184
185
|
* The Papyrus reference brand plus whatever the publication registered in its
|
|
185
186
|
* own `papyrus.config.ts` (resolved through the `papyrus-site` alias).
|
|
186
187
|
*/
|
|
187
|
-
const
|
|
188
|
+
const REGISTERED_SITE_BRANDS: Record<SiteBrandId, SiteBrand> = {
|
|
188
189
|
...REFERENCE_SITE_BRANDS,
|
|
189
190
|
...Object.fromEntries((papyrusSite.brands ?? []).map((brand) => [brand.id, brand])),
|
|
190
191
|
};
|
|
191
192
|
|
|
193
|
+
const SITE_BRANDS: Record<SiteBrandId, SiteBrand> = isLocalPreviewEnabled()
|
|
194
|
+
? Object.fromEntries(
|
|
195
|
+
Object.entries(REGISTERED_SITE_BRANDS).map(([id, brand]) => [id, { ...brand, newsroomLayout: "organizer" as const }]),
|
|
196
|
+
)
|
|
197
|
+
: REGISTERED_SITE_BRANDS;
|
|
198
|
+
|
|
192
199
|
export function normalizeSiteBrandId(value: string | undefined | null): SiteBrandId | null {
|
|
193
200
|
if (!value) return null;
|
|
194
201
|
const trimmed = value.trim();
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { type ArticlePublicationItem, getPublicationItemText } from "./publication-items";
|
|
2
|
+
import {
|
|
3
|
+
type LayoutCursor,
|
|
4
|
+
layoutTextLines,
|
|
5
|
+
type PreparedTextWithSegments,
|
|
6
|
+
prepareWithSegmentsCached,
|
|
7
|
+
type TextLine,
|
|
8
|
+
type TextObstacle,
|
|
9
|
+
} from "./pretext-layout";
|
|
10
|
+
import { reserveRhythmRows, type VerticalRhythm } from "./blog-rhythm";
|
|
11
|
+
import {
|
|
12
|
+
type FurnitureSufficiencyReport,
|
|
13
|
+
type LayoutConfig,
|
|
14
|
+
type SolvedChromeBox,
|
|
15
|
+
type SolvedFurniture,
|
|
16
|
+
} from "./layout-types";
|
|
17
|
+
import { SERIF_TEXT_FONT, clamp, getSpanWidth } from "./layout-config";
|
|
18
|
+
import { rectsOverlap } from "./furniture";
|
|
19
|
+
|
|
20
|
+
export function layoutTextColumns({
|
|
21
|
+
item,
|
|
22
|
+
cursor,
|
|
23
|
+
columnCount,
|
|
24
|
+
textHeight,
|
|
25
|
+
maximumTextHeightByColumn = [],
|
|
26
|
+
localConfig,
|
|
27
|
+
furniture,
|
|
28
|
+
chromeBoxes = [],
|
|
29
|
+
furnitureObstacleOffsetY = 0,
|
|
30
|
+
furnitureObstacleMarginBottom = 0,
|
|
31
|
+
minimumLineStartYByColumn = [],
|
|
32
|
+
}: {
|
|
33
|
+
item: ArticlePublicationItem;
|
|
34
|
+
cursor: LayoutCursor;
|
|
35
|
+
columnCount: number;
|
|
36
|
+
textHeight: number;
|
|
37
|
+
maximumTextHeightByColumn?: number[];
|
|
38
|
+
localConfig: LayoutConfig;
|
|
39
|
+
furniture: SolvedFurniture[];
|
|
40
|
+
chromeBoxes?: SolvedChromeBox[];
|
|
41
|
+
furnitureObstacleOffsetY?: number;
|
|
42
|
+
furnitureObstacleMarginBottom?: number;
|
|
43
|
+
minimumLineStartYByColumn?: number[];
|
|
44
|
+
}): { columns: TextLine[][]; cursor: LayoutCursor; hasMore: boolean } {
|
|
45
|
+
const columns: TextLine[][] = [];
|
|
46
|
+
const preparedText = getPrepared(item, localConfig.bodyFont);
|
|
47
|
+
const columnWidth = getSpanWidth(localConfig, 1);
|
|
48
|
+
let current = { ...cursor };
|
|
49
|
+
let hasMore = true;
|
|
50
|
+
|
|
51
|
+
for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) {
|
|
52
|
+
const result = layoutTextLines({
|
|
53
|
+
prepared: preparedText,
|
|
54
|
+
cursor: current,
|
|
55
|
+
maxHeight: maximumTextHeightByColumn[columnIndex] ?? textHeight,
|
|
56
|
+
maxWidth: columnWidth,
|
|
57
|
+
lineHeight: localConfig.lineHeight,
|
|
58
|
+
linePaintHeight: localConfig.linePaintHeight,
|
|
59
|
+
fontSize: localConfig.bodyFontSize,
|
|
60
|
+
fontFamily: SERIF_TEXT_FONT,
|
|
61
|
+
obstacles: getColumnTextObstacles(
|
|
62
|
+
furniture,
|
|
63
|
+
columnIndex,
|
|
64
|
+
chromeBoxes,
|
|
65
|
+
furnitureObstacleOffsetY,
|
|
66
|
+
furnitureObstacleMarginBottom,
|
|
67
|
+
minimumLineStartYByColumn[columnIndex] ?? 0,
|
|
68
|
+
),
|
|
69
|
+
});
|
|
70
|
+
columns.push(result.lines);
|
|
71
|
+
current = result.cursor;
|
|
72
|
+
hasMore = result.hasMore;
|
|
73
|
+
if (!hasMore) {
|
|
74
|
+
while (columns.length < columnCount) columns.push([]);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { columns, cursor: current, hasMore };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function getPrepared(article: ArticlePublicationItem, font: string): PreparedTextWithSegments {
|
|
83
|
+
return prepareWithSegmentsCached(getPublicationItemText(article), font, { whiteSpace: "pre-wrap" });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getLineLimitHeight(maxLines: number, lineHeight: number, linePaintHeight: number, rhythm: VerticalRhythm): number {
|
|
87
|
+
if (maxLines <= 0) return 0;
|
|
88
|
+
return reserveRhythmRows((maxLines - 1) * lineHeight + linePaintHeight, rhythm);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getColumnTextObstacles(
|
|
92
|
+
furniture: SolvedFurniture[],
|
|
93
|
+
columnIndex: number,
|
|
94
|
+
chromeBoxes: SolvedChromeBox[] = [],
|
|
95
|
+
furnitureOffsetY = 0,
|
|
96
|
+
furnitureMarginBottom = 0,
|
|
97
|
+
minimumLineStartY = 0,
|
|
98
|
+
): TextObstacle[] {
|
|
99
|
+
const topObstacle: TextObstacle[] = minimumLineStartY > 0
|
|
100
|
+
? [{ x: 0, y: 0, width: Number.POSITIVE_INFINITY, height: minimumLineStartY }]
|
|
101
|
+
: [];
|
|
102
|
+
const furnitureObstacles = furniture
|
|
103
|
+
.filter((item) => item.wrapsText && columnIndex >= item.columnStart && columnIndex < item.columnStart + item.columnSpan)
|
|
104
|
+
.map((item) => ({
|
|
105
|
+
x: 0,
|
|
106
|
+
y: item.y + furnitureOffsetY,
|
|
107
|
+
width: Number.POSITIVE_INFINITY,
|
|
108
|
+
height: item.height + getFurnitureObstacleMarginBottom(item, furnitureMarginBottom),
|
|
109
|
+
}));
|
|
110
|
+
const imageClearanceObstacles = furniture
|
|
111
|
+
.filter((item) => (
|
|
112
|
+
item.kind === "image" &&
|
|
113
|
+
item.wrapsText &&
|
|
114
|
+
furnitureMarginBottom > 0 &&
|
|
115
|
+
columnIndex >= item.columnStart &&
|
|
116
|
+
columnIndex < item.columnStart + item.columnSpan
|
|
117
|
+
))
|
|
118
|
+
.map((item) => ({
|
|
119
|
+
x: 0,
|
|
120
|
+
y: item.y + furnitureOffsetY + item.height,
|
|
121
|
+
width: Number.POSITIVE_INFINITY,
|
|
122
|
+
height: furnitureMarginBottom,
|
|
123
|
+
}));
|
|
124
|
+
const chromeObstacles = chromeBoxes
|
|
125
|
+
.filter((box) => columnIndex >= box.columnStart && columnIndex < box.columnStart + box.columnSpan)
|
|
126
|
+
.map((box) => ({
|
|
127
|
+
x: 0,
|
|
128
|
+
y: box.y,
|
|
129
|
+
width: Number.POSITIVE_INFINITY,
|
|
130
|
+
height: box.height,
|
|
131
|
+
}));
|
|
132
|
+
return [...topObstacle, ...furnitureObstacles, ...imageClearanceObstacles, ...chromeObstacles];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getFurnitureObstacleMarginBottom(item: SolvedFurniture, marginBottom: number): number {
|
|
136
|
+
return item.kind === "image" || item.kind === "pullQuote" ? marginBottom : 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function getColumnWhitespace(columns: TextLine[][], textHeight: number, furniture: SolvedFurniture[]): number {
|
|
140
|
+
return columns.reduce(
|
|
141
|
+
(total, column, columnIndex) =>
|
|
142
|
+
total + Math.max(0, textHeight - getLinesHeight(column) - getColumnObstacleHeight(furniture, columnIndex, textHeight)),
|
|
143
|
+
0,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function getFurnitureSufficiencyReport({
|
|
148
|
+
columns,
|
|
149
|
+
textHeight,
|
|
150
|
+
furniture,
|
|
151
|
+
hasMore,
|
|
152
|
+
config,
|
|
153
|
+
}: {
|
|
154
|
+
columns: TextLine[][];
|
|
155
|
+
textHeight: number;
|
|
156
|
+
furniture: SolvedFurniture[];
|
|
157
|
+
hasMore: boolean;
|
|
158
|
+
config: LayoutConfig;
|
|
159
|
+
}): FurnitureSufficiencyReport {
|
|
160
|
+
const visibleRows = columns.reduce((total, column) => total + column.length, 0);
|
|
161
|
+
const furnitureRows = furniture.reduce(
|
|
162
|
+
(total, item) => total + getFurnitureRowBurden(item, config.rhythm.rowHeight),
|
|
163
|
+
0,
|
|
164
|
+
);
|
|
165
|
+
const accepted = (reason?: string): FurnitureSufficiencyReport => ({
|
|
166
|
+
accepted: !reason,
|
|
167
|
+
reason,
|
|
168
|
+
visibleRows,
|
|
169
|
+
furnitureRows,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (furniture.length === 0 || furnitureRows === 0) return accepted();
|
|
173
|
+
const overlapReason = getFurnitureOverlapReason(furniture);
|
|
174
|
+
if (overlapReason) return accepted(overlapReason);
|
|
175
|
+
const minimumReadableRows = columns.length * (furniture.some((item) => item.kind === "pullQuote") ? 6 : 3);
|
|
176
|
+
if (visibleRows * 4 < furnitureRows) {
|
|
177
|
+
return accepted("visible-copy-far-below-furniture-burden");
|
|
178
|
+
}
|
|
179
|
+
if (visibleRows < furnitureRows && visibleRows < minimumReadableRows) {
|
|
180
|
+
return accepted("visible-copy-below-furniture-burden");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const finalTextColumnIndex = columns.reduce((lastIndex, column, columnIndex) => (
|
|
184
|
+
column.length > 0 ? columnIndex : lastIndex
|
|
185
|
+
), -1);
|
|
186
|
+
const exhausted = !hasMore;
|
|
187
|
+
for (let columnIndex = 0; columnIndex < columns.length; columnIndex += 1) {
|
|
188
|
+
const visibleColumnRows = columns[columnIndex].length;
|
|
189
|
+
const obstacleRows = Math.ceil(getColumnObstacleHeight(furniture, columnIndex, textHeight) / config.rhythm.rowHeight);
|
|
190
|
+
const availableRows = Math.floor(textHeight / config.rhythm.rowHeight) - obstacleRows;
|
|
191
|
+
const hasSubstantialFurniture = obstacleRows >= 6;
|
|
192
|
+
const isAfterExhaustedText = exhausted && columnIndex > finalTextColumnIndex;
|
|
193
|
+
if (!hasSubstantialFurniture && !isAfterExhaustedText && availableRows >= 3 && visibleColumnRows < 3) {
|
|
194
|
+
return accepted("open-column-below-minimum-copy");
|
|
195
|
+
}
|
|
196
|
+
if (!hasSubstantialFurniture && visibleColumnRows === 1 && columnIndex < finalTextColumnIndex) {
|
|
197
|
+
return accepted("non-final-column-single-copy-row");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return accepted();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function getFurnitureRowBurden(item: SolvedFurniture, rowHeight: number): number {
|
|
205
|
+
if (item.kind !== "image" && item.kind !== "pullQuote") return 0;
|
|
206
|
+
return Math.ceil(item.height / rowHeight) * item.columnSpan;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function getFurnitureOverlapReason(furniture: SolvedFurniture[]): string | undefined {
|
|
210
|
+
for (let firstIndex = 0; firstIndex < furniture.length; firstIndex += 1) {
|
|
211
|
+
for (let secondIndex = firstIndex + 1; secondIndex < furniture.length; secondIndex += 1) {
|
|
212
|
+
if (rectsOverlap(furniture[firstIndex], furniture[secondIndex])) return "furniture-overlap";
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function getColumnObstacleHeight(furniture: SolvedFurniture[], columnIndex: number, textHeight: number): number {
|
|
219
|
+
const intervals = furniture
|
|
220
|
+
.filter((item) => item.wrapsText && columnIndex >= item.columnStart && columnIndex < item.columnStart + item.columnSpan)
|
|
221
|
+
.map((item) => ({
|
|
222
|
+
start: clamp(item.y, 0, textHeight),
|
|
223
|
+
end: clamp(item.y + item.height, 0, textHeight),
|
|
224
|
+
}))
|
|
225
|
+
.filter((interval) => interval.end > interval.start)
|
|
226
|
+
.sort((a, b) => a.start - b.start);
|
|
227
|
+
|
|
228
|
+
const merged: Array<{ start: number; end: number }> = [];
|
|
229
|
+
for (const interval of intervals) {
|
|
230
|
+
const previous = merged[merged.length - 1];
|
|
231
|
+
if (previous && interval.start <= previous.end) previous.end = Math.max(previous.end, interval.end);
|
|
232
|
+
else merged.push({ ...interval });
|
|
233
|
+
}
|
|
234
|
+
return merged.reduce((total, interval) => total + interval.end - interval.start, 0);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function getDeadColumnCount(columns: TextLine[][], textHeight: number, furniture: SolvedFurniture[]): number {
|
|
238
|
+
return columns.reduce((count, column, columnIndex) => {
|
|
239
|
+
if (column.length > 0) return count;
|
|
240
|
+
const obstacleHeight = getColumnObstacleHeight(furniture, columnIndex, textHeight);
|
|
241
|
+
return obstacleHeight >= Math.min(96, textHeight * 0.18) ? count : count + 1;
|
|
242
|
+
}, 0);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function getLinesHeight(lines: TextLine[]): number {
|
|
246
|
+
return lines.length === 0 ? 0 : Math.max(...lines.map((line) => line.y + line.paintHeight));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function getFurnitureBottom(furniture: SolvedFurniture[]): number {
|
|
250
|
+
return furniture.reduce((bottom, item) => Math.max(bottom, item.y + item.height), 0);
|
|
251
|
+
}
|
package/package.json
CHANGED
|
@@ -105,6 +105,7 @@
|
|
|
105
105
|
"./components/reader-settings": "./components/reader-settings.ts",
|
|
106
106
|
"./components/reader-theme-control": "./components/reader-theme-control.tsx",
|
|
107
107
|
"./components/reference-curation-controls": "./components/reference-curation-controls.tsx",
|
|
108
|
+
"./components/reference-cyclotron-priming": "./components/reference-cyclotron-priming.tsx",
|
|
108
109
|
"./components/reference-cyclotron-status": "./components/reference-cyclotron-status.tsx",
|
|
109
110
|
"./components/reference-priming": "./components/reference-priming.tsx",
|
|
110
111
|
"./components/reference-relevance-review": "./components/reference-relevance-review.tsx",
|
|
@@ -142,6 +143,8 @@
|
|
|
142
143
|
"./lib/approved-priming-proof-reports": "./lib/approved-priming-proof-reports.ts",
|
|
143
144
|
"./lib/archive-data": "./lib/archive-data.ts",
|
|
144
145
|
"./lib/archive-types": "./lib/archive-types.ts",
|
|
146
|
+
"./lib/article-flow": "./lib/article-flow.ts",
|
|
147
|
+
"./lib/article-frame-solver": "./lib/article-frame-solver.ts",
|
|
145
148
|
"./lib/articles": "./lib/articles.ts",
|
|
146
149
|
"./lib/blog-feature-solver": "./lib/blog-feature-solver.ts",
|
|
147
150
|
"./lib/blog-rhythm": "./lib/blog-rhythm.ts",
|
|
@@ -150,6 +153,7 @@
|
|
|
150
153
|
"./lib/cached-content-repository": "./lib/cached-content-repository.ts",
|
|
151
154
|
"./lib/category-dashboard": "./lib/category-dashboard.ts",
|
|
152
155
|
"./lib/category-repository": "./lib/category-repository.ts",
|
|
156
|
+
"./lib/chrome-text": "./lib/chrome-text.ts",
|
|
153
157
|
"./lib/console-chat-client": "./lib/console-chat-client.ts",
|
|
154
158
|
"./lib/console-web-context": "./lib/console-web-context.ts",
|
|
155
159
|
"./lib/content-actions-client": "./lib/content-actions-client.ts",
|
|
@@ -168,10 +172,17 @@
|
|
|
168
172
|
"./lib/edition-sections": "./lib/edition-sections.ts",
|
|
169
173
|
"./lib/empty-edition-layout-plan": "./lib/empty-edition-layout-plan.ts",
|
|
170
174
|
"./lib/excerpts": "./lib/excerpts.ts",
|
|
175
|
+
"./lib/front-mosaic": "./lib/front-mosaic.ts",
|
|
176
|
+
"./lib/furniture": "./lib/furniture.ts",
|
|
171
177
|
"./lib/graphql-content-repository": "./lib/graphql-content-repository.ts",
|
|
172
178
|
"./lib/image-url": "./lib/image-url.ts",
|
|
179
|
+
"./lib/labels": "./lib/labels.ts",
|
|
180
|
+
"./lib/layout-config": "./lib/layout-config.ts",
|
|
173
181
|
"./lib/layout-plan": "./lib/layout-plan.ts",
|
|
174
182
|
"./lib/layout-scenarios": "./lib/layout-scenarios.ts",
|
|
183
|
+
"./lib/layout-types": "./lib/layout-types.ts",
|
|
184
|
+
"./lib/local-preview-seed": "./lib/local-preview-seed.ts",
|
|
185
|
+
"./lib/local-preview": "./lib/local-preview.ts",
|
|
175
186
|
"./lib/markus-body": "./lib/markus-body.ts",
|
|
176
187
|
"./lib/markus-ir": "./lib/markus-ir.ts",
|
|
177
188
|
"./lib/markus-projection": "./lib/markus-projection.ts",
|
|
@@ -245,6 +256,7 @@
|
|
|
245
256
|
"./lib/staging-gated-path": "./lib/staging-gated-path.ts",
|
|
246
257
|
"./lib/staging-preview-object": "./lib/staging-preview-object.ts",
|
|
247
258
|
"./lib/test-editor-override": "./lib/test-editor-override.ts",
|
|
259
|
+
"./lib/text-columns": "./lib/text-columns.ts",
|
|
248
260
|
"./lib/themed-image": "./lib/themed-image.ts",
|
|
249
261
|
"./lib/utils": "./lib/utils.ts",
|
|
250
262
|
"./lib/video-loading": "./lib/video-loading.ts",
|
|
@@ -289,7 +301,6 @@
|
|
|
289
301
|
"./app/newsroom/page": "./app/newsroom/page.tsx",
|
|
290
302
|
"./app/newsroom/sections/[sectionId]/page": "./app/newsroom/sections/[sectionId]/page.tsx",
|
|
291
303
|
"./app/newsroom/settings/page": "./app/newsroom/settings/page.tsx",
|
|
292
|
-
"./app/newsroom/work/page": "./app/newsroom/work/page.tsx",
|
|
293
304
|
"./app/page": "./app/page.tsx",
|
|
294
305
|
"./app/robots": "./app/robots.ts",
|
|
295
306
|
"./app/settings/page": "./app/settings/page.tsx"
|
|
@@ -339,7 +350,7 @@
|
|
|
339
350
|
"optional": true
|
|
340
351
|
}
|
|
341
352
|
},
|
|
342
|
-
"version": "1.
|
|
353
|
+
"version": "1.4.0-next.2",
|
|
343
354
|
"dependencies": {
|
|
344
355
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
345
356
|
"@aws-crypto/sha256-js": "5.2.0",
|
|
@@ -288,7 +288,7 @@ function PresentationItem({
|
|
|
288
288
|
const lines = useMemo(() => {
|
|
289
289
|
if (!maxWidth || !text.trim()) return [];
|
|
290
290
|
return layoutAllTextLines({
|
|
291
|
-
prepared: prepareWithSegments(text, `${textStyle.fontSize}px ${textStyle.fontFamily}
|
|
291
|
+
prepared: prepareWithSegments(text, `${textStyle.fontSize}px ${textStyle.fontFamily}`, { whiteSpace: "pre-wrap" }),
|
|
292
292
|
maxWidth,
|
|
293
293
|
...textStyle,
|
|
294
294
|
});
|