diff --git a/PLAN.md b/PLAN.md index 0be9e7aa..01a5e8d4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 をどう出すか。 diff --git a/packages/schemas/__tests__/multiVariableText.ui.test.ts b/packages/schemas/__tests__/multiVariableText.ui.test.ts index 60fb0df2..32e6ca4e 100644 --- a/packages/schemas/__tests__/multiVariableText.ui.test.ts +++ b/packages/schemas/__tests__/multiVariableText.ui.test.ts @@ -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[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[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[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[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' }), + }); }); }); diff --git a/packages/schemas/src/multiVariableText/uiRender.ts b/packages/schemas/src/multiVariableText/uiRender.ts index 49e07651..8ca68f59 100644 --- a/packages/schemas/src/multiVariableText/uiRender.ts +++ b/packages/schemas/src/multiVariableText/uiRender.ts @@ -30,17 +30,6 @@ export const uiRender = async (arg: UIRenderProps) => { : 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) => { _cache as Map, ); + // 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) => { 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['options']['font']>; + +const renderSplitVariableSpans = (arg: { textBlock: HTMLDivElement; lines: string[]; + runs?: RichTextRun[]; rawText: string; variables: Record; schema: MultiVariableTextSchema; + font: RenderFont; theme: UIRenderProps['theme']; onChange: UIRenderProps['onChange']; stopEditing: UIRenderProps['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; 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, -): 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, +): 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; + schema: MultiVariableTextSchema; + font: RenderFont; theme: UIRenderProps['theme']; onChange: UIRenderProps['onChange']; stopEditing: UIRenderProps['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) => {