@yadeck/slide-components-basic 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +16 -2
- package/dist/chrome.d.ts +8 -0
- package/dist/chrome.d.ts.map +1 -0
- package/dist/chrome.js +17 -0
- package/dist/chrome.js.map +1 -0
- package/dist/flow.d.ts +26 -0
- package/dist/flow.d.ts.map +1 -0
- package/dist/flow.js +46 -0
- package/dist/flow.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +57 -0
- package/dist/index.js.map +1 -0
- package/dist/layouts.d.ts +114 -0
- package/dist/layouts.d.ts.map +1 -0
- package/dist/layouts.js +220 -0
- package/dist/layouts.js.map +1 -0
- package/dist/panes.d.ts +43 -0
- package/dist/panes.d.ts.map +1 -0
- package/dist/panes.js +131 -0
- package/dist/panes.js.map +1 -0
- package/dist/styles.d.ts +12 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +157 -0
- package/dist/styles.js.map +1 -0
- package/package.json +60 -4
- package/src/chrome.tsx +29 -0
- package/src/flow.tsx +89 -0
- package/src/index.ts +91 -0
- package/src/layouts.tsx +508 -0
- package/src/panes.tsx +213 -0
- package/src/styles.ts +157 -0
package/src/layouts.tsx
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import hljs from "highlight.js";
|
|
2
|
+
import type { CSSProperties } from "react";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import {
|
|
5
|
+
Block,
|
|
6
|
+
RichText,
|
|
7
|
+
Slide,
|
|
8
|
+
pick,
|
|
9
|
+
pickAll,
|
|
10
|
+
type BlockNode,
|
|
11
|
+
type BlockOf,
|
|
12
|
+
type DeckNode,
|
|
13
|
+
type SlideNode,
|
|
14
|
+
type SlideOf,
|
|
15
|
+
} from "@yadeck/slide-core";
|
|
16
|
+
import { PAGE, SlideHeading } from "./chrome.js";
|
|
17
|
+
|
|
18
|
+
// ---- deck ----
|
|
19
|
+
|
|
20
|
+
export const DefaultDeck = (deck: DeckNode) => (
|
|
21
|
+
<main className="yd-deck">
|
|
22
|
+
{deck.slides.map((s) => (
|
|
23
|
+
<Slide key={s.id} data={s} />
|
|
24
|
+
))}
|
|
25
|
+
</main>
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// ---- slide ----
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `card` を付けた slide。item をカードで横並びにし、それ以外の block は前置きとして上に置く。
|
|
32
|
+
* この bind が無い構成では tags 無しの slide に fallback して縦に並ぶ。
|
|
33
|
+
*/
|
|
34
|
+
/** カードの列数。3 枚までは 1 行、4 枚は 2×2、5 枚以上は 3 列で折り返す(1 枚に見出しと説明が入る分、columns より早く折り返す) */
|
|
35
|
+
export function cardColumns(n: number): number {
|
|
36
|
+
if (n <= 3) return Math.max(1, n);
|
|
37
|
+
return n === 4 ? 2 : 3;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const CardSlide = (slide: SlideNode) => {
|
|
41
|
+
const [heading, rest] = pick(slide.items, ["heading"]);
|
|
42
|
+
const [entries, lead] = pickAll(rest, ["item"]);
|
|
43
|
+
return (
|
|
44
|
+
<div className={`${PAGE} b-card`}>
|
|
45
|
+
{heading && <SlideHeading block={heading} description={slide.description} />}
|
|
46
|
+
{lead.length > 0 && (
|
|
47
|
+
<div className="flex flex-col gap-3">
|
|
48
|
+
{lead.map((b) => (
|
|
49
|
+
<Block key={b.id} data={b} />
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
)}
|
|
53
|
+
<div className="grid min-h-0 flex-1 auto-rows-[minmax(0,1fr)] gap-6" style={{ gridTemplateColumns: `repeat(${cardColumns(entries.length)}, minmax(0, 1fr))` }}>
|
|
54
|
+
{entries.map((b) => (
|
|
55
|
+
<div key={b.id} className="flex min-h-0 flex-col justify-center rounded-2xl bg-ink/5 px-8 py-5 [&_.b-item]:items-center [&_.b-item]:text-center">
|
|
56
|
+
<Block data={b} />
|
|
57
|
+
</div>
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const headerType = z.object({
|
|
65
|
+
title: z.string().describe("発表タイトル"),
|
|
66
|
+
subtitle: z.string().optional().describe("副題"),
|
|
67
|
+
author: z.string().optional().describe("発表者名"),
|
|
68
|
+
affiliation: z.string().optional().describe("所属。author の下に小さく出す"),
|
|
69
|
+
event: z.string().optional().describe("イベント名。右下に date と並べる"),
|
|
70
|
+
date: z.string().optional().describe("日付"),
|
|
71
|
+
logo: z.string().optional().describe("ロゴ画像。public/ のファイルは ./logo.svg、外部は URL。右上に白抜きで出す"),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 表紙。title / subtitle を中央左寄せで大きく、発表者情報を左下、イベント情報を右下に置く。
|
|
76
|
+
* items は title の下に補足として並ぶ(通常は空)。
|
|
77
|
+
*/
|
|
78
|
+
export const HeaderSlide = (slide: SlideOf<typeof headerType>) => (
|
|
79
|
+
<div className="b-header absolute inset-0 flex flex-col overflow-hidden bg-linear-to-br from-primary to-primary-deep px-20 py-14 text-surface">
|
|
80
|
+
{/* 装飾: 右下の大きな円と下端のストライプ。accent トークンで色が変わる。aria-hidden で yadeck check の対象から外す */}
|
|
81
|
+
<div aria-hidden="true" className="pointer-events-none absolute -right-40 -bottom-64 size-[640px] rounded-full bg-accent/10" />
|
|
82
|
+
<div aria-hidden="true" className="absolute inset-x-0 bottom-0 h-3 bg-accent" />
|
|
83
|
+
{slide.logo && <img className="absolute top-14 right-20 h-12 w-auto brightness-0 invert" src={slide.logo} alt="" />}
|
|
84
|
+
<div className="relative flex flex-1 flex-col justify-center">
|
|
85
|
+
<div className="flex flex-col gap-5 border-l-[10px] border-accent pl-8">
|
|
86
|
+
<h1 className="m-0 text-h1 leading-[1.25] font-bold">
|
|
87
|
+
<RichText text={slide.title} />
|
|
88
|
+
</h1>
|
|
89
|
+
{slide.subtitle && (
|
|
90
|
+
<p className="m-0 text-h3 leading-snug font-normal text-accent">
|
|
91
|
+
<RichText text={slide.subtitle} />
|
|
92
|
+
</p>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
{slide.items.length > 0 && (
|
|
96
|
+
<div className="mt-8 flex flex-col gap-3">
|
|
97
|
+
{slide.items.map((b) => (
|
|
98
|
+
<Block key={b.id} data={b} />
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
<div className="relative flex items-end justify-between gap-6 text-small leading-snug">
|
|
104
|
+
<div className="flex flex-col gap-1">
|
|
105
|
+
{slide.author && <span className="text-h4 font-bold">{slide.author}</span>}
|
|
106
|
+
{slide.affiliation && <span className="opacity-85">{slide.affiliation}</span>}
|
|
107
|
+
</div>
|
|
108
|
+
<div className="flex flex-col items-end gap-1 opacity-85">
|
|
109
|
+
{slide.event && <span>{slide.event}</span>}
|
|
110
|
+
{slide.date && <span>{slide.date}</span>}
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
export const profileType = z.object({
|
|
117
|
+
name: z.string().describe("名前"),
|
|
118
|
+
role: z.string().optional().describe("肩書き・役割"),
|
|
119
|
+
affiliation: z.string().optional().describe("所属"),
|
|
120
|
+
photo: z.string().optional().describe("顔写真。無ければ name の頭文字を accent の円で出す"),
|
|
121
|
+
accounts: z.record(z.string(), z.string()).optional().describe("SNS や GitHub の ID。{ x: id, github: id } の形"),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const ACCOUNT_LABEL: Record<string, string> = { x: "X", twitter: "X", github: "GitHub", bluesky: "Bluesky", mastodon: "Mastodon", zenn: "Zenn", qiita: "Qiita" };
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 自己紹介。左に名前・肩書き・写真、右に items(heading があれば右上の見出しに)。
|
|
128
|
+
*/
|
|
129
|
+
export const ProfileSlide = (slide: SlideOf<typeof profileType>) => {
|
|
130
|
+
const [heading, rest] = pick(slide.items, ["heading"]);
|
|
131
|
+
return (
|
|
132
|
+
<div className={`${PAGE} b-profile`}>
|
|
133
|
+
{heading && (
|
|
134
|
+
<SlideHeading block={heading} description={slide.description} />
|
|
135
|
+
)}
|
|
136
|
+
<div className="grid min-h-0 flex-1 grid-cols-[380px_1fr] gap-12">
|
|
137
|
+
<div className="relative flex flex-col items-center justify-center gap-6 overflow-hidden rounded-2xl bg-linear-to-br from-primary to-primary-deep px-8 py-10 text-center text-surface">
|
|
138
|
+
<div aria-hidden="true" className="pointer-events-none absolute -right-20 -bottom-24 size-72 rounded-full bg-accent/15" />
|
|
139
|
+
{slide.photo ? (
|
|
140
|
+
<img className="relative size-52 rounded-full object-cover ring-6 ring-accent" src={slide.photo} alt="" />
|
|
141
|
+
) : (
|
|
142
|
+
<div className="relative flex size-52 items-center justify-center rounded-full bg-surface/10 text-display font-extrabold text-accent ring-6 ring-accent">
|
|
143
|
+
{Array.from(slide.name)[0]}
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
<div className="relative flex flex-col gap-1">
|
|
147
|
+
<span className="text-h3 font-bold">{slide.name}</span>
|
|
148
|
+
{slide.role && <span className="text-body text-accent">{slide.role}</span>}
|
|
149
|
+
{slide.affiliation && <span className="text-small opacity-85">{slide.affiliation}</span>}
|
|
150
|
+
</div>
|
|
151
|
+
{slide.accounts && (
|
|
152
|
+
<div className="relative flex flex-col gap-1 text-small">
|
|
153
|
+
{Object.entries(slide.accounts).map(([service, id]) => (
|
|
154
|
+
<span key={service}>
|
|
155
|
+
<span className="mr-2 text-accent">{ACCOUNT_LABEL[service.toLowerCase()] ?? service}</span>@{id}
|
|
156
|
+
</span>
|
|
157
|
+
))}
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
<div className="flex min-w-0 flex-col justify-center gap-6">
|
|
162
|
+
{rest.map((b) => (
|
|
163
|
+
<Block key={b.id} data={b} />
|
|
164
|
+
))}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export const sectionHeaderType = z.object({
|
|
172
|
+
number: z.string().optional().describe("章番号。\"01\" のように表示したい文字列"),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 章扉。表紙と同じ primary の地と accent の縦バーで、本文ページとの切り替わりを見せる。
|
|
177
|
+
* number は左上に accent で大きく、右下に透かしでもう一度出す
|
|
178
|
+
*/
|
|
179
|
+
export const SectionHeaderSlide = (slide: SlideOf<typeof sectionHeaderType>) => {
|
|
180
|
+
const [heading, rest] = pick(slide.items, ["heading"]);
|
|
181
|
+
const [bodies] = pickAll(rest, ["body"]);
|
|
182
|
+
return (
|
|
183
|
+
<div className="b-section absolute inset-0 flex flex-col justify-center overflow-hidden bg-linear-to-br from-primary to-primary-deep px-20 py-14 text-surface [&_.b-heading]:m-0 [&_.b-heading]:text-[64px] [&_.b-heading]:leading-tight [&_.b-heading]:text-surface [&_.b-body]:m-0 [&_.b-body]:text-h3 [&_.b-body]:text-accent">
|
|
184
|
+
{slide.number && (
|
|
185
|
+
<div aria-hidden="true" className="pointer-events-none absolute -right-6 -bottom-28 text-[420px] leading-none font-extrabold text-surface/5 select-none">
|
|
186
|
+
{slide.number}
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
<div aria-hidden="true" className="absolute inset-x-0 bottom-0 h-3 bg-accent" />
|
|
190
|
+
<div className="relative flex flex-col gap-6">
|
|
191
|
+
{slide.number && <div className="text-display leading-none font-extrabold text-accent">{slide.number}</div>}
|
|
192
|
+
<div className="flex flex-col gap-4 border-l-[10px] border-accent pl-8">
|
|
193
|
+
{heading && <Block data={heading} />}
|
|
194
|
+
{bodies.map((b) => (
|
|
195
|
+
<Block key={b.id} data={b} />
|
|
196
|
+
))}
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/** 見出し + 前置き + left / right の 2 列。left / right の無い block は前置きとして列の上に置く */
|
|
204
|
+
export const TwoColumnSlide = (slide: SlideNode) => {
|
|
205
|
+
const [heading, rest] = pick(slide.items, ["heading"]);
|
|
206
|
+
const [right, rest2] = pickAll(rest, ["right"]);
|
|
207
|
+
const [left, lead] = pickAll(rest2, ["left"]);
|
|
208
|
+
// 列の中の code が収まるよう、pane 相当の寸法を渡す(幅は列幅、高さは列の半分程度を上限とみなす)
|
|
209
|
+
const column = (blocks: BlockNode[]) => (
|
|
210
|
+
<div
|
|
211
|
+
className="flex min-w-0 flex-col gap-5 [&_.b-heading]:text-h3 [&_.b-heading]:text-primary"
|
|
212
|
+
style={{ "--pane-w": "540px", "--pane-h": "260px" } as CSSProperties}
|
|
213
|
+
>
|
|
214
|
+
{blocks.map((b) => (
|
|
215
|
+
<Block key={b.id} data={b} />
|
|
216
|
+
))}
|
|
217
|
+
</div>
|
|
218
|
+
);
|
|
219
|
+
return (
|
|
220
|
+
<div className={`${PAGE} b-two-column`}>
|
|
221
|
+
{heading && <SlideHeading block={heading} description={slide.description} />}
|
|
222
|
+
{lead.length > 0 && (
|
|
223
|
+
<div className="flex flex-col gap-3">
|
|
224
|
+
{lead.map((b) => (
|
|
225
|
+
<Block key={b.id} data={b} />
|
|
226
|
+
))}
|
|
227
|
+
</div>
|
|
228
|
+
)}
|
|
229
|
+
<div className="grid min-h-0 flex-1 grid-cols-2 gap-10">
|
|
230
|
+
{column(left)}
|
|
231
|
+
{column(right)}
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// ---- block ----
|
|
238
|
+
|
|
239
|
+
export const DefaultBlock = (block: BlockNode) => <RichText as="div" className="b-body" text={block.text} />;
|
|
240
|
+
|
|
241
|
+
export const headingType = z.object({
|
|
242
|
+
level: z.number().int().min(1).max(4).optional().describe("見出しレベル 1〜4。既定 2"),
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const HEADING_SIZE = { 1: "text-h1", 2: "text-h2", 3: "text-h3", 4: "text-h4" } as const;
|
|
246
|
+
|
|
247
|
+
/** 見出し。文字サイズは level で決まるが、slide 側が `[&_.b-heading]:text-*` で上書きできる */
|
|
248
|
+
export const HeadingBlock = (block: BlockOf<typeof headingType>) => {
|
|
249
|
+
const level = (block.level ?? 2) as 1 | 2 | 3 | 4;
|
|
250
|
+
const Tag = `h${level}` as "h2";
|
|
251
|
+
return (
|
|
252
|
+
<Tag className={`b-heading m-0 leading-tight font-bold ${HEADING_SIZE[level]}`}>
|
|
253
|
+
<RichText text={block.text} />
|
|
254
|
+
</Tag>
|
|
255
|
+
);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
export const BodyBlock = (block: BlockNode) => <RichText as="div" className="b-body" text={block.text} />;
|
|
259
|
+
|
|
260
|
+
export const listType = z.object({
|
|
261
|
+
ordered: z.boolean().optional().describe("番号付きにする"),
|
|
262
|
+
focus: z.number().int().min(1).optional().describe("焦点にする項目の番号(1 始まり)。他の項目は薄くなる"),
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
/** text の各行を項目にする。markdown の箇条書きは body でも書けるので、こちらは行区切りの簡易記法 */
|
|
266
|
+
export const ListBlock = (block: BlockOf<typeof listType>) => {
|
|
267
|
+
const lines = (block.text ?? "").split("\n").filter((l) => l.trim().length > 0);
|
|
268
|
+
const Tag = block.ordered ? "ol" : "ul";
|
|
269
|
+
return (
|
|
270
|
+
<Tag className={`b-list m-0 pl-[1.2em] marker:text-accent ${block.ordered ? "list-decimal" : "list-disc"}`}>
|
|
271
|
+
{lines.map((line, i) => (
|
|
272
|
+
<li key={i} className={`my-1 ${block.focus ? (block.focus === i + 1 ? "font-bold text-primary" : "opacity-50") : ""}`}>
|
|
273
|
+
<RichText text={line} />
|
|
274
|
+
</li>
|
|
275
|
+
))}
|
|
276
|
+
</Tag>
|
|
277
|
+
);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
export const codeType = z.object({
|
|
281
|
+
lang: z.string().optional().describe("言語。省略で自動判定、text でログなどを無色表示"),
|
|
282
|
+
caption: z.string().optional().describe("コードの下に添える 1 行"),
|
|
283
|
+
focus: z
|
|
284
|
+
.union([z.string(), z.number().int().min(1), z.array(z.union([z.string(), z.number().int().min(1)]))])
|
|
285
|
+
.optional()
|
|
286
|
+
.describe('焦点にする行。行番号(1 始まり)、"6-8" の範囲、または行に含まれる文字列。配列で複数可。他の行は薄くなる'),
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
/** focus の指定を行番号(0 始まり)の集合にする */
|
|
290
|
+
export function focusLines(lines: string[], focus: z.infer<typeof codeType>["focus"]): Set<number> {
|
|
291
|
+
const set = new Set<number>();
|
|
292
|
+
if (focus === undefined) return set;
|
|
293
|
+
for (const f of Array.isArray(focus) ? focus : [focus]) {
|
|
294
|
+
if (typeof f === "number") {
|
|
295
|
+
set.add(f - 1);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const range = f.match(/^(\d+)-(\d+)$/);
|
|
299
|
+
if (range) {
|
|
300
|
+
for (let i = Number(range[1]); i <= Number(range[2]); i++) set.add(i - 1);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
lines.forEach((l, i) => {
|
|
304
|
+
if (l.includes(f)) set.add(i);
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return set;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* ハイライト済み HTML を行ごとに分ける。行をまたぐ span(複数行コメントなど)は
|
|
312
|
+
* 行末で閉じて次の行頭で開き直す。
|
|
313
|
+
*/
|
|
314
|
+
export function splitHighlighted(html: string): string[] {
|
|
315
|
+
const out: string[] = [];
|
|
316
|
+
const open: string[] = [];
|
|
317
|
+
for (const raw of html.split("\n")) {
|
|
318
|
+
let line = open.join("");
|
|
319
|
+
for (const m of raw.matchAll(/<span[^>]*>|<\/span>/g)) {
|
|
320
|
+
if (m[0] === "</span>") open.pop();
|
|
321
|
+
else open.push(m[0]);
|
|
322
|
+
}
|
|
323
|
+
line += raw + "</span>".repeat(open.length);
|
|
324
|
+
out.push(line);
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* コード。highlight.js で SSG 時に色付けする。
|
|
331
|
+
* 行数と最大行長を CSS 変数で渡し、pane に収まるフォントサイズを CSS の calc で決める(styles.ts)。
|
|
332
|
+
*/
|
|
333
|
+
/** コードの最大行幅(全角 2)と行数。フォントサイズの計算に使う */
|
|
334
|
+
export function codeSize(text: string | undefined): { cols: number; rows: number } {
|
|
335
|
+
const lines = (text ?? "").replace(/\n$/, "").split("\n");
|
|
336
|
+
return { cols: Math.max(1, ...lines.map(displayWidth)), rows: lines.length };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** 箱の上下余白(40px)と、caption があればその 1 行分(40px)。高さからこの分を引いた残りに行を収める */
|
|
340
|
+
export function codeChrome(caption: string | undefined): number {
|
|
341
|
+
return caption ? 80 : 40;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export const CodeBlock = (block: BlockOf<typeof codeType>) => {
|
|
345
|
+
const code = (block.text ?? "").replace(/\n$/, "");
|
|
346
|
+
const lines = code.split("\n");
|
|
347
|
+
const { cols } = codeSize(code);
|
|
348
|
+
// lang 無しは自動判定、text / plain はログなどの無色表示
|
|
349
|
+
const plain = block.lang === "text" || block.lang === "plain" || block.lang === "plaintext";
|
|
350
|
+
const lang = block.lang && hljs.getLanguage(block.lang) ? block.lang : undefined;
|
|
351
|
+
const html = plain ? escapeHtml(code) : lang ? hljs.highlight(code, { language: lang }).value : hljs.highlightAuto(code).value;
|
|
352
|
+
const focus = focusLines(lines, block.focus);
|
|
353
|
+
const rows = splitHighlighted(html);
|
|
354
|
+
// 箱は内容の高さで止め、pane の中では縦中央に置く(短いコードで黒い箱が間延びしない)
|
|
355
|
+
return (
|
|
356
|
+
<figure className="m-0 flex min-h-0 flex-1 flex-col justify-center gap-2">
|
|
357
|
+
<pre
|
|
358
|
+
className="b-code m-0 max-h-full min-h-0 overflow-hidden rounded-lg bg-code py-5 font-mono leading-snug text-code-ink"
|
|
359
|
+
data-lang={block.lang}
|
|
360
|
+
style={{ "--code-cols": cols, "--code-rows": lines.length, "--code-chrome": `${codeChrome(block.caption)}px` } as CSSProperties}
|
|
361
|
+
>
|
|
362
|
+
<code className="hljs block">
|
|
363
|
+
{rows.map((row, i) => (
|
|
364
|
+
<span
|
|
365
|
+
key={i}
|
|
366
|
+
className={`block border-l-4 px-5 ${
|
|
367
|
+
focus.size === 0 ? "border-transparent" : focus.has(i) ? "border-accent bg-accent/15" : "border-transparent opacity-40"
|
|
368
|
+
}`}
|
|
369
|
+
dangerouslySetInnerHTML={{ __html: row || " " }}
|
|
370
|
+
/>
|
|
371
|
+
))}
|
|
372
|
+
</code>
|
|
373
|
+
</pre>
|
|
374
|
+
{block.caption && (
|
|
375
|
+
<figcaption className="shrink-0 text-small text-muted">
|
|
376
|
+
<RichText text={block.caption} />
|
|
377
|
+
</figcaption>
|
|
378
|
+
)}
|
|
379
|
+
</figure>
|
|
380
|
+
);
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
export const imageType = z.object({
|
|
384
|
+
src: z.string().describe("画像のパス(public/ なら ./name.png)か URL"),
|
|
385
|
+
alt: z.string().optional().describe("代替テキスト"),
|
|
386
|
+
width: z.union([z.string(), z.number()]).optional().describe("幅を固定したいときだけ。既定は pane いっぱい"),
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
/** 画像。pane いっぱいにアスペクト比を保って収める。width 指定があればその幅にする */
|
|
390
|
+
export const ImageBlock = (block: BlockOf<typeof imageType>) => (
|
|
391
|
+
<figure className="b-image m-0 flex min-h-0 flex-1 flex-col items-center gap-2">
|
|
392
|
+
<img
|
|
393
|
+
className={block.width ? "max-h-full" : "min-h-0 w-full flex-1 object-contain"}
|
|
394
|
+
src={block.src}
|
|
395
|
+
alt={block.alt ?? ""}
|
|
396
|
+
style={block.width ? { width: block.width } : undefined}
|
|
397
|
+
/>
|
|
398
|
+
{block.text && (
|
|
399
|
+
<figcaption className="shrink-0 text-small text-muted">
|
|
400
|
+
<RichText text={block.text} />
|
|
401
|
+
</figcaption>
|
|
402
|
+
)}
|
|
403
|
+
</figure>
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
export const videoType = z.object({
|
|
407
|
+
url: z.string().describe("YouTube の watch / youtu.be / shorts の URL、または iframe で開ける URL"),
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
/** 動画。YouTube は embed URL に変換して iframe で埋め込む。text はキャプション */
|
|
411
|
+
export const VideoBlock = (block: BlockOf<typeof videoType>) => (
|
|
412
|
+
<figure className="b-video m-0 flex min-h-0 flex-1 flex-col items-center gap-2">
|
|
413
|
+
<iframe
|
|
414
|
+
className="min-h-0 w-full flex-1 rounded-lg border-0 bg-code"
|
|
415
|
+
src={toEmbedUrl(block.url)}
|
|
416
|
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
417
|
+
allowFullScreen
|
|
418
|
+
/>
|
|
419
|
+
{block.text && (
|
|
420
|
+
<figcaption className="shrink-0 text-small text-muted">
|
|
421
|
+
<RichText text={block.text} />
|
|
422
|
+
</figcaption>
|
|
423
|
+
)}
|
|
424
|
+
</figure>
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
/** 等幅フォントでの表示幅。全角(CJK や全角記号)は半角 2 つ分として数える */
|
|
428
|
+
function displayWidth(line: string): number {
|
|
429
|
+
let w = 0;
|
|
430
|
+
for (const ch of line) {
|
|
431
|
+
const cp = ch.codePointAt(0)!;
|
|
432
|
+
w += cp >= 0x1100 && (cp <= 0x115f || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) || (cp >= 0xffe0 && cp <= 0xffe6) || cp >= 0x20000) ? 2 : 1;
|
|
433
|
+
}
|
|
434
|
+
return w;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function escapeHtml(s: string): string {
|
|
438
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export function toEmbedUrl(url: string): string {
|
|
442
|
+
const m = url.match(/(?:youtube\.com\/(?:watch\?v=|shorts\/)|youtu\.be\/)([\w-]{11})/);
|
|
443
|
+
return m ? `https://www.youtube.com/embed/${m[1]}` : url;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export const statType = z.object({
|
|
447
|
+
value: z.string().describe("大きく出す値。数字や固有名詞"),
|
|
448
|
+
label: z.string().optional().describe("値の説明。1 行"),
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
/** 数字や固有名詞を主役にする block。text は補足として小さく出す */
|
|
452
|
+
export const StatBlock = (block: BlockOf<typeof statType>) => (
|
|
453
|
+
<div className="b-stat flex items-baseline gap-5">
|
|
454
|
+
<span className="w-[240px] shrink-0 text-right text-h1 leading-none font-extrabold text-primary">
|
|
455
|
+
<RichText text={block.value} />
|
|
456
|
+
</span>
|
|
457
|
+
<div className="flex min-w-0 flex-col gap-1">
|
|
458
|
+
{block.label && (
|
|
459
|
+
<span className="text-h4 leading-tight font-bold">
|
|
460
|
+
<RichText text={block.label} />
|
|
461
|
+
</span>
|
|
462
|
+
)}
|
|
463
|
+
{block.text && (
|
|
464
|
+
<span className="text-small leading-snug text-muted">
|
|
465
|
+
<RichText text={block.text} />
|
|
466
|
+
</span>
|
|
467
|
+
)}
|
|
468
|
+
</div>
|
|
469
|
+
</div>
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
export const itemType = z.object({
|
|
473
|
+
title: z.string().describe("項目名"),
|
|
474
|
+
logo: z.string().optional().describe("項目のロゴ画像"),
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
/** 並列の項目。タイトルと説明を持つ。見せ方(カードにするか等)は slide の Layout が決める */
|
|
478
|
+
export const ItemBlock = (block: BlockOf<typeof itemType>) => (
|
|
479
|
+
<div className="b-item flex flex-col gap-2">
|
|
480
|
+
{block.logo && <img className="h-14 w-auto" src={block.logo} alt="" />}
|
|
481
|
+
<span className="text-h4 leading-tight font-bold [word-break:keep-all]">
|
|
482
|
+
<RichText text={block.title} />
|
|
483
|
+
</span>
|
|
484
|
+
{block.text && (
|
|
485
|
+
<span className="text-body leading-snug text-muted">
|
|
486
|
+
<RichText text={block.text} />
|
|
487
|
+
</span>
|
|
488
|
+
)}
|
|
489
|
+
</div>
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
export const quoteType = z.object({
|
|
493
|
+
cite: z.string().optional().describe("出典"),
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
export const QuoteBlock = (block: BlockOf<typeof quoteType>) => (
|
|
497
|
+
<blockquote className="b-quote m-0 border-l-[6px] border-primary px-6 py-2 text-muted">
|
|
498
|
+
<RichText as="div" className="b-body" text={block.text} />
|
|
499
|
+
{block.cite && <cite className="mt-2 block text-small text-faint">{block.cite}</cite>}
|
|
500
|
+
</blockquote>
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
/** 発表者ノート。画面・印刷には出さず、presenter モードが `.yd-notes` を拾って表示する */
|
|
504
|
+
export const NoteBlock = (block: BlockNode) => (
|
|
505
|
+
<div className="yd-notes b-note" hidden>
|
|
506
|
+
<RichText as="div" text={block.text} />
|
|
507
|
+
</div>
|
|
508
|
+
);
|