feat(schemas): edit split inline markdown mvt variables (#1474)

This commit is contained in:
Kyohei Fukuda
2026-05-06 13:25:32 +09:00
committed by GitHub
parent 6038638603
commit da8fdfaeec
3 changed files with 220 additions and 45 deletions

26
PLAN.md
View File

@@ -34,8 +34,8 @@ layout/builder の考え方を `converter` package の `md2pdf` に応用し、M
- リンク基盤、`@pdfme/jsx` MVP、text / MVT の `overflow: "expand"`、行単位 page split、
custom `basePdf` 制御、dynamic layout docs は main に入った。
- plain `multiVariableText` の split chunk は Form 上で編集できる。
- inline-markdown split chunk は、plain text / MVT ともに Form 上では read-only のまま残す。
- `multiVariableText` の split chunk は、plain / inline-markdown ともに Form 上で変数値だけ編集できる。
- `text` schema の inline-markdown split chunk は Form 上では read-only のまま残す。
- 次の大きな判断は、dynamic layout の編集体験をどこまで広げるかと、`@pdfme/jsx` / `md2pdf`
に進む前にどの schema 表現を追加するか。
@@ -94,9 +94,8 @@ layout/builder の考え方を `converter` package の `md2pdf` に応用し、M
`__splitRange: { unit: "textLine", start, end }` を持つ split schema に分割し、次ページへ続ける。
plain text と inline-markdown は同じ line layout を使い、PDF / UI preview で split chunk が
同じ input 全体を重複描画しないようにする。
- inline-markdown の split chunk は markdown 記法が行境界で分断される可能性があるため、Form 上では
初回は read-only 表示に寄せる。将来的に編集可能にする場合は rich text AST と selection/editing
model を合わせて設計する。
- inline-markdown の split chunk は markdown 記法が行境界で分断される可能性があるため、`text`
schema は Form 上では read-only 表示に寄せる。編集対象は Designer / template authoring 側に寄せる。
### PR #1469: dynamic layout split range の共通化
@@ -132,13 +131,21 @@ layout/builder の考え方を `converter` package の `md2pdf` に応用し、M
- inline-markdown の split chunk は、rich text AST / selection editing の設計が必要なため read-only
のまま残す。
### PR #1474: inline-markdown `multiVariableText` split chunk の変数編集
- inline-markdown `multiVariableText` の split chunk は、Form 上で表示範囲内の変数値だけ編集できる。
- template の markdown 記法、static text、link は Form では編集対象にしない。
- 変数値に `**` や `` ` `` などの markdown delimiter が含まれても、変数値として literal に扱う。
計測時は escape 済みの値を使い、再描画時に変数値が markdown として再解釈されないようにする。
- link は Form 上では clickable にしない。link の見た目を Form mode でも維持するかは follow-up とする。
## 次 PR 候補
### 1. inline-markdown split chunk 編集方針
### 1. `text` schema inline-markdown split chunk 編集方針
直近で一番悩ましい残論点。すぐ実装するより、先に仕様を固定した方がよい。
- split 後の inline-markdown text / MVT を Form 上でも編集可能にするか、read-only 表示に限定するか決める。
- split 後の inline-markdown `text` を Form 上でも編集可能にするか、read-only 表示に限定するか決める。
- 編集可能にする場合は、markdown source string を直接編集するのか、rich text AST / run model を編集して
source へ戻すのか決める。
- link / bold / italic / inline code が行境界で分断された場合の selection / blur / input merge 方針を決める。
@@ -235,8 +242,7 @@ layout/builder の考え方を `converter` package の `md2pdf` に応用し、M
Dynamic layout / editing:
- inline-markdown の `multiVariableText` split chunk を Form 上で編集可能にするべきか、read-only chunk のままでよいか。
- split 後の inline-markdown 編集をサポートするか、read-only 表示に限定するか。
- `text` schema の split inline-markdown 編集をサポートするか、read-only 表示に限定するか。
- split chunk 内の複数 variable span を連続編集した場合、blur の順序と reflow 後の最新 input を
どう同期するか。必要なら live pagination / editing session の設計と合わせて扱う。
- custom `basePdf` では dynamic layout を無効にする現行方針で固定するか、将来的に限定的な reflow
@@ -246,6 +252,8 @@ Dynamic layout / editing:
Rich content / link:
- MVT の inline link 対応をどのタイミングで入れるか。
- Form mode の inline link は clickable にしないまま、色や underline などの視覚表現だけを
viewer と揃えるべきか決める。
- table cell / list item の rich inline content を schema 拡張で扱うか、複数 schema に分解するか。
- link の見た目をデフォルトで青 + 下線にするか、明示的 styling に任せるか。
- Designer で通常のテキスト編集を難しくせずにリンク編集 UI をどう出すか。

View File

@@ -330,7 +330,7 @@ describe('multiVariableText inline markdown UI rendering', () => {
expect(newValue.name).not.toBe('edited chunk');
});
it('keeps split inline markdown form chunks as read-only resolved text', async () => {
it('writes split inline markdown form chunk edits back into the full variable value', async () => {
const rootElement = document.createElement('div');
const onChange = vi.fn();
const schema: MultiVariableTextSchema = {
@@ -341,6 +341,44 @@ describe('multiVariableText inline markdown UI rendering', () => {
__splitRange: { unit: 'textLine', start: 0, end: 1 },
};
await uiRender({
value: JSON.stringify({ name: 'first **line**\nsecond line' }),
schema,
rootElement,
mode: 'form',
onChange,
options: { font: getSampleFont() },
_cache: new Map(),
theme: { colorPrimary: '#1677ff' },
} as Parameters<typeof uiRender>[0]);
const textBlock = rootElement.querySelector(`#text-${schema.id}`) as HTMLDivElement;
const variableSpan = textBlock.querySelector('span') as HTMLSpanElement;
expect(textBlock.textContent).toBe('first **line**');
expect(variableSpan.contentEditable).toBe('plaintext-only');
expect(variableSpan.style.fontWeight).toBe('800');
expect(variableSpan.style.textShadow).not.toBe('');
variableSpan.textContent = 'edited **first** line';
variableSpan.dispatchEvent(new Event('blur'));
expect(onChange).toHaveBeenCalledWith({
key: 'content',
value: JSON.stringify({ name: 'edited **first** line\nsecond line' }),
});
});
it('writes later split inline markdown chunk edits back into the correct variable range', async () => {
const rootElement = document.createElement('div');
const onChange = vi.fn();
const schema: MultiVariableTextSchema = {
...getSchema(),
text: '**{name}**',
variables: ['name'],
width: 100,
__splitRange: { unit: 'textLine', start: 1, end: 2 },
};
await uiRender({
value: JSON.stringify({ name: 'first line\nsecond line' }),
schema,
@@ -352,11 +390,87 @@ describe('multiVariableText inline markdown UI rendering', () => {
theme: { colorPrimary: '#1677ff' },
} as Parameters<typeof uiRender>[0]);
const textBlock = rootElement.querySelector(`#text-${schema.id}`) as HTMLDivElement;
const variableSpan = textBlock.querySelector('span') as HTMLSpanElement;
expect(textBlock.textContent).toBe('second line');
variableSpan.textContent = 'edited second line';
variableSpan.dispatchEvent(new Event('blur'));
expect(onChange).toHaveBeenCalledWith({
key: 'content',
value: JSON.stringify({ name: 'first line\nedited second line' }),
});
});
it('does not create an editable split inline markdown span for an empty variable value', async () => {
const rootElement = document.createElement('div');
const onChange = vi.fn();
const schema: MultiVariableTextSchema = {
...getSchema(),
text: '**{name}**',
variables: ['name'],
width: 100,
__splitRange: { unit: 'textLine', start: 0, end: 1 },
};
await uiRender({
value: JSON.stringify({ name: '' }),
schema,
rootElement,
mode: 'form',
onChange,
options: { font: getSampleFont() },
_cache: new Map(),
theme: { colorPrimary: '#1677ff' },
} as Parameters<typeof uiRender>[0]);
const textBlock = rootElement.querySelector(`#text-${schema.id}`) as HTMLDivElement;
textBlock.dispatchEvent(new Event('blur'));
expect(textBlock.textContent).toBe('');
expect(textBlock.querySelector('span')).toBeNull();
expect(onChange).not.toHaveBeenCalled();
expect(textBlock.querySelector('span')?.contentEditable).not.toBe('plaintext-only');
expect(textBlock.textContent).toBe('first line');
});
it('keeps split inline markdown links non-clickable while editing variables', async () => {
const rootElement = document.createElement('div');
const onChange = vi.fn();
const schema: MultiVariableTextSchema = {
...getSchema(),
text: '[docs](https://pdfme.com) for **{name}**',
variables: ['name'],
width: 100,
__splitRange: { unit: 'textLine', start: 0, end: 1 },
};
await uiRender({
value: JSON.stringify({ name: 'Alice\nBob' }),
schema,
rootElement,
mode: 'form',
onChange,
options: { font: getSampleFont() },
_cache: new Map(),
theme: { colorPrimary: '#1677ff' },
} as Parameters<typeof uiRender>[0]);
const textBlock = rootElement.querySelector(`#text-${schema.id}`) as HTMLDivElement;
const variableSpan = Array.from(textBlock.querySelectorAll('span')).find(
(span) => span.contentEditable === 'plaintext-only',
) as HTMLSpanElement;
expect(textBlock.querySelector('a')).toBeNull();
expect(textBlock.textContent).toBe('docs for Alice');
expect(variableSpan.textContent).toBe('Alice');
expect(variableSpan.style.fontWeight).toBe('800');
variableSpan.textContent = 'Carol';
variableSpan.dispatchEvent(new Event('blur'));
expect(onChange).toHaveBeenCalledWith({
key: 'content',
value: JSON.stringify({ name: 'Carol\nBob' }),
});
});
});

