html2pptx-local-mcp 1.1.34 → 1.1.37

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.
@@ -618,6 +618,26 @@ export const DOCS_COPY = {
618
618
  ],
619
619
 
620
620
  apiEndpoints: [
621
+ {
622
+ method: 'POST',
623
+ path: '/api/v1/import/pptx',
624
+ title: 'Import PPTX to HTML',
625
+ description: 'Converts an existing .pptx into editable HTML slides (the reverse of export). Resolves theme colors (including clrMap/clrMapOvr inversion on dark slides), master text styles, and font families. Synchronous — returns the HTML in a single response. Also available at POST /api/import/pptx.',
626
+ requestTitle: 'Request Body',
627
+ requestFields: [
628
+ { field: 'file', type: 'file', required: false, default: '--', description: 'multipart/form-data: the .pptx file (max 25 MB). Use this OR pptxBase64.' },
629
+ { field: 'pptxBase64', type: 'string', required: false, default: '--', description: 'application/json: base64-encoded .pptx contents (data URLs accepted). Recommended for JSON-RPC / MCP clients.' },
630
+ { field: 'fileName', type: 'string', required: false, default: 'presentation.pptx', description: 'Optional original file name used for labeling.' },
631
+ ],
632
+ responseTitle: 'Response (200 OK)',
633
+ responseCode: '{\n "fileName": "deck.pptx",\n "plan": "api_starter",\n "slideCount": 13,\n "slides": [{ "index": 1, "html": "<section class=\\"slide\\">...</section>" }],\n "html": "<!doctype html>...",\n "css": "...",\n "warnings": []\n}',
634
+ errors: [
635
+ { code: '400', description: 'Missing/invalid file, invalid base64, or invalid OOXML.' },
636
+ { code: '401', description: 'Missing API key. Set the Authorization: Bearer or X-API-Key header.' },
637
+ { code: '403', description: 'API key is not mapped to a plan with API access.' },
638
+ { code: '413', description: 'PPTX exceeds the 25 MB size limit.' },
639
+ ],
640
+ },
621
641
  {
622
642
  method: 'POST',
623
643
  path: '/api/export/jobs',
@@ -987,6 +1007,7 @@ echo 'HTML2PPTX_API_KEY=sk_live_xxxx' >> .env`,
987
1007
  mcpTools: [
988
1008
  { tool: 'html2pptx_list_export_plans', description: 'List the current commercial plan catalog and recommended plan.' },
989
1009
  { tool: 'html2pptx_create_export_job', description: 'Create an export job from HTML/CSS content. Supports optional width, height, layout, waitForCompletion, timeoutMs, and responseFormat ("url" | "base64" | "both").' },
1010
+ { tool: 'html2pptx_import_pptx', description: 'Reverse of export: convert an existing .pptx into editable HTML slides. Pass the file as pptxBase64 (works on remote MCP); the local stdio MCP also accepts filePath. Resolves theme colors, master text styles, and fonts.' },
990
1011
  { tool: 'html2pptx_get_export_job', description: 'Get the current status of an export job by jobId.' },
991
1012
  { tool: 'html2pptx_wait_for_export_job', description: 'Poll until the job completes or fails. Handles retries and backoff inside the tool.' },
992
1013
  { tool: 'html2pptx_get_docs', description: 'Fetch html2pptx.app documentation to understand the API contract, HTML requirements, and integration guides.' },
@@ -1523,6 +1544,26 @@ echo 'HTML2PPTX_API_KEY=sk_live_xxxx' >> .env`,
1523
1544
  ],
1524
1545
 
1525
1546
  apiEndpoints: [
1547
+ {
1548
+ method: 'POST',
1549
+ path: '/api/v1/import/pptx',
1550
+ title: 'PPTXをHTMLに取り込み',
1551
+ description: '既存の .pptx を編集可能なHTMLスライドへ変換します(エクスポートの逆方向)。テーマ色(ダークスライドの clrMap/clrMapOvr 反転を含む)、マスターのテキストスタイル、フォントファミリーを解決します。同期処理で、1回のレスポンスでHTMLを返します。POST /api/import/pptx でも利用可能。',
1552
+ requestTitle: 'リクエストボディ',
1553
+ requestFields: [
1554
+ { field: 'file', type: 'file', required: false, default: '--', description: 'multipart/form-data: .pptx ファイル(最大25MB)。pptxBase64 とどちらか一方。' },
1555
+ { field: 'pptxBase64', type: 'string', required: false, default: '--', description: 'application/json: base64エンコードした .pptx(data URL も可)。JSON-RPC / MCP クライアント向けに推奨。' },
1556
+ { field: 'fileName', type: 'string', required: false, default: 'presentation.pptx', description: 'ラベル用の元ファイル名(任意)。' },
1557
+ ],
1558
+ responseTitle: 'レスポンス (200 OK)',
1559
+ responseCode: '{\n "fileName": "deck.pptx",\n "plan": "api_starter",\n "slideCount": 13,\n "slides": [{ "index": 1, "html": "<section class=\\"slide\\">...</section>" }],\n "html": "<!doctype html>...",\n "css": "...",\n "warnings": []\n}',
1560
+ errors: [
1561
+ { code: '400', description: 'ファイル欠如/不正、base64不正、または不正なOOXML。' },
1562
+ { code: '401', description: 'APIキー未指定。Authorization: Bearer または X-API-Key を設定してください。' },
1563
+ { code: '403', description: 'APIキーがAPIアクセス可能なプランに紐づいていません。' },
1564
+ { code: '413', description: 'PPTXが25MBの上限を超えています。' },
1565
+ ],
1566
+ },
1526
1567
  {
1527
1568
  method: 'POST',
1528
1569
  path: '/api/export/jobs',
@@ -1892,6 +1933,7 @@ echo 'HTML2PPTX_API_KEY=sk_live_xxxx' >> .env`,
1892
1933
  mcpTools: [
1893
1934
  { tool: 'html2pptx_list_export_plans', description: '現在の商用プラン一覧と推奨プランを取得。' },
1894
1935
  { tool: 'html2pptx_create_export_job', description: 'HTML/CSS からエクスポートジョブを作成。width、height、layout、waitForCompletion、timeoutMs、responseFormat ("url" | "base64" | "both") などのオプションにも対応。' },
1936
+ { tool: 'html2pptx_import_pptx', description: 'エクスポートの逆方向。既存の .pptx を編集可能なHTMLスライドへ変換。pptxBase64 でファイルを渡す(リモートMCP対応)。ローカルstdio MCP では filePath も可。テーマ色・マスターのテキストスタイル・フォントを解決。' },
1895
1937
  { tool: 'html2pptx_get_export_job', description: 'jobId を指定してジョブの現在状態を取得。' },
1896
1938
  { tool: 'html2pptx_wait_for_export_job', description: '完了または失敗までジョブを自動ポーリング。内部でリトライとバックオフを処理。' },
1897
1939
  { tool: 'html2pptx_get_docs', description: 'html2pptx.app のドキュメントを取得。API契約、HTML要件、統合ガイドを理解するために使用。' },
@@ -112,6 +112,36 @@ export const TOOL_DEFINITIONS = [
112
112
  openWorldHint: true,
113
113
  },
114
114
  },
115
+ {
116
+ name: 'html2pptx_import_pptx',
117
+ title: 'Import PPTX to HTML',
118
+ description:
119
+ 'Convert an existing .pptx file into editable HTML slides (the reverse of export). Resolves theme colors (incl. clrMap/clrMapOvr inversion), master text styles, and font families. Provide the file as base64 in pptxBase64 (recommended, works on remote MCP). The local stdio MCP also accepts filePath to read a .pptx from disk. Returns full-deck html, per-slide html, and shared css.',
120
+ inputSchema: {
121
+ type: 'object',
122
+ properties: {
123
+ pptxBase64: {
124
+ type: 'string',
125
+ description: 'Base64-encoded .pptx contents. Data URLs (data:...;base64,) are also accepted.',
126
+ },
127
+ filePath: {
128
+ type: 'string',
129
+ description: 'Local filesystem path to a .pptx file. Only honored by the local stdio MCP server.',
130
+ },
131
+ fileName: {
132
+ type: 'string',
133
+ description: 'Optional original file name for labeling. Defaults to presentation.pptx.',
134
+ },
135
+ },
136
+ additionalProperties: false,
137
+ },
138
+ annotations: {
139
+ readOnlyHint: true,
140
+ destructiveHint: false,
141
+ idempotentHint: true,
142
+ openWorldHint: true,
143
+ },
144
+ },
115
145
  {
116
146
  name: 'html2pptx_get_export_job',
117
147
  title: 'Get Export Job',
@@ -842,6 +872,17 @@ export async function executeTool(name, args, client, { sendNotification, progre
842
872
 
843
873
  return buildToolResponse(renderJobText(result), result);
844
874
  }
875
+ case 'html2pptx_import_pptx': {
876
+ if (typeof client.importPptx !== 'function') {
877
+ throw new Error('html2pptx_import_pptx is not available on this server.');
878
+ }
879
+ const data = await client.importPptx({
880
+ pptxBase64: typeof args.pptxBase64 === 'string' ? args.pptxBase64 : undefined,
881
+ filePath: typeof args.filePath === 'string' ? args.filePath : undefined,
882
+ fileName: typeof args.fileName === 'string' ? args.fileName : undefined,
883
+ });
884
+ return buildToolResponse(renderImportText(data), data);
885
+ }
845
886
  case 'html2pptx_get_export_job': {
846
887
  ensureNonEmptyString(args.jobId, 'jobId');
847
888
  const responseFormat = resolveResponseFormat(args);
@@ -1423,6 +1464,19 @@ function renderUsageText(data) {
1423
1464
  return lines.join('\n');
1424
1465
  }
1425
1466
 
1467
+ function renderImportText(data) {
1468
+ const lines = [];
1469
+ const name = data?.fileName || 'presentation.pptx';
1470
+ lines.push(`Imported ${name}: ${data?.slideCount ?? 0} slide(s) converted to HTML.`);
1471
+ if (Array.isArray(data?.warnings) && data.warnings.length) {
1472
+ lines.push(`Warnings: ${data.warnings.length}`);
1473
+ for (const w of data.warnings.slice(0, 5)) lines.push(` - ${w}`);
1474
+ }
1475
+ const htmlLen = typeof data?.html === 'string' ? data.html.length : 0;
1476
+ lines.push(`HTML: ${htmlLen} bytes (full deck). Per-slide HTML available in slides[].html; shared styles in css.`);
1477
+ return lines.join('\n');
1478
+ }
1479
+
1426
1480
  function renderJobText(job) {
1427
1481
  const lines = [];
1428
1482
  lines.push(`Job ${job.jobId || 'unknown'} is ${job.status || 'unknown'}.`);
@@ -1895,6 +1895,7 @@ export default function EditSlideClient() {
1895
1895
  const [inspectorTab, setInspectorTab] = useState('design');
1896
1896
  const [selection, setSelection] = useState(null); // { selector, element }
1897
1897
  const [expandedLayers, setExpandedLayers] = useState(() => new Set(['__root__']));
1898
+ const [leftTab, setLeftTab] = useState('slides');
1898
1899
  const [filePath, setFilePath] = useState(null); // server-side relative path
1899
1900
  const [assetScope, setAssetScope] = useState('project'); // project | global
1900
1901
  const [saveStatus, setSaveStatus] = useState('idle'); // idle | saving | saved | error
@@ -3956,41 +3957,60 @@ ${slidesArg.join('\n\n')}
3956
3957
  <div className="cv-main">
3957
3958
  {/* ---------------- left panel (pages + layers) ---------------- */}
3958
3959
  <aside className="cv-leftpanel">
3959
- <div className="cv-lp-section cv-lp-section--slides">
3960
- <div className="cv-lp-head">
3961
- <span>{text.slides}</span>
3960
+ <div className="cv-lp-tabs" role="tablist">
3961
+ <button
3962
+ type="button"
3963
+ role="tab"
3964
+ aria-selected={leftTab === 'slides'}
3965
+ className={'cv-lp-tab' + (leftTab === 'slides' ? ' is-active' : '')}
3966
+ onClick={() => setLeftTab('slides')}
3967
+ >
3968
+ {text.slides}
3969
+ </button>
3970
+ <button
3971
+ type="button"
3972
+ role="tab"
3973
+ aria-selected={leftTab === 'layers'}
3974
+ className={'cv-lp-tab' + (leftTab === 'layers' ? ' is-active' : '')}
3975
+ onClick={() => setLeftTab('layers')}
3976
+ >
3977
+ {text.layers}
3978
+ </button>
3979
+ </div>
3980
+ {leftTab === 'slides' ? (
3981
+ <div className="cv-lp-section cv-lp-section--slides">
3982
+ <div className="cv-lp-pages">
3983
+ {slides.map((_, i) => (
3984
+ <button
3985
+ key={i}
3986
+ type="button"
3987
+ className={'cv-lp-page' + (i === active ? ' is-active' : '')}
3988
+ onClick={() => enterFocus(i)}
3989
+ >
3990
+ <span className="cv-lp-page__num">{i + 1}</span>
3991
+ <span className="cv-lp-page__thumb">
3992
+ {thumbs[i]
3993
+ ? <img src={thumbs[i]} alt="" draggable={false} />
3994
+ : <span className="cv-lp-page__ph" />}
3995
+ </span>
3996
+ </button>
3997
+ ))}
3998
+ </div>
3962
3999
  </div>
3963
- <div className="cv-lp-pages">
3964
- {slides.map((_, i) => (
3965
- <button
3966
- key={i}
3967
- type="button"
3968
- className={'cv-lp-page' + (i === active ? ' is-active' : '')}
3969
- onClick={() => enterFocus(i)}
3970
- >
3971
- <span className="cv-lp-page__num">{i + 1}</span>
3972
- <span className="cv-lp-page__thumb">
3973
- {thumbs[i]
3974
- ? <img src={thumbs[i]} alt="" draggable={false} />
3975
- : <span className="cv-lp-page__ph" />}
3976
- </span>
3977
- </button>
3978
- ))}
4000
+ ) : (
4001
+ <div className="cv-lp-section cv-lp-section--layers">
4002
+ <LayerTree
4003
+ tree={layerTree}
4004
+ expandedKeys={expandedLayers}
4005
+ selectedKey={selectedLayerKey}
4006
+ onToggle={toggleLayer}
4007
+ onSelect={selectLayer}
4008
+ onPreview={previewLayer}
4009
+ onPreviewEnd={clearLayerPreview}
4010
+ labels={text.layer}
4011
+ />
3979
4012
  </div>
3980
- </div>
3981
- <div className="cv-lp-section cv-lp-section--layers">
3982
- <div className="cv-lp-head"><span>{text.layers}</span></div>
3983
- <LayerTree
3984
- tree={layerTree}
3985
- expandedKeys={expandedLayers}
3986
- selectedKey={selectedLayerKey}
3987
- onToggle={toggleLayer}
3988
- onSelect={selectLayer}
3989
- onPreview={previewLayer}
3990
- onPreviewEnd={clearLayerPreview}
3991
- labels={text.layer}
3992
- />
3993
- </div>
4013
+ )}
3994
4014
  </aside>
3995
4015
 
3996
4016
  {/* ---------------- canvas viewport ---------------- */}
@@ -4400,11 +4420,16 @@ ${slidesArg.join('\n\n')}
4400
4420
  .cv-main { display: flex; min-height: 0; min-width: 0; }
4401
4421
  .cv-leftpanel { width: 280px; flex: 0 0 280px; display: flex; flex-direction: column; background: #ffffff; border-right: 1px solid #e6e6e6; overflow: hidden; }
4402
4422
  .cv-lp-section { display: flex; flex-direction: column; border-bottom: 1px solid #ececec; }
4423
+ .cv-lp-section--slides { flex: 1 1 auto; min-height: 0; overflow: hidden; }
4424
+ .cv-lp-tabs { display: flex; gap: 2px; padding: 8px 8px 0; border-bottom: 1px solid #ececec; }
4425
+ .cv-lp-tab { flex: 1; height: 30px; font-size: 11px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; cursor: pointer; border: none; background: transparent; color: #8a8a8a; border-bottom: 2px solid transparent; }
4426
+ .cv-lp-tab:hover { color: #555; }
4427
+ .cv-lp-tab.is-active { color: #111; border-bottom-color: #0D99FF; }
4403
4428
  .cv-lp-section--layers { flex: 1 1 auto; min-height: 0; overflow: hidden; }
4404
4429
  .cv-lp-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px 6px; font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: #8a8a8a; }
4405
4430
  .cv-lp-add { width: 22px; height: 22px; display: grid; place-items: center; border: none; border-radius: 6px; background: transparent; color: #8a8a8a; cursor: pointer; }
4406
4431
  .cv-lp-add:hover { background: #f0f0f0; color: #1a1a1a; }
4407
- .cv-lp-pages { display: flex; flex-direction: column; gap: 8px; padding: 8px 10px 10px; max-height: 48vh; overflow-y: auto; }
4432
+ .cv-lp-pages { display: flex; flex-direction: column; gap: 8px; padding: 8px 10px 10px; flex: 1 1 auto; min-height: 0; overflow-y: auto; }
4408
4433
  .cv-lp-page { display: flex; align-items: center; gap: 8px; padding: 0; border: none; background: transparent; cursor: pointer; width: 100%; }
4409
4434
  .cv-lp-page__num { flex: 0 0 auto; width: 14px; text-align: right; font-size: 10px; color: #9a9a9a; font-variant-numeric: tabular-nums; }
4410
4435
  .cv-lp-page.is-active .cv-lp-page__num { color: #0a0a0a; font-weight: 700; }
@@ -12,6 +12,7 @@ import {
12
12
  normalizeEditPanelInputValue,
13
13
  normalizeStyleValue,
14
14
  } from '../../lib/edit-panel-value-normalizer';
15
+ import { keepTextEditable } from '../../lib/edit-slide-editor-helpers';
15
16
 
16
17
  const TEXT_LEAF_TAGS = new Set([
17
18
  'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
@@ -265,7 +266,7 @@ export default function EditPropertyPanel({
265
266
 
266
267
  const applyText = (value) => {
267
268
  element.textContent = value;
268
- setTextEdit(readTextEdit(element));
269
+ setTextEdit((prev) => keepTextEditable(readTextEdit(element), prev));
269
270
  onChange?.();
270
271
  };
271
272
 
@@ -273,7 +274,7 @@ export default function EditPropertyPanel({
273
274
  const run = textEdit.runs[index];
274
275
  if (!run?.node) return;
275
276
  run.node.nodeValue = value;
276
- setTextEdit(readTextEdit(element));
277
+ setTextEdit((prev) => keepTextEditable(readTextEdit(element), prev));
277
278
  onChange?.();
278
279
  };
279
280
 
@@ -686,36 +687,6 @@ function ImageAssetSection({ element, ui, assetTools, onChange }) {
686
687
  </div>
687
688
  ) : null}
688
689
 
689
- <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
690
- <span style={{ fontSize: 11, color: '#9a9a9a' }}>{ui.saveTo}</span>
691
- {[
692
- ['project', ui.assetScopeProject],
693
- ['global', ui.assetScopeGlobal],
694
- ].map(([value, label]) => (
695
- <button
696
- key={value}
697
- type="button"
698
- onClick={() => assetTools.setScope?.(value)}
699
- style={{
700
- flex: 1,
701
- height: 26,
702
- fontSize: 11.5,
703
- fontWeight: 600,
704
- cursor: 'pointer',
705
- borderRadius: 6,
706
- border: '1px solid ' + (scope === value ? '#0D99FF' : '#e0e0e0'),
707
- background: scope === value ? 'rgba(13,153,255,0.12)' : '#fff',
708
- color: scope === value ? '#0D78CC' : '#666',
709
- }}
710
- >
711
- {label}
712
- </button>
713
- ))}
714
- </div>
715
-
716
- <button type="button" className="ppt-lumina-add" disabled={busy} onClick={() => fileInputRef.current?.click()}>
717
- {busy ? ui.uploading : isImg ? ui.replaceImage : isVideo ? ui.replaceVideo : ui.insertImage}
718
- </button>
719
690
  <input
720
691
  ref={fileInputRef}
721
692
  type="file"
@@ -731,67 +702,94 @@ function ImageAssetSection({ element, ui, assetTools, onChange }) {
731
702
  <p style={{ margin: '6px 0 0', fontSize: 11, color: '#d23' }}>{error}</p>
732
703
  ) : null}
733
704
 
734
- <div style={{ marginTop: 12 }}>
705
+ <div style={{ marginTop: 4 }}>
735
706
  <h4 style={{ margin: '0 0 6px', fontSize: 11, fontWeight: 600, color: '#9a9a9a' }}>{ui.savedAssets}</h4>
736
- {assets.length === 0 ? (
737
- <p style={{ margin: 0, fontSize: 11.5, color: '#b0b0b0' }}>{ui.noSavedAssets}</p>
738
- ) : (
739
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 6 }}>
740
- {assets.map((asset) => {
741
- const kind = asset.kind || assetKindFromName(asset.src || asset.name);
742
- const preview = assetTools.resolveAssetUrl?.(asset.src) || '';
743
- const payload = JSON.stringify({ src: asset.src, name: asset.name, kind });
744
- return (
745
- <div key={asset.src} style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
746
- <button
747
- type="button"
748
- title={`${asset.name} - ${ui.dragAssetHint}`}
749
- draggable
750
- onDragStart={(event) => {
751
- event.dataTransfer.effectAllowed = 'copy';
752
- event.dataTransfer.setData('application/x-edit-slide-asset', payload);
753
- event.dataTransfer.setData('text/plain', asset.src);
754
- }}
755
- onClick={() => applyAssetSrc(asset.src, kind)}
756
- style={{
757
- padding: 0,
758
- aspectRatio: '1 / 1',
759
- cursor: 'grab',
760
- borderRadius: 6,
761
- border: '1px solid #e6e6e6',
762
- background: '#fafafa',
763
- overflow: 'hidden',
764
- }}
765
- >
766
- {kind === 'video' ? (
767
- <video src={preview} muted playsInline style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
768
- ) : (
769
- <img src={preview} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
770
- )}
771
- </button>
707
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 6 }}>
708
+ <button
709
+ type="button"
710
+ disabled={busy}
711
+ onClick={() => fileInputRef.current?.click()}
712
+ title={ui.insertImage}
713
+ style={{
714
+ aspectRatio: '1 / 1',
715
+ display: 'flex',
716
+ alignItems: 'center',
717
+ justifyContent: 'center',
718
+ cursor: busy ? 'wait' : 'pointer',
719
+ borderRadius: 6,
720
+ border: '1px dashed #c8c8c8',
721
+ background: '#fafafa',
722
+ color: '#9a9a9a',
723
+ fontSize: 22,
724
+ lineHeight: 1,
725
+ }}
726
+ >
727
+ {busy ? '…' : '+'}
728
+ </button>
729
+ {assets.map((asset) => {
730
+ const kind = asset.kind || assetKindFromName(asset.src || asset.name);
731
+ const preview = assetTools.resolveAssetUrl?.(asset.src) || '';
732
+ const payload = JSON.stringify({ src: asset.src, name: asset.name, kind });
733
+ return (
734
+ <div key={asset.src} style={{ position: 'relative', minWidth: 0 }}>
735
+ <button
736
+ type="button"
737
+ title={`${asset.name} - ${ui.dragAssetHint}`}
738
+ draggable
739
+ onDragStart={(event) => {
740
+ event.dataTransfer.effectAllowed = 'copy';
741
+ event.dataTransfer.setData('application/x-edit-slide-asset', payload);
742
+ event.dataTransfer.setData('text/plain', asset.src);
743
+ }}
744
+ onClick={() => applyAssetSrc(asset.src, kind)}
745
+ style={{
746
+ padding: 0,
747
+ width: '100%',
748
+ aspectRatio: '1 / 1',
749
+ cursor: 'grab',
750
+ borderRadius: 6,
751
+ border: '1px solid #e6e6e6',
752
+ background: '#fafafa',
753
+ overflow: 'hidden',
754
+ display: 'block',
755
+ }}
756
+ >
757
+ {kind === 'video' ? (
758
+ <video src={preview} muted playsInline style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
759
+ ) : (
760
+ <img src={preview} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
761
+ )}
762
+ </button>
763
+ {kind === 'image' ? (
772
764
  <button
773
765
  type="button"
774
766
  onClick={() => applyAssetBackground(asset.src, kind)}
775
- disabled={kind !== 'image'}
776
- title={kind === 'image' ? ui.applyBackground : 'Video cannot be used as a CSS background'}
767
+ title={ui.applyBackground}
777
768
  style={{
778
- border: '1px solid #e6e6e6',
779
- borderRadius: 5,
780
- background: '#fff',
781
- color: kind === 'image' ? '#444' : '#aaa',
782
- cursor: kind === 'image' ? 'pointer' : 'not-allowed',
783
- fontSize: 9,
784
- padding: '3px 2px',
785
- lineHeight: 1.1,
769
+ position: 'absolute',
770
+ right: 3,
771
+ bottom: 3,
772
+ padding: '1px 4px',
773
+ fontSize: 8,
774
+ fontWeight: 700,
775
+ lineHeight: 1.2,
776
+ color: '#fff',
777
+ background: 'rgba(0,0,0,0.55)',
778
+ border: 'none',
779
+ borderRadius: 4,
780
+ cursor: 'pointer',
786
781
  }}
787
782
  >
788
- {ui.applyBackground}
783
+ BG
789
784
  </button>
790
- </div>
791
- );
792
- })}
793
- </div>
794
- )}
785
+ ) : null}
786
+ </div>
787
+ );
788
+ })}
789
+ </div>
790
+ {assets.length === 0 ? (
791
+ <p style={{ margin: '6px 0 0', fontSize: 11, color: '#b0b0b0' }}>{ui.noSavedAssets}</p>
792
+ ) : null}
795
793
  </div>
796
794
  </>
797
795
  )}
@@ -68,6 +68,18 @@ export function restorePreviewAssetUrls(root) {
68
68
  root.querySelectorAll?.('img[src], video[src], source[src], [style]').forEach(restoreElementAssetUrls);
69
69
  }
70
70
 
71
+ /**
72
+ * Clearing all text makes an element stop qualifying as a text element, so the
73
+ * panel's readTextEdit() returns mode 'none' and the Content editor unmounts —
74
+ * the now-empty element collapses and clicks fall through to a parent. While the
75
+ * user is actively editing, keep a plain (empty) editor so the selected element
76
+ * stays editable and selected.
77
+ */
78
+ export function keepTextEditable(next, prev) {
79
+ if (next.mode !== 'none' || prev.mode === 'none') return next;
80
+ return { mode: 'plain', value: '', runs: [] };
81
+ }
82
+
71
83
  export function isEditorTypingTarget(target) {
72
84
  const element = target?.nodeType === 1 ? target : target?.parentElement;
73
85
  if (!element) return false;
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import process from 'node:process';
4
+ import { readFile } from 'node:fs/promises';
5
+ import { basename } from 'node:path';
4
6
  import {
5
7
  DEFAULT_PROTOCOL,
6
8
  SERVER_INFO,
@@ -125,6 +127,24 @@ async function handleMessage(message) {
125
127
  // or WorkOS JWT) so anonymous curl/browser can't scrape prompts.
126
128
  requireApiKey: true,
127
129
  }),
130
+ importPptx: async ({ pptxBase64, filePath, fileName } = {}) => {
131
+ let base64 = pptxBase64;
132
+ let name = fileName;
133
+ // The local stdio server can read a .pptx straight off disk.
134
+ if (!base64 && filePath) {
135
+ const data = await readFile(filePath);
136
+ base64 = data.toString('base64');
137
+ if (!name) name = basename(filePath);
138
+ }
139
+ if (!base64) {
140
+ throw new Error('html2pptx_import_pptx requires pptxBase64 or filePath.');
141
+ }
142
+ return requestJson('/api/v1/import/pptx', {
143
+ method: 'POST',
144
+ requireApiKey: true,
145
+ body: { pptxBase64: base64, fileName: name },
146
+ });
147
+ },
128
148
  openLocalSlideEditor: async (args) => localSlideEditorManager.open(args),
129
149
  stopLocalSlideEditor: async (sessionId) => localSlideEditorManager.stop(sessionId),
130
150
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "html2pptx-local-mcp",
3
- "version": "1.1.34",
3
+ "version": "1.1.37",
4
4
  "type": "module",
5
5
  "description": "Local stdio MCP server for opening html2pptx slide HTML in the local edit-slide editor.",
6
6
  "bin": {