v1.56.0: Layout tab performance fix, text corner-handle resize direction, draggable text position inside balloons
- Fix slow object operations / unresponsive layer panel: 3D pose images no longer embed the VRM model (data-pose3d-model, ~56MB each); existing ones are stripped on page open and on save. Images/balloons/groups are saved only when actually moved. - Text corner handles now keep the opposite corner fixed and scale in the drag direction. - Balloon-embedded text can be moved by dragging the triangular corner of a dotted box (double-click opens the existing modal). - Help (ja/en/zh), README (ja/en/zh) and DEVLOG updated. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
f604770c30
commit
baedada7f5
@@ -2,6 +2,28 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-17(レイアウトタブの操作遅延の解消、テキスト角ハンドルの拡縮方向、フキダシ内テキストの位置ドラッグ、v1.56.0)
|
||||
|
||||
**1. レイアウトタブでオブジェクトの操作が遅い・レイヤーパネルの選択が効かない(修正)**
|
||||
- 原因: 3Dポーズ画像の要素に `data-pose3d-model`(VRMモデルのbase64、1枚あたり約56MB)が埋め込まれており、実機のページでSVG全体が約114MBになっていた。クリックしただけでも `mouseup` で `savePanelSvg`(SVG全体の複製・直列化・IndexedDB保存)が走るため、次の操作が詰まり、保存完了時の `renderLayerPanel()` でクリック中のレイヤー行が差し替わって `click` が届かなかった。
|
||||
- 実装: `23-pose3d-bridge.js` でモデルを埋め込まないようにした(読み出し側は元々無く、再編集はポーズJSON `data-pose3d-pose` のみで足りる)。`renderLayoutTab` でページを開く際に既存の `data-pose3d-model` を正規表現で除去してDBへ書き戻し(`_stripPose3dModelFromPage`)、`savePanelSvg` でも保存時に除去する。実機でSVGが約114MB→約1.1MBに低下。
|
||||
- 画像・フキダシ・グループのドラッグ処理(`08-panels-images.js`、`06a-polygon-geometry.js`)は、実際に動かした場合だけ保存する(クリック選択のみでは保存しない)。
|
||||
- 未対応: テキスト・描画図形のドラッグにも同種の構造が残っている可能性がある(未調査)。
|
||||
|
||||
**2. テキストの角ハンドルのリサイズ方向(変更)**
|
||||
- 従来は中心からの距離比で拡縮していたため、どの角をドラッグしても同じ動きだった。反対側の角を固定し、固定角→ドラッグ角の対角線上へカーソルを射影した比でフォントサイズを決める方式に変更(`09e-text-tool.js`)。拡縮でずれた固定角はテキストごと平行移動して戻す。回転・変形したテキストでも変形後の角座標(`_textLocalToSvg`)で扱う。辺ハンドルの縦横伸縮・回転は従来どおり。
|
||||
|
||||
**3. フキダシ内テキストの位置をドラッグで移動(新機能)**
|
||||
- テキストを内包したフキダシの選択時、テキストの周りに紫の点線ボックスを表示し、左下の三角コーナーのドラッグでテキストだけを動かせる。ダブルクリック(三角コーナー・フキダシ)で従来のモーダル。
|
||||
- 位置は `data-text-offset-x/y`(フキダシのローカル座標での寄せ位置からの追加移動量)に保存し、`_bubbleTextRenderText` で全tspanへ加算する。回転したフキダシでも向きに沿って動く。モーダルで上下寄せ・文字寄せを変えるとオフセットは解除(寄せ位置を優先)。
|
||||
- 点線ボックス(`.balloon-textbox`)と三角コーナー(`.balloon-textbox-mark`)は編集用の表示で、保存用クローン(`savePanelSvg` / `saveOverlaySvg`)から除外する。実装は `09c-balloon-handles.js`(`updateBubbleTextBox`)、ドラッグ処理は `09d-balloon-tools.js`。動かしたときだけ履歴・保存を行う。
|
||||
|
||||
**How to apply**
|
||||
- 保存が重いと感じたら、まず `svg.outerHTML.length` と、巨大な属性(`data-*`)を持つ要素がないかを疑う。
|
||||
- 新たに要素へ大きなデータ属性を埋め込む機能を作る場合は、SVGへ永続化する前に別ストアへ逃がすこと。
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-14(Nanobanana: 生成方式を設定タブで切替し、半自動マンガ・Autoタブにも適用、v1.55.0)
|
||||
|
||||
v1.54.0 で Nanobanana タブにだけ追加した「ComfyUIネイティブノード」方式を、共通の設定として設定タブで切り替えられるようにし、半自動マンガ(スクリプト)と Auto タブの Nanobanana 生成にも適用した。3か所とも `requestNanobananaGenerate(payload)`(base64画像の配列を返す共通の入口)を通るため、呼び出し側はほぼ無改修。
|
||||
|
||||
@@ -28,9 +28,9 @@ ComfyUI 上で動作するマンガページ作成 SPA(シングルページ
|
||||
- **サブコマ** — コマの中に矩形・丸のサブコマ(別カットの画像挿入先)をドラッグで追加。ハンドルで移動・リサイズ・回転でき、親コマの外へはみ出した部分は通常のオブジェクトと同じく自動でクロップ。枠線の太さはサブコマごとに個別設定可能。レイヤーパネルの「サブコマ操作」チェックボックスで、中のオブジェクトより優先してサブコマ自体を選択・移動するモードに切替できる。レイヤーパネル下部の複製・移動ボタンでサブコマ自体を他のコマやオーバーレイへ複製・移動(親コマの付け替え)することも可能
|
||||
- **コマ分割** — 「分割」サブタブでコマ分割を ON にし、プレビュー上でコマ内をドラッグすると、その 1 コマだけを任意の角度の直線で 2 つに分割できる(水平・垂直に限らない。テンプレート作成ウィザードの単一コマ分割と同じアルゴリズムを流用)。**コマ間の幅**は、コマ分割を ON にした時点でそのページの既存コマ同士の隙間から自動検出され初期値に反映(0 で隙間なく分割)。画像・フキダシ・テキスト等の既存オブジェクトは、分割後の新コマ範囲に完全に収まる場合だけ新コマへ移動し、分割線をまたぐ・元コマ側に収まる要素はそのまま残る。新コマは元コマの直後(番号+1)に追加され、以降のコマ番号が繰り下がる。ロック中のコマ・サブコマは分割対象外
|
||||
- **コマ変形・コマ削除** — 「変形」サブタブで、選択中のコマ 1 つを破壊的に変形(コマ間の幅・外側の幅は無視)。頂点のドラッグ移動・頂点の削除(四角形→三角形。三角形は不可)・辺のドラッグによる平行移動(辺の向きを保ち、両端は隣の辺の延長線上を滑る)が可能。「外周ロック」(既定 ON)でレイアウト全体の外周上の頂点を保護でき、ロック中のコマは変形・削除できない。レイヤーパネルのコマ行の ✕ ボタン(または「選択コマを削除」)でコマ削除(中の画像・フキダシ・サブコマも一緒に削除、Undo 可)
|
||||
- **フキダシ** — 楕円・角丸矩形・思考・バクダン・雲(もこもこ/なみなみ)の形状をコマ内に配置。8 点リサイズハンドル対応。**尻尾なし**チェックボックスで尻尾の長さを0にして非表示にできる(OFFに戻すと直前の長さに復元)。バクダン形状は**内側カーブ**スライダーでトゲの先端を尖らせたまま谷と側面を丸められる。**テキストを内包**ボタンで、いずれの形状にもテキストを自動折返しで内包(縦書き対応、行間スライダー、文字色、太字、上下寄せとは独立した文字寄せの配置調整、Google/システム/カテゴリのフォント選択、既存の内包テキストはダブルクリックで再編集)。アセットのカスタムSVGフキダシも配置後に塗り色・枠色を変更可能
|
||||
- **フキダシ** — 楕円・角丸矩形・思考・バクダン・雲(もこもこ/なみなみ)の形状をコマ内に配置。8 点リサイズハンドル対応。**尻尾なし**チェックボックスで尻尾の長さを0にして非表示にできる(OFFに戻すと直前の長さに復元)。バクダン形状は**内側カーブ**スライダーでトゲの先端を尖らせたまま谷と側面を丸められる。**テキストを内包**ボタンで、いずれの形状にもテキストを自動折返しで内包(縦書き対応、行間スライダー、文字色、太字、上下寄せとは独立した文字寄せの配置調整、Google/システム/カテゴリのフォント選択、既存の内包テキストはダブルクリックで再編集)。アセットのカスタムSVGフキダシも配置後に塗り色・枠色を変更可能 内包テキストは、選択時に表示される点線ボックスの**左下の三角コーナー**をドラッグして自由な位置へ動かせる(ダブルクリックで従来のモーダル)。
|
||||
- **延長フキダシ** — Comic Life の「延長フキダシ」を参考にした機能。**延長を追加**ボタンで、選択中のフキダシと同じ形状・色のフキダシをネック(連結部分)でつないで追加でき、長いセリフを複数のフキダシに分けて配置できる。延長は個別に位置・大きさ・回転・内包テキストを編集可能。ベースを動かすと延長も一緒に平行移動(ベースのリサイズ・回転は延長の位置に影響しない)、延長を動かすとネックだけが伸縮。尻尾をベースから延長側へドラッグすると連結全体の外周まで届かせられる。フキダシ同士が重なっても内部に線は出ず、連結全体の外周だけが表示される
|
||||
- **テキスト** — 縦書き・横書き、Google Fonts / システムフォント、塗り・線・袋文字・影・行間・上下位置・文字寄せを設定できるスタイルモーダル。プレビュー文字欄は複数行対応、サイズ下の行間スライダーとフキダシ内包テキストと同じボタン形式の上下位置(上/中央/下)・文字寄せ(左/中央/右)で調整(縦書き時は上下位置=列内の縦位置、文字寄せ=列群の左右位置)。行間・上下位置・文字寄せはレイアウト/Image タブ両方の実テキストに反映される。塗りは単色に加えグラデーション・テクスチャ(位置 X/Y 指定可)・塗りなしに対応(レイアウト/Image タブ共通) 辺中央の青いハンドルのドラッグで縦長・横長に伸縮、Shift+ドラッグで辺に沿ってせん断して平行四辺形に変形でき、「変形リセット」で元に戻せる
|
||||
- **テキスト** — 縦書き・横書き、Google Fonts / システムフォント、塗り・線・袋文字・影・行間・上下位置・文字寄せを設定できるスタイルモーダル。プレビュー文字欄は複数行対応、サイズ下の行間スライダーとフキダシ内包テキストと同じボタン形式の上下位置(上/中央/下)・文字寄せ(左/中央/右)で調整(縦書き時は上下位置=列内の縦位置、文字寄せ=列群の左右位置)。行間・上下位置・文字寄せはレイアウト/Image タブ両方の実テキストに反映される。塗りは単色に加えグラデーション・テクスチャ(位置 X/Y 指定可)・塗りなしに対応(レイアウト/Image タブ共通) 辺中央の青いハンドルのドラッグで縦長・横長に伸縮、Shift+ドラッグで辺に沿ってせん断して平行四辺形に変形でき、「変形リセット」で元に戻せる 角ハンドルは反対側の角を固定し、ドラッグした方向に拡大・縮小する。
|
||||
- **形状描画(ドロー)** — 矩形・楕円・直線・曲線・多角形・ベクター曲線・鎖・ロープ・My 曲線を SVG レイヤーに直接描画。多角形はクリックで頂点追加+始点付近クリックで確定、ベクター曲線はクリックでノード追加してなめらかな曲線(スプライン)で結び、始点付近クリックで閉じたシェイプ、Enter キーで開いた線として確定。塗り(矩形・楕円・多角形・ベクター曲線等)は単色に加えグラデーション・テクスチャ(位置 X/Y 指定可、シェイプの移動・リサイズに追従)・塗りなしに対応。図形の PNG 変換もこれらの塗りをそのまま反映
|
||||
- **ペイントツール** — 形状描画(ドロー)とは別系統のラスターブラシ機能。「ペイントを追加」ボタンで選択中のコマ/オーバーレイ/下書きレイヤーのサイズにぴったり合わせた画像を自動生成・選択し、そのまま描画 ON でブラシ描画を開始できる(左隣の「背景色」チェックボックスをONにすると指定色の不透明画像として作成でき、透過のままI2Iへ送ると背景が黒くなる問題を回避可能)。ブラシは新規ペイントオブジェクトだけでなく選択中の通常画像にも直接描き込める。ブラシ色・不透明度・サイズ(スライダー)に加え、x5(ブラシサイズ 5 倍)・消しゴムの ON/OFF をボタンの色で明示するトグルに対応。**ペンタブレットでは筆圧に応じてブラシサイズ・不透明度が自動的に変化**(マウス操作時は従来通り一定)。レイヤーパネルには 🖌 アイコン・「ペイント」の名前で表示され通常画像と区別できる。「選択画像を統合」ボタンでレイヤーパネルのチェックボックスで選んだ画像・ペイントレイヤー(2枚以上)を重ね順・回転を保ったまま1枚のPNGへ合成でき、I2Iに複数レイヤーをまとめて送りたい場合に活用できる。Image タブでの下描き・I2I 参照画像の作成などに活用可能
|
||||
- **マスクツール** — コマ/オーバーレイ/個別オブジェクト(画像・フキダシ・テキスト・図形・グループ)単位に、隠す/表示の2種類のマスクレイヤーを重ねて追加・合成できる。ブラシ(塗る/消す)・サイズ・硬さを調整して塗り、非表示部分を半透明の赤でプレビュー表示可能。反転・クリア・全面塗りの一括操作、レイヤーごとの一時無効化にも対応。**ペンタブレットでは筆圧に応じてブラシサイズが自動的に変化**(マウス操作時は従来通り一定)
|
||||
|
||||
+2
-2
@@ -28,9 +28,9 @@ A manga page creation SPA (single-page application) that runs on top of ComfyUI.
|
||||
- **Sub-panels** — Drag to add a rectangular or circular sub-panel inside a panel as a nested image target (e.g. an inset cut). Move, resize, and rotate with handles; parts that extend outside the parent panel are automatically cropped, just like any other object. Border thickness can be set per sub-panel. A "Move sub-panel" checkbox in the layer panel toggles whether clicking inside selects the sub-panel itself instead of the objects inside it. The layer panel's duplicate/move buttons also work on the sub-panel itself, including moving/duplicating it to another panel or to the overlay (re-parenting)
|
||||
- **Panel split** — In the "Split" sub-tab, turn panel split ON and drag across a panel to split just that panel in two along a line at any angle. The gap between the two panels is auto-detected from the existing panels on the page. Existing objects that fully fit inside the new panel move with it; locked panels and sub-panels cannot be split
|
||||
- **Panel deform / delete** — In the "Deform" sub-tab, destructively reshape the selected panel (gaps between panels and the outer margin are ignored): drag vertices, delete a vertex (quad → triangle; triangles cannot be reduced), or drag an edge to translate it in parallel (keeping its direction; the end vertices slide along the neighboring edges). "Lock outer edge" (ON by default) protects vertices on the outer boundary of the layout, and locked panels cannot be deformed or deleted. Delete a panel with the ✕ button on its layer-panel row or "Delete panel" (its images, balloons and sub-panels are deleted too; Undo supported)
|
||||
- **Speech balloons** — Place oval, rounded-rectangle, thought, burst, and cloud (puffy/wavy) shaped balloons inside panels, with 8-point resize handles. The **No tail** checkbox sets the tail length to 0 to hide it (unchecking restores the previous length). The burst shape has an **inner curve** slider that rounds the notch and flank between spikes while keeping the tips sharp. The **Embed Text** button lets you auto-wrap and embed text into any of these shapes (vertical writing, a line-height slider, text color, bold, a cross-axis position adjustment independent of the main alignment, Google/System/Category font selection, and double-click to re-edit an already-embedded text). Custom SVG balloons from assets can also have their fill/border colors changed after placement
|
||||
- **Speech balloons** — Place oval, rounded-rectangle, thought, burst, and cloud (puffy/wavy) shaped balloons inside panels, with 8-point resize handles. The **No tail** checkbox sets the tail length to 0 to hide it (unchecking restores the previous length). The burst shape has an **inner curve** slider that rounds the notch and flank between spikes while keeping the tips sharp. The **Embed Text** button lets you auto-wrap and embed text into any of these shapes (vertical writing, a line-height slider, text color, bold, a cross-axis position adjustment independent of the main alignment, Google/System/Category font selection, and double-click to re-edit an already-embedded text). Custom SVG balloons from assets can also have their fill/border colors changed after placement Embedded text can be moved freely by dragging the **triangular corner at the bottom-left** of the dotted box shown on selection (double-click opens the usual modal).
|
||||
- **Extension balloons** — Inspired by Comic Life's "extension balloon" feature. The **Add Extension** button adds a balloon of the same shape and color as the selected one, connected by a neck, so a long line of dialogue can be split across multiple balloons. Extensions can be individually repositioned, resized, rotated, and given their own embedded text. Moving the base also moves any extensions with it (resizing/rotating the base does not affect their position), while moving an extension only stretches the neck. Dragging the tail from the base toward an extension lets it reach the outer edge of the combined shape. Even where balloons overlap, no interior lines appear — only the outer outline of the whole connected group is shown
|
||||
- **Text** — Vertical/horizontal writing, Google Fonts / system fonts, a style modal for fill, stroke, outline, shadow, line height, vertical alignment, and text alignment. The preview supports multiple lines; below Size, a line-height slider plus button-style vertical alignment (top/center/bottom) and text alignment (left/center/right) — the same controls as the balloon's embedded text — let you adjust and check multi-line text (in vertical writing, vertical alignment controls the position within each column, text alignment the left-right spread of the columns). Line height, vertical alignment, and text alignment are applied to the real text on both the Layout and Image tabs. Fills support gradients, textures (with adjustable X/Y position), and no-fill in addition to solid colors (shared between the Layout and Image tabs) Drag a blue edge-center handle to stretch the text taller or wider, Shift+drag to skew along the edge into a parallelogram; "Reset deform" restores it
|
||||
- **Text** — Vertical/horizontal writing, Google Fonts / system fonts, a style modal for fill, stroke, outline, shadow, line height, vertical alignment, and text alignment. The preview supports multiple lines; below Size, a line-height slider plus button-style vertical alignment (top/center/bottom) and text alignment (left/center/right) — the same controls as the balloon's embedded text — let you adjust and check multi-line text (in vertical writing, vertical alignment controls the position within each column, text alignment the left-right spread of the columns). Line height, vertical alignment, and text alignment are applied to the real text on both the Layout and Image tabs. Fills support gradients, textures (with adjustable X/Y position), and no-fill in addition to solid colors (shared between the Layout and Image tabs) Drag a blue edge-center handle to stretch the text taller or wider, Shift+drag to skew along the edge into a parallelogram; "Reset deform" restores it Corner handles keep the opposite corner fixed and scale in the direction you drag.
|
||||
- **Shape drawing (Draw)** — Draw rectangles, ellipses, lines, curves, polygons, vector curves, chains, ropes, and My Curve directly onto an SVG layer. Polygons: click to add vertices, click near the start point to close. Vector curves: click to add nodes connected by a smooth spline, click near the start point to close as a filled shape, or press Enter to commit as an open line. Fills (rectangles, ellipses, polygons, vector curves, etc.) support gradients, textures (with configurable X/Y offset that follows the shape when moved or resized), and no-fill in addition to solid colors. Converting a shape to PNG preserves these fills as well
|
||||
- **Paint tool** — A raster-brush painting feature, separate from vector shape drawing. Clicking "Add Paint" creates an image sized exactly to the selected panel/overlay/draft layer, selects it automatically, and turns Draw ON so you can start brushing right away (turning on the "Background" checkbox to its left fills it with an opaque color instead of full transparency, avoiding black backgrounds when sent to I2I). The brush works on any selected regular image too, not just newly added paint objects. Supports brush color, opacity, and size (slider), plus x5 (5x brush size) and eraser toggles whose ON/OFF state is shown by button color. **With a pen tablet, brush size and opacity automatically change with pen pressure** (mouse operation keeps the fixed size and opacity as before). Paint objects appear in the layer panel with a 🖌 icon and the name "Paint" to distinguish them from regular images. The "Merge Selected" button composites 2+ checked images/paint layers in the layer panel into a single PNG (preserving stacking order and rotation), useful for sending multiple layers combined to I2I. Useful for rough sketches to edit in the Image tab, or for creating I2I reference images
|
||||
- **Mask tool** — Add multiple mask layers to a panel, overlay, or individual object (image, balloon, text, shape, or group), of two types — hide and show — stacked and composited together. Adjust the brush (paint/erase), size, and hardness while painting, with an optional semi-transparent red preview of the hidden area. Supports bulk invert/clear/fill-all operations and per-layer temporary enable/disable. **With a pen tablet, brush size automatically changes with pen pressure** (mouse operation keeps the fixed size as before)
|
||||
|
||||
+2
-2
@@ -28,9 +28,9 @@
|
||||
- **子画格** — 在分格中拖动添加矩形或圆形的子画格,作为嵌套的图像插入目标(例如插入一个小画面)。可通过手柄移动、调整大小、旋转,超出父画格的部分会像普通对象一样自动被裁剪。每个子画格可单独设置边框粗细。图层面板中的「移动子画格」复选框可切换:点击子画格内部时优先选中子画格本身,而不是其中的对象。图层面板下方的复制・移动按钮同样可作用于子画格本身,支持将其复制/移动(更换父画格)到其他分格或叠加层
|
||||
- **分格分割** — 在"分割"子标签中开启分格分割,在预览上拖动即可沿任意角度的直线将该分格一分为二。分格间距会根据页面上现有分格自动检测。完全落入新分格内的对象会随之移动;已锁定的分格和子画格无法分割
|
||||
- **分格变形・删除** — 在"变形"子标签中破坏性地改变选中分格的形状(忽略分格间距和外侧边距):拖动顶点、删除顶点(四边形→三角形,三角形不可)、拖动边进行平行移动(保持方向,两端顶点沿相邻边延长线滑动)。"外周锁定"(默认开启)可保护整体布局外周上的顶点,已锁定的分格无法变形或删除。可通过图层面板分格行的 ✕ 按钮或"删除所选画格"删除分格(格内图像・气泡・子画格一并删除,支持 Undo)
|
||||
- **对话气泡** — 在分格内放置椭圆、圆角矩形、思考、爆炸、云朵(蓬松/波浪)等形状的对话气泡,支持8点调整手柄。**无尾巴**复选框可将尾巴长度设为0并隐藏(取消勾选会恢复为之前的长度)。爆炸形状带有**内侧弧度**滑块,可在保持尖刺尖端锐利的同时让凹角和侧边变圆润。**内嵌文字**按钮可为以上任意形状内嵌自动换行的文字(支持竖排、行距滑块、文字颜色、粗体、独立于文字对齐的上下/左右位置调整、Google/系统/分类字体选择,双击已内嵌文字的气泡可重新编辑)。素材中的自定义SVG气泡放置后也可修改填充色・边框色
|
||||
- **对话气泡** — 在分格内放置椭圆、圆角矩形、思考、爆炸、云朵(蓬松/波浪)等形状的对话气泡,支持8点调整手柄。**无尾巴**复选框可将尾巴长度设为0并隐藏(取消勾选会恢复为之前的长度)。爆炸形状带有**内侧弧度**滑块,可在保持尖刺尖端锐利的同时让凹角和侧边变圆润。**内嵌文字**按钮可为以上任意形状内嵌自动换行的文字(支持竖排、行距滑块、文字颜色、粗体、独立于文字对齐的上下/左右位置调整、Google/系统/分类字体选择,双击已内嵌文字的气泡可重新编辑)。素材中的自定义SVG气泡放置后也可修改填充色・边框色 内嵌文字可通过拖动选中时显示的虚线框**左下角的三角形角标**自由移动位置(双击打开原有弹窗)。
|
||||
- **延长气泡** — 参考了 Comic Life 的"延长气泡"功能。**添加延长气泡**按钮可添加一个与选中气泡形状、颜色相同的气泡,并用连接颈将两者连接,从而把较长的台词拆分到多个气泡中显示。延长气泡可单独调整位置、大小、旋转角度,并可单独内嵌文字。移动基础气泡时延长气泡会一起平行移动(调整基础气泡的大小或旋转不影响延长气泡的位置),移动延长气泡时只有连接颈会伸缩。将尾巴从基础气泡拖向延长气泡方向,可使尾巴延伸到整个连接体的外周。即使气泡之间发生重叠,内部也不会出现多余线条,只显示整个连接体的外部轮廓线
|
||||
- **文字** — 支持横排/竖排,Google Fonts / 系统字体,可设置填充、描边、勾边字、阴影、行距、上下对齐、文字对齐的样式弹窗。预览文字栏支持多行,"大小"下方的行距滑块,以及与对话气泡内嵌文字相同的按钮式上下对齐(上/中央/下)・文字对齐(左/中央/右)可用于调整确认(竖排时上下对齐=列内纵向位置,文字对齐=列群左右位置)。行距・上下对齐・文字对齐会实际应用于排版标签和Image标签两侧的文字。填充除纯色外还支持渐变、纹理(可指定X/Y位置)和无填充(排版/Image标签通用) 拖动边中点的蓝色手柄可将文字拉伸为纵长/横长,Shift+拖动可沿边剪切为平行四边形,"重置变形"可还原
|
||||
- **文字** — 支持横排/竖排,Google Fonts / 系统字体,可设置填充、描边、勾边字、阴影、行距、上下对齐、文字对齐的样式弹窗。预览文字栏支持多行,"大小"下方的行距滑块,以及与对话气泡内嵌文字相同的按钮式上下对齐(上/中央/下)・文字对齐(左/中央/右)可用于调整确认(竖排时上下对齐=列内纵向位置,文字对齐=列群左右位置)。行距・上下对齐・文字对齐会实际应用于排版标签和Image标签两侧的文字。填充除纯色外还支持渐变、纹理(可指定X/Y位置)和无填充(排版/Image标签通用) 拖动边中点的蓝色手柄可将文字拉伸为纵长/横长,Shift+拖动可沿边剪切为平行四边形,"重置变形"可还原 角手柄固定对角,朝拖动方向放大/缩小。
|
||||
- **形状绘制(绘制)** — 直接在SVG图层上绘制矩形、椭圆、直线、曲线、多边形、矢量曲线、锁链、绳索、My Curve。多边形:点击添加顶点,点击起点附近闭合;矢量曲线:点击添加节点,以平滑曲线(样条)连接,点击起点附近闭合为填充形状,或按Enter键确定为开放曲线。填充(矩形・椭圆・多边形・矢量曲线等)除纯色外还支持渐变、纹理(可指定X/Y位置偏移,会随形状的移动/缩放一起跟随)和无填充。图形转换为PNG时这些填充也会一并保留
|
||||
- **绘画工具** — 与矢量图形绘制完全独立的光栅画笔功能。点击"添加绘画"会按所选分格/覆盖层/草稿层的尺寸创建图像,自动选中并开启绘制ON,可立即用画笔绘制(开启左侧的"背景色"复选框可改为创建指定颜色填充的不透明图像,避免发送到I2I时背景变黑)。画笔不仅可用于新添加的绘画对象,也可直接在当前选中的任意普通图像上绘制。支持画笔颜色、不透明度、大小(滑块),以及x5(5倍画笔大小)和橡皮擦的ON/OFF切换按钮(通过颜色明确显示状态)。**使用数位板时,画笔大小和不透明度会根据笔压自动变化**(鼠标操作时与以往一样保持固定)。绘画对象在图层面板中以🖌图标和"绘画"名称显示,与普通图像区分开。"统合选中图像"按钮可将图层面板中勾选的2个以上图像/绘画对象合成为一张PNG(保留叠放顺序和旋转),便于将多个图层合并后发送到I2I。可用于Image标签中绘制・编辑的草图,或制作I2I参考图像
|
||||
- **蒙版工具** — 可为分格、覆盖层或单个对象(图像、对话气泡、文字、图形、分组)叠加多个蒙版图层并合成,分为"隐藏"和"显示"两种类型。涂抹时可调整画笔(涂抹/擦除)、大小、硬度,并可将隐藏区域以半透明红色预览。支持反转/清除/全部涂抹的批量操作,以及按图层临时启用/禁用。**使用数位板时,画笔大小会根据笔压自动变化**(鼠标操作时与以往一样保持固定)
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-comic-creator"
|
||||
description = "A manga page creation SPA for ComfyUI: templates, layered image editor, speech balloons, font manager, AI image generation (Nanobanana), script management, and halftone/manga-effect tools."
|
||||
version = "1.55.0"
|
||||
version = "1.56.0"
|
||||
license = {file = "LICENSE"}
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -1203,6 +1203,11 @@ input[type="number"] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* フキダシ内テキストの移動用点線ボックス */
|
||||
.balloon-textbox-mark:hover {
|
||||
fill: #b39bff;
|
||||
}
|
||||
|
||||
/* Image Handles */
|
||||
.image-handle {
|
||||
cursor: default;
|
||||
|
||||
@@ -512,6 +512,7 @@ function initGroupManipulation(svgEl) {
|
||||
let groupResizing = false;
|
||||
let groupResizeDir = null;
|
||||
let targetGroup = null;
|
||||
let groupMoved = false; // 実際に動かした場合のみ保存する(クリック選択だけで全SVG複製+DB保存を走らせない)
|
||||
|
||||
let startX = 0, startY = 0;
|
||||
let initTx = 0, initTy = 0;
|
||||
@@ -656,6 +657,7 @@ function initGroupManipulation(svgEl) {
|
||||
|
||||
svgEl.addEventListener('mousemove', (e) => {
|
||||
if (!targetGroup) return;
|
||||
if (groupDragging || groupRotating || groupResizing) groupMoved = true;
|
||||
const pt = getSvgPt(e.clientX, e.clientY);
|
||||
|
||||
if (groupDragging) {
|
||||
@@ -757,7 +759,8 @@ function initGroupManipulation(svgEl) {
|
||||
|
||||
const onMouseUp = async () => {
|
||||
if (!targetGroup) { groupDragging = groupRotating = groupResizing = false; return; }
|
||||
const wasActive = groupDragging || groupRotating || groupResizing;
|
||||
const wasActive = (groupDragging || groupRotating || groupResizing) && groupMoved;
|
||||
groupMoved = false;
|
||||
const savedGroup = targetGroup;
|
||||
groupDragging = false; groupRotating = false; groupResizing = false;
|
||||
groupResizeDir = null; targetGroup = null;
|
||||
|
||||
@@ -502,6 +502,30 @@ async function createPageFromTemplate() {
|
||||
// ページプレビュー・SVG操作(レイアウト)
|
||||
// ==============================
|
||||
|
||||
const _POSE3D_MODEL_ATTR_RE = /\sdata-pose3d-model="[^"]*"/g;
|
||||
// pageRecord内のSVG文字列から data-pose3d-model 属性を除去する。変更があればstate.activePageを
|
||||
// 差し替え(イミュータブル更新)てtrueを返す
|
||||
function _stripPose3dModelFromPage(pageRecord) {
|
||||
const strip = (str) => (typeof str === 'string' && str.includes('data-pose3d-model='))
|
||||
? str.replace(_POSE3D_MODEL_ATTR_RE, '') : str;
|
||||
let changed = false;
|
||||
const next = { ...pageRecord };
|
||||
['svgContent', 'overlaySvgContent', 'draftSvgContent'].forEach(k => {
|
||||
const v = strip(pageRecord[k]);
|
||||
if (v !== pageRecord[k]) { next[k] = v; changed = true; }
|
||||
});
|
||||
if (Array.isArray(pageRecord.panels)) {
|
||||
next.panels = pageRecord.panels.map(p => {
|
||||
const v = strip(p.panelSvgContent);
|
||||
if (v === p.panelSvgContent) return p;
|
||||
changed = true;
|
||||
return { ...p, panelSvgContent: v };
|
||||
});
|
||||
}
|
||||
if (changed) state.activePage = next;
|
||||
return changed;
|
||||
}
|
||||
|
||||
async function renderLayoutTab() {
|
||||
// ドローツールのプロパティ変更がdebounce待ちのまま呼ばれると、DBの保存が
|
||||
// 完了する前に古いデータを読み込んでしまうため、必ず先にflushを待つ
|
||||
@@ -525,6 +549,12 @@ async function renderLayoutTab() {
|
||||
|
||||
state.activePage = pageRecord;
|
||||
|
||||
// 旧版が3Dポーズ画像へ埋め込んだモデル(base64, 数十MB)を除去してDBへ書き戻す(読み出し側は無く、
|
||||
// 残すとページを開くだけでパース・DOM構築・保存のすべてが著しく重くなる)
|
||||
if (_stripPose3dModelFromPage(pageRecord)) {
|
||||
try { await dbPut('pages', state.activePage, { deferThumb: true }); } catch (e) { console.error('pose3d model strip save error:', e); }
|
||||
}
|
||||
|
||||
// 背景SVG + 全コマコンテンツを1つのSVGに統合(clipPathが同一SVG内で解決される)
|
||||
// includeDraft: 下書きレイヤーはプレビュー専用(出力/エクスポート側は includeDraft を渡さないため自動的に除外される)
|
||||
const mergedSvgStr = buildMergedSvg(pageRecord, { includeDraft: true });
|
||||
@@ -817,6 +847,10 @@ function _collectReferencedFilters(targetSvg, sourceDefs) {
|
||||
async function savePanelSvg(panelId, panelLayerSvgEl) {
|
||||
if (!state.activePage || !panelLayerSvgEl) return;
|
||||
|
||||
// 旧版が3Dポーズ画像に埋め込んでいたモデル(base64, 1枚あたり数十MB)は読み出し側が無く、
|
||||
// 保存・複製・直列化のたびに操作を著しく重くするため、保存時に取り除く
|
||||
panelLayerSvgEl.querySelectorAll('[data-pose3d-model]').forEach(el => el.removeAttribute('data-pose3d-model'));
|
||||
|
||||
// panel-0 または __overlay__ はオーバーレイレイヤーとして扱う
|
||||
if (panelId === 'panel-0' || panelId === '__overlay__') {
|
||||
await saveOverlaySvg(panelLayerSvgEl);
|
||||
@@ -833,7 +867,7 @@ async function savePanelSvg(panelId, panelLayerSvgEl) {
|
||||
const clone = panelLayerSvgEl.cloneNode(true);
|
||||
clone.querySelectorAll(
|
||||
'.panel-overlay, .panel-border, .panel-indication, .panel-hit-area, ' +
|
||||
'#balloon-hit-bg, .balloon-handle, .balloon-bbox, .balloon-rotate-line, ' +
|
||||
'#balloon-hit-bg, .balloon-handle, .balloon-bbox, .balloon-textbox, .balloon-textbox-mark, .balloon-rotate-line, ' +
|
||||
'.text-handle, .text-bbox, .text-rotate-line, ' +
|
||||
'.image-handle, .image-bbox, .image-rotate-line, ' +
|
||||
'.group-handle, .group-bbox, .group-rotate-line, ' +
|
||||
|
||||
@@ -1049,6 +1049,7 @@ function initImageManipulation(svgEl, balloonSvgEl) {
|
||||
let imgRotating = false;
|
||||
let imgResizeDir = null;
|
||||
let imgHistoryPushed = false; // 実際にドラッグで動かした場合のみhistoryへ積む(クリック選択だけでは積まない)
|
||||
let imgMoved = false; // 実際に動かした(移動/リサイズ/回転)場合のみ保存する。クリックだけで全SVG複製+DB保存が走ると操作が重くなる
|
||||
let selectedImage = null;
|
||||
let startX, startY;
|
||||
let initX, initY, initW, initH;
|
||||
@@ -1170,6 +1171,7 @@ function initImageManipulation(svgEl, balloonSvgEl) {
|
||||
_imgManipDocMouseMove = (e) => {
|
||||
if (!selectedImage) selectedImage = state.selectedImageEl || null;
|
||||
if (!selectedImage) return;
|
||||
if (imgDragging || imgResizing || imgRotating) imgMoved = true;
|
||||
|
||||
if (imgDragging) {
|
||||
if (!imgHistoryPushed) { pushHistory(); imgHistoryPushed = true; }
|
||||
@@ -1282,7 +1284,9 @@ function initImageManipulation(svgEl, balloonSvgEl) {
|
||||
imgResizing = false;
|
||||
imgRotating = false;
|
||||
imgResizeDir = null;
|
||||
if (selectedImage) {
|
||||
const moved = imgMoved;
|
||||
imgMoved = false;
|
||||
if (moved && selectedImage) {
|
||||
const panelId = selectedImage.getAttribute('data-panel-id') ||
|
||||
selectedImage.closest('g[data-clip-panel]')?.getAttribute('data-clip-panel') || state.selectedPanelId || 'panel-0';
|
||||
await savePanelSvg(panelId, svgEl);
|
||||
@@ -1382,8 +1386,12 @@ function initImageManipulation(svgEl, balloonSvgEl) {
|
||||
const balloonMouseUp = async () => {
|
||||
if (balloonDragging && balloonTarget) {
|
||||
balloonDragging = false;
|
||||
const panelId = balloonTarget.closest('g[data-clip-panel]')?.getAttribute('data-clip-panel') || state.selectedPanelId || 'panel-0';
|
||||
await savePanelSvg(panelId, balloonSvgEl);
|
||||
// クリック選択だけ(実際に動かしていない)なら全SVG複製+DB保存は不要。
|
||||
// 保存後のレイヤーパネル再描画がクリック中のレイヤー行を差し替えて選択が効かなくなるのも防ぐ
|
||||
if (balloonHistoryPushed) {
|
||||
const panelId = balloonTarget.closest('g[data-clip-panel]')?.getAttribute('data-clip-panel') || state.selectedPanelId || 'panel-0';
|
||||
await savePanelSvg(panelId, balloonSvgEl);
|
||||
}
|
||||
balloonTarget = null;
|
||||
balloonLinkedExts = [];
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ async function saveOverlaySvg(panelLayerSvgEl) {
|
||||
const clone = panelLayerSvgEl.cloneNode(true);
|
||||
clone.querySelectorAll(
|
||||
'.panel-overlay, .panel-border, .panel-indication, .panel-hit-area, ' +
|
||||
'#balloon-hit-bg, .balloon-handle, .balloon-bbox, .balloon-rotate-line, ' +
|
||||
'#balloon-hit-bg, .balloon-handle, .balloon-bbox, .balloon-textbox, .balloon-textbox-mark, .balloon-rotate-line, ' +
|
||||
'.text-handle, .text-bbox, .text-rotate-line, ' +
|
||||
'.image-handle, .image-bbox, .image-rotate-line, ' +
|
||||
'.group-handle, .group-bbox, .group-rotate-line, ' +
|
||||
|
||||
@@ -420,7 +420,7 @@ async function addExtensionBalloon() {
|
||||
}
|
||||
|
||||
function clearHandles() {
|
||||
document.querySelectorAll('.balloon-handle, .balloon-bbox, .balloon-rotate-line, .tail-cp-line').forEach(h => h.remove());
|
||||
document.querySelectorAll('.balloon-handle, .balloon-bbox, .balloon-textbox, .balloon-textbox-mark, .balloon-rotate-line, .tail-cp-line').forEach(h => h.remove());
|
||||
const toImageBtn = document.getElementById('h2-to-image-btn');
|
||||
if (toImageBtn) toImageBtn.disabled = true;
|
||||
const addExtBtn = document.getElementById('h2-add-extension-btn');
|
||||
@@ -592,6 +592,7 @@ function _updateH2HandlePositions(el) {
|
||||
if (angle) bboxEl.setAttribute('transform', `rotate(${angle},${cx},${cy})`);
|
||||
else bboxEl.removeAttribute('transform');
|
||||
}
|
||||
if (svg.querySelector('.balloon-textbox')) updateBubbleTextBox(el); // ハンドル非表示中は作らない
|
||||
const positions = _balloonGetRotatedHandlePositions(cx, cy, rx, ry, angle);
|
||||
positions.tail = [tailTipX, tailTipY];
|
||||
|
||||
@@ -717,6 +718,9 @@ function renderHandles(el) {
|
||||
if (balloonAngle0) bboxEl.setAttribute('transform', `rotate(${balloonAngle0},${cx},${cy})`);
|
||||
svg.appendChild(bboxEl);
|
||||
|
||||
// 内包テキストがあれば、ドラッグで位置を動かせる点線ボックスを出す(ハンドルより下の層)
|
||||
updateBubbleTextBox(el);
|
||||
|
||||
// 8点リサイズハンドル。回転を考慮した位置に配置する
|
||||
const rotatedPts = _balloonGetRotatedHandlePositions(cx, cy, rx, ry, balloonAngle0);
|
||||
['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'].forEach(hType => {
|
||||
@@ -759,6 +763,58 @@ function renderHandles(el) {
|
||||
renderLayerPanel();
|
||||
}
|
||||
|
||||
// フキダシ内包テキストの点線ボックス(ドラッグで位置移動、ダブルクリックでモーダル)を作成/更新する。
|
||||
// テキストが無ければ削除する。テキストはフキダシ要素のローカル座標なので、bboxと同じrotateを掛ける
|
||||
function updateBubbleTextBox(el) {
|
||||
const svg = el.ownerSVGElement || el.closest('svg');
|
||||
if (!svg) return;
|
||||
const textEl = el.querySelector('.bubbletext-text');
|
||||
let box = svg.querySelector('.balloon-textbox');
|
||||
let mark = svg.querySelector('.balloon-textbox-mark');
|
||||
if (!textEl || !(el.dataset.bubbleText || '').trim()) {
|
||||
box?.remove(); mark?.remove();
|
||||
return;
|
||||
}
|
||||
const ns = 'http://www.w3.org/2000/svg';
|
||||
const bb = textEl.getBBox();
|
||||
if (!bb.width && !bb.height) { box?.remove(); mark?.remove(); return; }
|
||||
const vb = svg.viewBox.baseVal;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const scale = rect.width ? vb.width / rect.width : 1;
|
||||
const pad = Math.round(scale * 4);
|
||||
const sw = Math.max(1, Math.round(scale * 1.5));
|
||||
if (!box) {
|
||||
box = document.createElementNS(ns, 'rect');
|
||||
box.setAttribute('class', 'balloon-textbox');
|
||||
box.setAttribute('fill', 'none');
|
||||
box.setAttribute('stroke', '#8a63d2');
|
||||
box.style.pointerEvents = 'none'; // 点線は表示のみ。ドラッグは左下の三角コーナー(mark)で行う
|
||||
// 直後に作られるハンドル群より下に置くため、bboxの直後へ挿入する
|
||||
const anchor = svg.querySelector('.balloon-bbox');
|
||||
if (anchor && anchor.nextSibling) svg.insertBefore(box, anchor.nextSibling); else svg.appendChild(box);
|
||||
}
|
||||
if (!mark) {
|
||||
mark = document.createElementNS(ns, 'path');
|
||||
mark.setAttribute('class', 'balloon-textbox-mark');
|
||||
mark.setAttribute('fill', '#8a63d2');
|
||||
mark.style.pointerEvents = 'all';
|
||||
mark.style.cursor = 'move';
|
||||
box.after(mark);
|
||||
}
|
||||
box.setAttribute('x', bb.x - pad);
|
||||
box.setAttribute('y', bb.y - pad);
|
||||
box.setAttribute('width', bb.width + pad * 2);
|
||||
box.setAttribute('height', bb.height + pad * 2);
|
||||
box.setAttribute('stroke-width', sw);
|
||||
box.setAttribute('stroke-dasharray', `${Math.round(scale * 6)} ${Math.round(scale * 4)}`);
|
||||
// 左下の角に直角三角形のグリップ(ドラッグでテキスト移動)。ボックス内側に収める
|
||||
const gx = bb.x - pad, gy = bb.y + bb.height + pad, gs = Math.round(scale * 16);
|
||||
mark.setAttribute('d', `M${gx},${gy}L${gx + gs},${gy}L${gx},${gy - gs}Z`);
|
||||
const angle = parseFloat(el.dataset.angle || 0);
|
||||
const tf = angle ? `rotate(${angle},${parseFloat(el.dataset.cx)},${parseFloat(el.dataset.cy)})` : '';
|
||||
[box, mark].forEach(n => tf ? n.setAttribute('transform', tf) : n.removeAttribute('transform'));
|
||||
}
|
||||
|
||||
function createHandle(svg, type, x, y, className) {
|
||||
const r = calcHandleR(svg);
|
||||
const strokeW = Math.round(r * 0.25);
|
||||
@@ -776,7 +832,7 @@ function createHandle(svg, type, x, y, className) {
|
||||
export {
|
||||
_balloonGetRotateHandlePos, _balloonGetRotatedHandlePositions, _h2CalcCurveHandlePos,
|
||||
_syncH2UI, _updateH2HandlePositions, addExtensionBalloon, applyImageTransform, calcHandleR,
|
||||
clearHandles, convertShapeToImage, createBalloonAtPosition, createHandle, flipSelected,
|
||||
clearHandles, convertShapeToImage, createBalloonAtPosition, createHandle, updateBubbleTextBox, flipSelected,
|
||||
flipSelectedImage, flipSelectedShape, insertSmartBalloonTemplate, renderHandles,
|
||||
};
|
||||
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
import { t } from '../i18n.js';
|
||||
import { _isObjectLocked } from './03-layers-panel.js';
|
||||
import { renderLayerPanel } from './04b-layer-panel-render.js';
|
||||
import { savePanelSvg } from './07-pages.js';
|
||||
import { savePanelSvg, pushHistory } from './07-pages.js';
|
||||
import { updateShapePath, _h2TailBoundaryPoint, _updateH2ShapePath } from './09b-balloon-shapes.js';
|
||||
import { _updateH2HandlePositions, _h2CalcCurveHandlePos, renderHandles, clearHandles } from './09c-balloon-handles.js';
|
||||
import { _updateH2HandlePositions, _h2CalcCurveHandlePos, renderHandles, clearHandles, updateBubbleTextBox } from './09c-balloon-handles.js';
|
||||
import { _bubbleTextRenderText, _bubbleTextAreaFor } from './09f-bubble-text.js';
|
||||
import { state } from './01-state.js';
|
||||
import { clearDrawShapeHandles } from './17c-layer-draw-handles.js';
|
||||
import { _fontMgr, _fontMgrCatLabel, _fontMgrCatNames, _fontMgrGoogleList } from './19-font-manager.js';
|
||||
@@ -34,6 +35,7 @@ function initBalloonTools(overlaySvgEl, _panelSvgEl) {
|
||||
let startX, startY;
|
||||
let startCx, startCy, startRx, startRy;
|
||||
let startAngle = 0, startAngleRad = 0;
|
||||
let textOffsetStart = null; // テキスト点線ボックスのドラッグ開始時の状態
|
||||
|
||||
const getSvgPt = (clientX, clientY) => {
|
||||
const pt = overlaySvgEl.createSVGPoint();
|
||||
@@ -48,6 +50,23 @@ function initBalloonTools(overlaySvgEl, _panelSvgEl) {
|
||||
|
||||
// 編集モード
|
||||
if (state.balloon.isEditMode) {
|
||||
// フキダシ内テキストの点線ボックス左下の三角コーナー: ドラッグでテキスト位置を移動
|
||||
const textBox = e.target.closest('.balloon-textbox-mark');
|
||||
if (textBox) {
|
||||
const shape = document.getElementById(state.selectedShapeId);
|
||||
if (!shape || _isObjectLocked(shape)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isDragging = true;
|
||||
dragTarget = 'text-offset';
|
||||
targetShape = shape;
|
||||
textOffsetStart = {
|
||||
x: pt.x, y: pt.y, moved: false,
|
||||
ox: parseFloat(shape.dataset.textOffsetX) || 0,
|
||||
oy: parseFloat(shape.dataset.textOffsetY) || 0,
|
||||
};
|
||||
return;
|
||||
}
|
||||
const handle = e.target.closest('.balloon-handle');
|
||||
if (handle) {
|
||||
e.preventDefault();
|
||||
@@ -98,6 +117,18 @@ function initBalloonTools(overlaySvgEl, _panelSvgEl) {
|
||||
if (!isDragging || !targetShape) return;
|
||||
const pt = getSvgPt(e.clientX, e.clientY);
|
||||
|
||||
if (dragTarget === 'text-offset') {
|
||||
if (!textOffsetStart) return;
|
||||
if (!textOffsetStart.moved) { pushHistory(); textOffsetStart.moved = true; }
|
||||
// マウス移動量(グローバル座標)をフキダシのローカル座標系(無回転)へ逆回転して加算する
|
||||
const rad = (parseFloat(targetShape.dataset.angle || 0)) * Math.PI / 180;
|
||||
const dxG = pt.x - textOffsetStart.x, dyG = pt.y - textOffsetStart.y;
|
||||
targetShape.dataset.textOffsetX = textOffsetStart.ox + dxG * Math.cos(rad) + dyG * Math.sin(rad);
|
||||
targetShape.dataset.textOffsetY = textOffsetStart.oy - dxG * Math.sin(rad) + dyG * Math.cos(rad);
|
||||
_bubbleTextRenderText(targetShape, _bubbleTextAreaFor(targetShape));
|
||||
updateBubbleTextBox(targetShape);
|
||||
return;
|
||||
}
|
||||
if (dragTarget === 'create') {
|
||||
const rx = Math.abs(pt.x - startX);
|
||||
const ry = Math.abs(pt.y - startY);
|
||||
@@ -241,8 +272,12 @@ function initBalloonTools(overlaySvgEl, _panelSvgEl) {
|
||||
isDragging = false;
|
||||
const shape = targetShape;
|
||||
const dt = dragTarget;
|
||||
const textMoved = dt === 'text-offset' && textOffsetStart?.moved;
|
||||
textOffsetStart = null;
|
||||
dragTarget = null;
|
||||
targetShape = null;
|
||||
// テキスト点線ボックスをクリックしただけ(動かしていない)なら保存しない
|
||||
if (dt === 'text-offset' && !textMoved) return;
|
||||
if (state.balloon.isEditMode && state.selectedShapeId) {
|
||||
// リサイズ/tail完了後にハンドルを正確に再描画
|
||||
const s = document.getElementById(state.selectedShapeId);
|
||||
@@ -515,6 +550,7 @@ function syncFontFamilyUI(textEl) {
|
||||
}
|
||||
|
||||
export {
|
||||
_textLocalToSvg,
|
||||
_loadFavoriteFontsToSelect, _loadGoogleFontsToSelect, _syncFontFavCatSelect,
|
||||
_textBuildTransform, _textGetDeform, _textHasDeform,
|
||||
clearTextHandles, initBalloonTools, loadSystemFontsToSelect, renderTextHandles, syncFontFamilyUI,
|
||||
|
||||
@@ -15,7 +15,7 @@ import { _isObjectLocked, syncPanelSelectionToObject } from './03-layers-panel.j
|
||||
import { getPanelLayerSvg, renderLayerPanel } from './04b-layer-panel-render.js';
|
||||
import { pushHistory, savePanelSvg, saveTextSvg } from './07-pages.js';
|
||||
import { saveOverlaySvg, getOrCreateOverlayGroup } from './09b-balloon-shapes.js';
|
||||
import { syncFontFamilyUI, renderTextHandles, clearTextHandles, _textBuildTransform, _textGetDeform, _textHasDeform } from './09d-balloon-tools.js';
|
||||
import { syncFontFamilyUI, renderTextHandles, clearTextHandles, _textBuildTransform, _textGetDeform, _textHasDeform, _textLocalToSvg } from './09d-balloon-tools.js';
|
||||
import { state } from './01-state.js';
|
||||
import { _clearObjectSelection } from './08-panels-images.js';
|
||||
import { _scriptMangaGetSelectedDialogue } from './21a-script-manga.js';
|
||||
@@ -26,6 +26,15 @@ import { _fontMgrLoadPresets } from './20-font-presets.js';
|
||||
let _textToolsDocMouseMove = null;
|
||||
let _textToolsDocMouseUp = null;
|
||||
|
||||
// テキストの指定した角(nw/ne/se/sw)の、現在の回転・変形を適用したSVG座標を返す
|
||||
function _textCornerSvgPt(el, type) {
|
||||
const bb = el.getBBox();
|
||||
const cx = bb.x + bb.width / 2, cy = bb.y + bb.height / 2;
|
||||
const lx = type.includes('w') ? bb.x : bb.x + bb.width;
|
||||
const ly = type.includes('n') ? bb.y : bb.y + bb.height;
|
||||
return _textLocalToSvg(el, lx, ly, cx, cy);
|
||||
}
|
||||
|
||||
// 選択中テキストの縦長/横長/せん断の変形を解除する(「変形リセット」ボタン)
|
||||
async function _textResetDeform() {
|
||||
const el = state.selectedTextEl;
|
||||
@@ -58,6 +67,8 @@ function initTextTools(textSvgEl, _imageSvgEl) {
|
||||
let startAngle = 0, startAngleRad = 0;
|
||||
let startX = 0, startY = 0, initTx = 0, initTy = 0;
|
||||
let resizeInitFontSize = 0, resizeInitBboxDiag = 0, resizeCx = 0, resizeCy = 0;
|
||||
// 角ハンドル: 反対側の角を固定し、ドラッグした角に追従して拡縮する
|
||||
let resizeFixedType = null, resizeFixedPt = null, resizeHandleVec = null;
|
||||
|
||||
const getSvgPt = (clientX, clientY) => {
|
||||
const pt = textSvgEl.createSVGPoint();
|
||||
@@ -103,6 +114,17 @@ function initTextTools(textSvgEl, _imageSvgEl) {
|
||||
parseFloat(handle.getAttribute('cy')) - cy) || 1);
|
||||
startX = pt.x;
|
||||
startY = pt.y;
|
||||
// 反対側の角(固定点)のSVG座標と、そこからドラッグ角へのベクトルを記録
|
||||
const oppositeOf = { nw: 'se', ne: 'sw', se: 'nw', sw: 'ne' };
|
||||
resizeFixedType = oppositeOf[handle.dataset.handleType] || null;
|
||||
if (resizeFixedType) {
|
||||
resizeFixedPt = _textCornerSvgPt(selectedText, resizeFixedType);
|
||||
resizeHandleVec = {
|
||||
x: parseFloat(handle.getAttribute('cx')) - resizeFixedPt.x,
|
||||
y: parseFloat(handle.getAttribute('cy')) - resizeFixedPt.y,
|
||||
};
|
||||
}
|
||||
if (_isObjectLocked(selectedText)) { textResizing = false; return; }
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -208,16 +230,36 @@ function initTextTools(textSvgEl, _imageSvgEl) {
|
||||
e.preventDefault();
|
||||
const pt = getSvgPt(e.clientX, e.clientY);
|
||||
// ハンドルのドラッグ量から、中心までの距離の変化比でフォントサイズを拡縮
|
||||
const dx = pt.x - resizeCx;
|
||||
const dy = pt.y - resizeCy;
|
||||
const currentDist = Math.sqrt(dx * dx + dy * dy);
|
||||
const ratio = currentDist / (resizeInitBboxDiag / 2);
|
||||
let ratio;
|
||||
if (resizeFixedPt && resizeHandleVec) {
|
||||
// 固定角→ドラッグ角の対角線上へカーソルを射影した比で拡縮(ドラッグした方向に拡大/縮小)
|
||||
const vx = pt.x - resizeFixedPt.x, vy = pt.y - resizeFixedPt.y;
|
||||
const len2 = resizeHandleVec.x ** 2 + resizeHandleVec.y ** 2 || 1;
|
||||
ratio = (vx * resizeHandleVec.x + vy * resizeHandleVec.y) / len2;
|
||||
} else {
|
||||
const dx = pt.x - resizeCx;
|
||||
const dy = pt.y - resizeCy;
|
||||
ratio = Math.sqrt(dx * dx + dy * dy) / (resizeInitBboxDiag / 2);
|
||||
}
|
||||
const newSize = Math.max(6, Math.round(resizeInitFontSize * ratio));
|
||||
selectedText.setAttribute('font-size', newSize);
|
||||
// テクスチャ塗りのタイルサイズはフォントサイズに比例するため、パターンも追従させる
|
||||
_textSyncTexturePatternScale(selectedText);
|
||||
// tspan の x/y オフセットはフォントサイズに依存しないので再配置不要
|
||||
renderTextHandles(selectedText, textSvgEl);
|
||||
if (resizeFixedPt) {
|
||||
// 拡縮でずれた固定角を元の位置へ戻すよう、テキスト全体を平行移動する
|
||||
const np = _textCornerSvgPt(selectedText, resizeFixedType);
|
||||
const mdx = resizeFixedPt.x - np.x, mdy = resizeFixedPt.y - np.y;
|
||||
if (Math.abs(mdx) > 1e-6 || Math.abs(mdy) > 1e-6) {
|
||||
selectedText.setAttribute('x', (parseFloat(selectedText.getAttribute('x')) || 0) + mdx);
|
||||
selectedText.setAttribute('y', (parseFloat(selectedText.getAttribute('y')) || 0) + mdy);
|
||||
selectedText.querySelectorAll('tspan[x]').forEach(ts => ts.setAttribute('x', parseFloat(ts.getAttribute('x')) + mdx));
|
||||
selectedText.querySelectorAll('tspan[y]').forEach(ts => ts.setAttribute('y', parseFloat(ts.getAttribute('y')) + mdy));
|
||||
_textSyncTexturePatternTransform(selectedText);
|
||||
renderTextHandles(selectedText, textSvgEl);
|
||||
}
|
||||
}
|
||||
// UIのサイズ表示も更新
|
||||
const fontSizeInput = document.getElementById('font-size');
|
||||
const PT_TO_SVG = 3.528;
|
||||
|
||||
@@ -116,6 +116,9 @@ function _bubbleTextRenderText(el, area) {
|
||||
const valign = el.dataset.textValign || 'center';
|
||||
const lineHeightMult = parseFloat(el.dataset.lineHeightMult) || 1.4;
|
||||
const lineHeight = fontSizeSvg * lineHeightMult;
|
||||
// 点線ボックスのドラッグで指定した位置オフセット(フキダシのローカル座標、寄せ位置からの追加移動量)
|
||||
const ox = parseFloat(el.dataset.textOffsetX) || 0;
|
||||
const oy = parseFloat(el.dataset.textOffsetY) || 0;
|
||||
const k = area.kind === 'oval' ? Math.SQRT2 : 1;
|
||||
// 楕円形状は対角線方向の実効半径がrx/ryそのものより小さい(内接矩形相当のrx/k, ry/k)ため、
|
||||
// 折り返し判定だけでなく寄せ位置の計算にも同じkを反映し、どの方向に寄せてもpadding分の
|
||||
@@ -173,8 +176,8 @@ function _bubbleTextRenderText(el, area) {
|
||||
const textY = align === 'left' ? (area.cy - vRange + padding) : align === 'right' ? (area.cy + vRange - padding) : area.cy;
|
||||
lines.forEach((line, i) => {
|
||||
const tspan = document.createElementNS(ns, 'tspan');
|
||||
tspan.setAttribute('x', startX - i * lineHeight);
|
||||
tspan.setAttribute('y', textY);
|
||||
tspan.setAttribute('x', startX - i * lineHeight + ox);
|
||||
tspan.setAttribute('y', textY + oy);
|
||||
tspan.textContent = line || ' ';
|
||||
textEl.appendChild(tspan);
|
||||
});
|
||||
@@ -192,8 +195,8 @@ function _bubbleTextRenderText(el, area) {
|
||||
: (area.cy - totalH / 2 + lineHeight * 0.8);
|
||||
lines.forEach((line, i) => {
|
||||
const tspan = document.createElementNS(ns, 'tspan');
|
||||
tspan.setAttribute('x', textX);
|
||||
tspan.setAttribute('y', startY + i * lineHeight);
|
||||
tspan.setAttribute('x', textX + ox);
|
||||
tspan.setAttribute('y', startY + i * lineHeight + oy);
|
||||
tspan.textContent = line || ' '; // 空行も高さを保つためnbsp
|
||||
textEl.appendChild(tspan);
|
||||
});
|
||||
@@ -289,6 +292,11 @@ async function applyBubbleTextToShape(el, { text, fontSizePt, textAlign, textVal
|
||||
el.dataset.bubbleText = text;
|
||||
el.dataset.fontFamily = ff;
|
||||
el.dataset.fontSizeSvg = fontSizeSvg;
|
||||
// モーダルで寄せ位置を変えた場合は、ドラッグで付けた位置オフセットを解除して寄せ位置を優先する
|
||||
if ((el.dataset.textAlign || 'center') !== (textAlign || 'center') || (el.dataset.textValign || 'center') !== (textValign || 'center')) {
|
||||
delete el.dataset.textOffsetX;
|
||||
delete el.dataset.textOffsetY;
|
||||
}
|
||||
el.dataset.textAlign = textAlign || 'center';
|
||||
el.dataset.textValign = textValign || 'center';
|
||||
el.dataset.bubbleTextVertical = vertical ? '1' : '0';
|
||||
@@ -666,7 +674,9 @@ function initBubbleTextTools(svgEl) {
|
||||
if (!svgEl) return;
|
||||
if (_bubbleTextDblClickHandler) svgEl.removeEventListener('dblclick', _bubbleTextDblClickHandler, true);
|
||||
_bubbleTextDblClickHandler = (e) => {
|
||||
const shape = e.target.closest('.balloon-shape');
|
||||
// 選択中フキダシのテキスト点線ボックスの三角コーナー(ハンドル層)のダブルクリックでも同じモーダルを開く
|
||||
const shape = e.target.closest('.balloon-shape')
|
||||
|| (e.target.closest('.balloon-textbox-mark') ? document.getElementById(state.selectedShapeId) : null);
|
||||
if (!shape || !_bubbleTextCanHoldText(shape.dataset.shapeType)) return;
|
||||
if (typeof _isObjectLocked === 'function' && _isObjectLocked(shape)) return;
|
||||
e.preventDefault();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -631,9 +631,6 @@ function hidePose3DCanvas() {
|
||||
async function _pose3dInsertCaptureIntoPanel(editor, panelId) {
|
||||
// ポーズデータ・モデル情報を収集(再編集用)
|
||||
const poseJson = editor.exportPose() ?? '';
|
||||
const modelB64 = (!state.pose3d.modelIsDefault && state.pose3d.modelBuffer)
|
||||
? _arrayBufferToBase64(state.pose3d.modelBuffer)
|
||||
: '';
|
||||
|
||||
// キャプチャ(ボーン点を非表示にしてレンダリング)
|
||||
const dataUrl = editor.capture();
|
||||
@@ -649,7 +646,6 @@ async function _pose3dInsertCaptureIntoPanel(editor, panelId) {
|
||||
|
||||
await insertImage(dataUrl, w, h, {
|
||||
'data-pose3d-pose': poseJson,
|
||||
'data-pose3d-model': modelB64,
|
||||
});
|
||||
|
||||
state.selectedPanelId = prevPanelId;
|
||||
|
||||
Reference in New Issue
Block a user