View File

@@ -30,17 +30,6 @@ export const uiRender = async (arg: UIRenderProps<MultiVariableTextSchema>) => {
: substituteVariables(text, value);
if (mode === 'form' && numVariables > 0 && !renderResolvedValue) {
if (getTextLineRange(schema) && isInlineMarkdownTextSchema(schema)) {
await parentUiRender({
value: renderValue,
schema,
mode: 'viewer',
rootElement,
...rest,
});
return;
}
await formUiRender(arg);
return;
}
@@ -115,6 +104,9 @@ const formUiRender = async (arg: UIRenderProps<MultiVariableTextSchema>) => {
_cache as Map<string, import('fontkit').Font>,
);
// The split/non-split form paths rebuild child spans after this call. We still create
// the parent text block through the shared helper so sizing, alignment, and font
// setup stay identical to the normal text renderer.
const textBlock = buildStyledTextContainer(
arg,
fontKitFont,
@@ -126,18 +118,25 @@ const formUiRender = async (arg: UIRenderProps<MultiVariableTextSchema>) => {
const lineRange = getTextLineRange(schema);
if (lineRange) {
const { lines } = await measureTextLines({
value: substitutedText,
// Variable values are literal Form inputs. Escape markdown delimiters before
// measuring so a value like "**name**" is not reinterpreted as template markdown
// after blur/reflow.
value: inlineMarkdownRuns
? substituteVariablesAsInlineMarkdownLiterals(rawText, variables)
: substitutedText,
schema,
font,
_cache,
ignoreDynamicFontSize: true,
});
renderSplitPlainVariableSpans({
renderSplitVariableSpans({
textBlock,
lines,
runs: inlineMarkdownRuns,
rawText,
variables,
schema,
font,
theme,
onChange,
stopEditing,
@@ -203,28 +202,36 @@ type SplitChunkSegment = {
variableName?: string;
variableStart?: number;
variableEnd?: number;
run?: RichTextRun;
};
type ResolvedPlainChar = {
type ResolvedChunkChar = {
char: string;
variableName?: string;
variableOffset?: number;
run?: RichTextRun;
};
const renderSplitPlainVariableSpans = (arg: {
type RenderFont = NonNullable<UIRenderProps<MultiVariableTextSchema>['options']['font']>;
const renderSplitVariableSpans = (arg: {
textBlock: HTMLDivElement;
lines: string[];
runs?: RichTextRun[];
rawText: string;
variables: Record<string, string>;
schema: MultiVariableTextSchema;
font: RenderFont;
theme: UIRenderProps<MultiVariableTextSchema>['theme'];
onChange: UIRenderProps<MultiVariableTextSchema>['onChange'];
stopEditing: UIRenderProps<MultiVariableTextSchema>['stopEditing'];
}) => {
const { textBlock, lines, rawText, variables, schema, theme, onChange, stopEditing } = arg;
const { textBlock, lines, runs, rawText, variables, schema, font, theme, onChange, stopEditing } =
arg;
const lineRange = getTextLineRange(schema);
const lineSegments = getSplitPlainLineSegments({
const lineSegments = getSplitLineSegments({
lines,
runs,
rawText,
variables,
start: lineRange?.start ?? 0,
@@ -239,6 +246,8 @@ const renderSplitPlainVariableSpans = (arg: {
textBlock,
segment,
variables,
schema,
font,
theme,
onChange,
stopEditing,
@@ -249,6 +258,9 @@ const renderSplitPlainVariableSpans = (arg: {
const span = document.createElement('span');
span.style.letterSpacing = lineIndex === lineSegments.length - 1 ? '0' : 'inherit';
span.textContent = segment.text;
if (segment.run) {
applyInlineMarkdownStyle({ element: span, run: segment.run, schema, font });
}
textBlock.appendChild(span);
});
@@ -258,24 +270,29 @@ const renderSplitPlainVariableSpans = (arg: {
});
};
const getSplitPlainLineSegments = (arg: {
const getSplitLineSegments = (arg: {
lines: string[];
runs?: RichTextRun[];
rawText: string;
variables: Record<string, string>;
start: number;
end: number;
}): SplitChunkSegment[][] => {
const { lines, rawText, variables, start, end } = arg;
const resolvedChars = buildResolvedPlainChars(rawText, variables);
const allLineSegments = consumeMeasuredLineSegments(lines, resolvedChars);
const { lines, runs, rawText, variables, start, end } = arg;
const resolvedChars = runs
? buildResolvedInlineMarkdownChars(runs, variables)
: buildResolvedPlainChars(rawText, variables);
const allLineSegments = consumeMeasuredLineSegments(lines, resolvedChars, {
dropUnmappedTargets: Boolean(runs),
});
return allLineSegments.slice(start, end);
};
const buildResolvedPlainChars = (
rawText: string,
variables: Record<string, string>,
): ResolvedPlainChar[] => {
const chars: ResolvedPlainChar[] = [];
): ResolvedChunkChar[] => {
const chars: ResolvedChunkChar[] = [];
let lastIndex = 0;
visitVariables(rawText, ({ name, startIndex, endIndex }) => {
@@ -294,15 +311,40 @@ const buildResolvedPlainChars = (
return chars;
};
const appendTextChars = (chars: ResolvedPlainChar[], text: string) => {
const buildResolvedInlineMarkdownChars = (
runs: RichTextRun[],
variables: Record<string, string>,
): ResolvedChunkChar[] => {
const chars: ResolvedChunkChar[] = [];
runs.forEach((run) => {
let lastIndex = 0;
visitVariables(run.text, ({ name, startIndex, endIndex }) => {
appendTextChars(chars, run.text.slice(lastIndex, startIndex), run);
const value = variables[name] ?? '';
for (let i = 0; i < value.length; i += 1) {
chars.push({ char: value[i], variableName: name, variableOffset: i, run });
}
lastIndex = endIndex + 1;
});
appendTextChars(chars, run.text.slice(lastIndex), run);
});
return chars;
};
const appendTextChars = (chars: ResolvedChunkChar[], text: string, run?: RichTextRun) => {
for (let i = 0; i < text.length; i += 1) {
chars.push({ char: text[i] });
chars.push({ char: text[i], run });
}
};
const consumeMeasuredLineSegments = (
lines: string[],
resolvedChars: ResolvedPlainChar[],
resolvedChars: ResolvedChunkChar[],
options: { dropUnmappedTargets?: boolean } = {},
): SplitChunkSegment[][] => {
const lineSegments: SplitChunkSegment[][] = [];
let cursor = 0;
@@ -311,9 +353,10 @@ const consumeMeasuredLineSegments = (
const segments: SplitChunkSegment[] = [];
const lineText = stripTrailingLineBreaks(line);
// `lines` must come from measuring the substituted plain MVT value. We map those
// measured characters back to the same substituted value, annotated with variable
// offsets, so each editable span can update only the touched variable range.
// `lines` must come from measuring the substituted MVT value. We map those measured
// characters back to the same substituted value, annotated with variable offsets
// and optional inline markdown run styles, so each editable span updates only the
// touched variable range.
for (let i = 0; i < lineText.length; i += 1) {
const target = lineText[i];
while (
@@ -326,6 +369,7 @@ const consumeMeasuredLineSegments = (
}
if (cursor >= resolvedChars.length) {
if (options.dropUnmappedTargets) continue;
appendSegment(segments, { char: target });
continue;
}
@@ -338,6 +382,7 @@ const consumeMeasuredLineSegments = (
// If a future text measurement path normalizes characters differently, keep
// rendering the chunk but do not attach the mismatched character to a variable
// offset. Advancing the cursor here would corrupt all following mappings.
if (options.dropUnmappedTargets) continue;
appendSegment(segments, { char: target });
}
}
@@ -362,7 +407,7 @@ const consumeMeasuredLineSegments = (
const absorbHiddenTrailingWhitespace = (
segments: SplitChunkSegment[],
resolvedChars: ResolvedPlainChar[],
resolvedChars: ResolvedChunkChar[],
cursor: number,
) => {
let nextCursor = cursor;
@@ -376,6 +421,7 @@ const absorbHiddenTrailingWhitespace = (
lastSegment &&
lastSegment.variableName === sourceChar.variableName &&
lastSegment.variableEnd === sourceChar.variableOffset &&
lastSegment.run === sourceChar.run &&
sourceChar.variableOffset !== undefined
) {
lastSegment.variableEnd = sourceChar.variableOffset + 1;
@@ -406,12 +452,13 @@ const isWhitespaceChar = (value: string) =>
const isHorizontalWhitespaceChar = (value: string) =>
value === ' ' || value === '\t' || value === '\f' || value === '\v';
const appendSegment = (segments: SplitChunkSegment[], sourceChar: ResolvedPlainChar) => {
const appendSegment = (segments: SplitChunkSegment[], sourceChar: ResolvedChunkChar) => {
const lastSegment = segments.at(-1);
if (
lastSegment &&
lastSegment.variableName === sourceChar.variableName &&
lastSegment.variableEnd === sourceChar.variableOffset
lastSegment.variableEnd === sourceChar.variableOffset &&
lastSegment.run === sourceChar.run
) {
lastSegment.text += sourceChar.char;
if (sourceChar.variableOffset !== undefined) {
@@ -426,6 +473,7 @@ const appendSegment = (segments: SplitChunkSegment[], sourceChar: ResolvedPlainC
variableStart: sourceChar.variableOffset,
variableEnd:
sourceChar.variableOffset === undefined ? undefined : sourceChar.variableOffset + 1,
run: sourceChar.run,
});
};
@@ -433,15 +481,20 @@ const appendRangedVariableSpan = (arg: {
textBlock: HTMLDivElement;
segment: SplitChunkSegment;
variables: Record<string, string>;
schema: MultiVariableTextSchema;
font: RenderFont;
theme: UIRenderProps<MultiVariableTextSchema>['theme'];
onChange: UIRenderProps<MultiVariableTextSchema>['onChange'];
stopEditing: UIRenderProps<MultiVariableTextSchema>['stopEditing'];
}) => {
const { textBlock, segment, variables, theme, onChange, stopEditing } = arg;
const { textBlock, segment, variables, schema, font, theme, onChange, stopEditing } = arg;
if (!segment.variableName) return;
const span = document.createElement('span');
span.style.outline = `${theme.colorPrimary} dashed 1px`;
if (segment.run) {
applyInlineMarkdownStyle({ element: span, run: segment.run, schema, font });
}
makeElementPlainTextContentEditable(span);
span.textContent = segment.text;
span.addEventListener('blur', (e: Event) => {