<!doctype html>
<html lang="ja">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>スクショエディター</title>
<style>
* {
box-sizing: border-box
}
body {
margin: 0;
background: #eef1f6 ;
color: #172238 ;
font: 14px system-ui, sans-serif
}
header {
background: #172238 ;
color: white;
padding: 6px 24px;
display: flex;
align-items: center;
gap: 18px
}
h1 {
font-size: 18px;
margin: 0
}
header span {
color: #b8c5db ;
font-size: 13px
}
.bar {
display: flex;
flex-wrap: wrap;
gap: 9px;
padding: 14px 20px;
background: white;
border-bottom: 1px solid #dce2eb ;
align-items: center
}
button,
.file {
background: #f4f6fa ;
border: 1px solid #d5ddea ;
border-radius: 7px;
padding: 9px 12px;
color: #233550 ;
cursor: pointer;
font: inherit
}
button:hover,
.file:hover {
background: #e5ecf7
}
button.active,
button.primary {
background: #3468df ;
color: white;
border-color: #3468df
}
label {
display: inline-flex;
align-items: center;
gap: 6px
}
input[type=number] {
width: 75px;
padding: 7px;
border: 1px solid #d5ddea ;
border-radius: 5px
}
input[type=color] {
width: 34px;
height: 32px;
border: 0;
padding: 0;
background: none
}
input[type=range] {
width: 85px
}
.sep {
height: 26px;
width: 1px;
background: #dce2eb ;
margin: 0 4px
}
.stage {
padding: 24px;
overflow: auto;
min-height: 400px
}
canvas {
display: block;
background-color: white;
background-image: linear-gradient(45deg, #e0e4ec 25%, transparent 25%), linear-gradient(-45deg, #e0e4ec 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e4ec 75%), linear-gradient(-45deg, transparent 75%, #e0e4ec 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
box-shadow: 0 4px 24px #1d33551c ;
touch-action: none;
max-width: 100%;
height: auto
}
footer {
padding: 0 24px 24px;
color: #56647c ;
line-height: 1.9
}
#status {
color: #234fb0 ;
min-height: 26px
}
.file input {
display: none
}
@media(max-width:650px) {
header span {
display: none
}
.stage {
padding: 12px
}
.bar {
padding: 10px
}
}
.icon-tool {
width: 42px;
height: 40px;
padding: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0
}
.icon-tool[hidden],
button[hidden] {
display: none
}
.row-label {
width: 84px;
flex-shrink: 0;
font-size: 12px;
font-weight: 700;
color: #62728b
}
.tool-group,
.palette {
display: inline-flex;
align-items: center;
gap: 5px
}
.tool-group {
gap: 9px
}
.swatch {
width: 23px;
height: 23px;
padding: 0;
border-radius: 5px;
border: 1px solid #aab6c8
}
.swatch:hover {
outline: 2px solid #3468df ;
outline-offset: 2px
}
textarea {
width: 275px;
min-height: 50px;
border: 1px solid #d5ddea ;
border-radius: 6px;
padding: 7px;
font: inherit;
resize: vertical
}
.danger {
color: #bd2929
}
select {
padding: 7px;
border: 1px solid #d5ddea ;
border-radius: 6px;
background: white;
font: inherit
}
button:focus-visible,
.file:focus-visible {
outline: 3px solid #8aadff ;
outline-offset: 2px
}
@media(max-width:650px) {
.row-label {
width: 100%
}
.palette {
flex-wrap: wrap
}
textarea {
max-width: 100%
}
}
.topbar {
flex-wrap: nowrap;
overflow-x: auto;
gap: 16px
}
.toolbar-section {
display: flex;
align-items: center;
gap: 9px;
flex-shrink: 0
}
.toolbar-section .row-label {
width: auto;
white-space: nowrap;
margin-right: 5px
}
.toolbar-section button {
white-space: nowrap
}
.toolbar-section label {
white-space: nowrap
}
details {
margin-top: 8px
}
summary {
cursor: pointer;
display: list-item;
color: #234fb0 ;
width: max-content
}
details .instructions {
padding-top: 10px
}
summary:focus-visible {
outline: 2px solid #3468df ;
outline-offset: 4px
}
button:disabled,
textarea:disabled {
opacity: .45;
cursor: not-allowed
}
button:disabled:hover {
background: #f4f6fa
}
textarea:disabled {
background: #edf0f5
}
.instructions {
max-width: 920px
}
.instructions h2 {
font-size: 20px;
margin: 12px 0 20px
}
.instructions h3 {
font-size: 16px;
color: #233550 ;
margin: 24px 0 8px
}
.instructions p {
margin: 8px 0
}
.instructions ul {
margin: 8px 0;
padding-left: 24px
}
.output-label {
width: auto;
margin-left: 4px
}
.bar{padding:8px 16px;gap:7px}.stage{padding:16px;min-height:calc(100vh - 220px)}.settings{margin:0;background:#fff;border-bottom:1px solid #dce2eb }.settings>summary{padding:8px 20px;font-size:13px}.flyout{margin:0;flex-shrink:0}.flyout>summary{list-style:none;padding:9px 12px;border:1px solid #d5ddea ;border-radius:7px;background:#f4f6fa;font-size:13px}.flyout-panel{position:fixed;top:105px;right:20px;width:min(540px,calc(100vw - 40px));max-height:65vh;overflow:auto;z-index:10;background:white;padding:18px;border:1px solid #d5ddea ;border-radius:10px;box-shadow:0 8px 30px #17223833 }.compact-row{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0 18px;align-items:center}.panel-hint{font-size:12px;color:#62728b;line-height:1.7;margin:8px 0}.compact-row input:not([type=number]){width:145px;padding:8px;border:1px solid #d5ddea ;border-radius:6px}#autosaveStatus{margin-left:auto;font-size:12px}.drop-active{outline:3px dashed #3468df ;outline-offset:-6px}canvas:focus{outline:1px solid #8aadff }.selection-count{font-size:12px}
/* Left tool rail with overlay panels keeps the canvas unobstructed. */
body{height:100vh;overflow:hidden}header{height:36px;padding:6px 18px}.editor-layout{display:flex;height:calc(100vh - 36px)}.left-sidebar{width:116px;flex:0 0 116px;background:#fff;border-right:1px solid #d5ddea ;overflow-y:auto;padding:8px 6px}.editor-main{flex:1;min-width:0;overflow:auto}.left-sidebar .topbar{padding:0;border:0;overflow:visible;flex-direction:column;align-items:stretch;gap:12px}.left-sidebar .toolbar-section{display:grid;grid-template-columns:repeat(2,42px);justify-content:center;gap:6px;flex-shrink:1}.left-sidebar .row-label{grid-column:1/-1;width:auto;text-align:center;margin:0;padding:4px 0;font-size:11px}.left-sidebar .toolbar-section>.sep{grid-column:1/-1;width:100%;height:1px;margin:4px 0}.left-sidebar .topbar>.sep{width:100%;height:1px;margin:0}.left-sidebar .tool-group{grid-column:1/-1;display:flex;flex-direction:column;align-items:center;gap:6px}.left-sidebar .tool-group label{font-size:11px;gap:3px}.left-sidebar .tool-group select{width:74px;font-size:11px;padding:4px 1px}.left-sidebar #front ,.left-sidebar #back ,.left-sidebar #clear ,.left-sidebar #cropApply ,.left-sidebar #cropCancel {grid-column:1/-1;font-size:11px;padding:7px 3px}.left-sidebar .flyout,.left-sidebar .settings{margin:8px 0 0;border:0}.left-sidebar summary{list-style:none;display:block;width:100%;font-size:11px;line-height:1.6;text-align:center;padding:8px 4px;border:1px solid #d5ddea ;border-radius:7px;background:#f4f6fa}.left-sidebar summary::-webkit-details-marker{display:none}.left-sidebar details[open]>summary{background:#e5ecff;border-color:#8aadff}.left-sidebar .flyout-panel,.left-sidebar .settings-panel{position:fixed;left:126px;right:auto;top:48px;width:min(420px,calc(100vw - 142px));max-height:calc(100vh - 70px);overflow:auto;z-index:20;background:#fff;border:1px solid #d5ddea ;border-radius:10px;box-shadow:0 8px 30px #17223833 ;padding:14px}.left-sidebar .settings-panel .bar{padding:10px 0;gap:10px;align-items:center}.left-sidebar .settings-panel .row-label{width:100%;text-align:left;font-size:12px}.left-sidebar .settings-panel textarea{width:100%}.left-sidebar .settings-panel .palette{flex-wrap:wrap}.left-sidebar .settings-panel .sep{display:none}.left-sidebar .settings-panel label{font-size:12px}.stage{padding:20px;min-height:calc(100vh - 115px)}footer{padding:0 20px 18px}.editor-main canvas{max-width:100%}#autosaveStatus{white-space:nowrap}@media(max-width:650px){.left-sidebar{width:100px;flex-basis:100px;padding:6px 3px}.left-sidebar .flyout-panel,.left-sidebar .settings-panel{left:108px;width:calc(100vw - 120px)}.stage{padding:12px}.left-sidebar .toolbar-section .row-label{width:auto}header{padding:6px 12px}#autosaveStatus{font-size:10px}}
.output-menu{margin-top:12px;padding-top:8px;border-top:1px solid #d5ddea ;display:flex;flex-direction:column;gap:6px}.output-menu h2{font-size:11px;color:#62728b;text-align:center;margin:0 0 2px}.output-menu button{font-size:11px;padding:9px 3px;white-space:nowrap}</style>
<header>
<h1>スクショエディター</h1>
<span id="autosaveStatus" role="status">自動保存を準備中</span></header>
<div class="editor-layout"><aside class="left-sidebar" aria-label="編集メニュー"><div class="bar topbar">
<div class="toolbar-section" aria-label="画像と描画"><span class="row-label">画像と描画</span>
<label class="file icon-tool" title="画像を読み込む" aria-label="画像を読み込む" tabindex="0" role="button"><svg width="24"
height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 7h7l2 3h9v11H3z" />
<path d="M14 4h7m-3-3v6" />
</svg><input id="imageFile" type="file" accept="image/*"></label><button id="paste" class="icon-tool"
title="画像を貼り付け" aria-label="画像を貼り付け"><svg width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true">
<rect x="6" y="5" width="13" height="16" rx="2" />
<rect x="9" y="2" width="7" height="5" rx="1" />
</svg></button><button data-tool="select" class="icon-tool active" title="選択・移動" aria-label="選択・移動"><svg
width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 2l14 11-7 1-3 7z" />
<path d="M16 17h7m-3-3v6" />
</svg></button><button id="crop" class="icon-tool" title="画像を切り抜く" aria-label="画像を切り抜く"><svg width="24"
height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M7 2v15h15M2 7h15v15" />
</svg></button><button id="cropApply" hidden>切り抜きを確定</button><button id="cropCancel"
hidden>キャンセル</button><span class="sep"></span><button data-tool="rect" class="icon-tool" title="角丸の四角枠"
aria-label="角丸の四角枠"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="5" width="18" height="14" rx="4" />
</svg></button><button data-tool="fill" class="icon-tool" title="半透明の四角形" aria-label="半透明の四角形"><svg
width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="5" width="18" height="14" rx="2" fill="currentColor" fill-opacity=".3" />
</svg></button><button id="textEdit" class="icon-tool" title="文字入力(図形を選択してください)" aria-label="文字入力"
disabled><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 5h14M12 5v15" stroke-width="3" stroke-linecap="square" />
</svg></button><span class="tool-group"><button data-tool="arrow" class="icon-tool" title="矢印"
aria-label="矢印"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 20L21 4m-9 0h9v9" />
</svg></button><label>向き<select id="direction">
<option value="free">自由角度</option>
<option value="horizontal">水平</option>
<option value="vertical">垂直</option>
</select></label><label><input id="doubleArrow" type="checkbox">両端</label></span>
</div><span class="sep"></span>
<details id="extras" class="flyout"><summary>追加ツール・整列</summary><div class="flyout-panel"><strong>記号を配置</strong><div class="compact-row"><button data-tool="number" title="番号付きマーカー">① 番号</button><label>次の番号<input id="numberValue" type="number" min="1" max="999" value="1"></label><button data-tool="check" title="チェック印">✓ チェック</button><button data-tool="cross" title="バツ印">× バツ</button></div><div class="compact-row"><button data-tool="mouse" title="マウス操作のマーク">マウス</button><select id="mouseKind" aria-label="マウス操作"><option value="left">左クリック</option><option value="right">右クリック</option><option value="double">ダブルクリック</option></select><button data-tool="key" title="キー操作のラベル">キー</button><input id="keyValue" aria-label="キーラベル" value="Ctrl + C" maxlength="60"></div><strong>整列・等間隔配置</strong><p class="panel-hint">ShiftまたはCtrlを押しながらクリックして複数選択。空白からドラッグして範囲選択。</p><div class="compact-row"><button data-align="left">左揃え</button><button data-align="center">中央揃え</button><button data-align="right">右揃え</button><button data-align="top">上揃え</button><button data-align="middle">上下中央</button><button data-align="bottom">下揃え</button><button data-align="horizontal">横に等間隔</button><button data-align="vertical">縦に等間隔</button></div></div></details><div class="toolbar-section operations" aria-label="オブジェクト操作"><span class="row-label">操作</span><button
id="duplicate" class="icon-tool" title="複製" aria-label="複製"><svg width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true">
<rect x="8" y="8" width="13" height="13" rx="2" />
<path d="M16 5V3H3v13h2" />
</svg></button><button id="remove" class="icon-tool" title="削除" aria-label="削除"><svg width="24" height="24"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true">
<path d="M3 6h18M9 6V3h6v3M6 6l1 15h10l1-15M10 10v7m4-7v7" />
</svg></button><button id="front">最前面へ</button><button id="back">最背面へ</button><span
class="sep"></span><button id="undo" class="icon-tool" title="元に戻す" aria-label="元に戻す"><svg width="24"
height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M8 4L3 9l5 5M3 9h10a7 7 0 010 14" />
</svg></button><button id="redo" class="icon-tool" title="やり直す" aria-label="やり直す"><svg width="24"
height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M16 4l5 5-5 5m5-5H11a7 7 0 000 14" />
</svg></button><button id="clear" class="danger">一括クリア</button></div>
</div><details id="settings" class="settings"><summary>図形・文字の設定</summary><div class="settings-panel"><div class="bar" aria-label="図形の見た目"><span class="row-label">図形</span><label>色<input id="color" type="color"
value="#ef4444"></label><span id="colorPalette" class="palette"></span><span
class="sep"></span><label>線の太さ<input id="line" type="number" min="1" max="40" value="5"></label><label>透明度<input
id="opacity" type="range" min="0" max="100" value="100"><span
id="opacityText">100%</span></label><label>角の丸み<input id="radius" type="number" min="0" max="100"
value="12"></label></div>
<div class="bar" aria-label="文字の設定"><span class="row-label">文字</span><textarea id="textValue" rows="2"
aria-label="図形内の文字" placeholder="文字を入力。部分的に色を変えるには文字を選択"></textarea><label>文字色<input id="textColor" type="color"
value="#172238"></label><span id="textColorPalette" class="palette"></span><button id="allTextColor"
title="全文を現在の文字色にする">全文に適用</button><label>フォント<select id="font">
<option value="sans-serif">ゴシック</option>
<option value="serif">明朝</option>
<option value="monospace">等幅</option>
<option value="Meiryo, sans-serif">メイリオ</option>
<option value=""Yu Gothic", sans-serif">游ゴシック</option>
<option value=""Yu Mincho", serif">游明朝</option>
<option value=""BIZ UDPGothic", sans-serif">BIZ UDPゴシック</option>
<option value=""BIZ UDPMincho", serif">BIZ UDP明朝</option>
<option value=""MS Gothic", monospace">MS ゴシック</option>
<option value=""MS PGothic", sans-serif">MS Pゴシック</option>
</select></label><label>横位置<select id="textH"><option value="left">左</option><option value="center" selected>中央</option><option value="right">右</option></select></label><label>縦位置<select id="textV"><option value="top">上</option><option value="middle" selected>中央</option><option value="bottom">下</option></select></label><label>文字サイズ<input id="fontSize" type="number" min="8" max="200" value="28" required>px</label>
</div>
</div></details><details id="canvasSettings" class="settings"><summary>キャンバス</summary><div class="settings-panel"><div class="bar" aria-label="キャンバス"><span class="row-label">キャンバス</span><label>幅<input id="cw" type="number"
min="100" max="6000" value="1200"></label><label>高さ<input id="ch" type="number" min="100" max="6000"
value="750"></label><button id="resize">サイズ変更</button><span class="sep"></span><label>背景色<input id="bg"
type="color" value="#ffffff"></label><span id="bgPalette" class="palette"></span><label>背景の透明度<input
id="bgAlpha" type="range" min="0" max="100" value="0"><span id="bgText">0%</span></label></div></div></details><section class="output-menu" aria-label="出力"><h2>出力</h2><button id="copy"
class="primary">PNGをコピー</button><button id="save" class="primary">PNGを保存</button></section></aside><main class="editor-main"><div class="stage"><canvas id="canvas" width="1200" height="750" tabindex="0" aria-label="画像と図形の編集領域"></canvas></div>
<footer>
<div id="status" role="status">画像をCtrl+Vで貼り付けるか、読み込んでください。</div>
<details id="help">
<summary>説明を表示</summary>
<div class="instructions">
<h2>スクショマイスターの使い方</h2>
<h3>1.画像を取り込む</h3>
<p>「画像を読み込む」で画像ファイルを選ぶか、コピーしたスクリーンショットを「画像を貼り付け」または Ctrl+V で取り込みます。</p>
<h3>2.図形や矢印を描く</h3>
<p>「角丸の四角枠」「半透明の四角形」「矢印」を選び、キャンバス上でドラッグします。</p>
<p>矢印の向きは「自由角度」「水平」「垂直」から選べます。自由角度では、Shiftキーを押しながら描くと水平・垂直に固定できます。</p>
<h3>3.選択・移動・サイズ変更</h3>
<p>オブジェクトをクリックすると選択され、青い点が表示されます。</p>
<ul>
<li>オブジェクトをドラッグ:移動</li>
<li>青い点をドラッグ:サイズ変更、矢印の端の調整</li>
<li>空白部分をクリック:選択解除</li>
</ul>
<p>画像のサイズ変更は縦横比を固定します。Altキーを押しながら操作すると、縦横を自由に変更できます。</p>
<h3>4.図形の見た目を変える</h3>
<p>図形を選択し、「図形」のメニューで色・線の太さ・透明度・角の丸みを調整します。</p>
<p>色はパレットや色選択欄から指定できます。透明度を0%にすると、図形の枠や塗りが見えなくなります。図形内の文字は表示されます。</p>
<h3>5.図形内に文字を入れる</h3>
<p>四角形を選択して「T」の文字入力ボタンを押し、文字欄に入力します。改行もできます。</p>
<p>文字色・フォント・文字サイズは「文字」のメニューで設定します。</p>
<ul>
<li>一部の文字の色を変更:文字欄で対象を選択し、文字色やパレットを選ぶ</li>
<li>全文の色を変更:文字色を選び、「全文に適用」を押す</li>
<li>次に入力する文字の色を指定:文字を選択せずに文字色を選ぶ</li>
</ul>
<p>未選択時や画像・矢印の選択時は、文字入力ボタンを使えません。</p>
<h3>6.画像を切り抜く</h3>
<p>画像を選択して「画像を切り抜く」を押します。画像内で残したい範囲をドラッグし、「切り抜きを確定」を押します。</p>
<p>範囲を指定した後は、枠の四隅・辺の青い点をドラッグして大きさを調整できます。範囲内をドラッグすると範囲を移動できます。画像内の範囲外からドラッグすると、範囲を指定し直せます。取り消す場合は「キャンセル」を押してください。</p>
<h3>7.複製・削除・重なり順</h3>
<p>オブジェクトを選択し、「操作」のメニューを使います。</p>
<ul>
<li>複製:同じオブジェクトを追加</li>
<li>削除:選択したオブジェクトを削除</li>
<li>最前面へ/最背面へ:重なり順を変更</li>
<li>元に戻す/やり直す:編集操作を取り消す、または再実行</li>
<li>一括クリア:キャンバス内の画像・図形をすべて削除</li>
</ul>
<h3>8.キャンバス</h3>
<p>背景色と背景の透明度を設定できます。背景の透明度を0%にすると透明背景になります。取り込んだ画像自体の背景は変わりません。</p>
<h3>9.出力</h3>
<p>「PNGをコピー」で画像をコピーし、貼り付け先で Ctrl+V を押します。「PNGを保存」で画像ファイルとして保存できます。</p>
<h3>10.追加ツールと整列</h3><p>「追加ツール・整列」から番号、チェック・バツ、マウス操作、キー操作のラベルを選び、キャンバスをクリックして配置します。ドラッグすると大きさを指定できます。番号は配置するたびに増えます。キー操作のラベルは入力欄で指定します。</p><p>ShiftまたはCtrlを押しながらオブジェクトをクリックすると複数選択できます。空白からドラッグして範囲選択もできます。2個以上で整列、3個以上で等間隔配置が使えます。</p><p>矢印の「両端」を選ぶと両端矢印を描けます。「図形・文字の設定」で文字の横位置と縦位置を設定できます。画像ファイルはキャンバスへのドラッグ&ドロップでも取り込めます。画像がキャンバスより大きい場合はキャンバスを自動で広げます。</p><h3>保存について</h3>
<p>画像は外部に送信されません。終了前にPNGを保存してください。編集内容はこのブラウザに自動保存され、次回このHTMLを開くと復元されます。保存状況はタイトル右側に表示されます。ブラウザのデータ削除や保存制限がある環境では復元できないため、必要な画像はPNGでも保存してください。</p>
</div>
</details>
</footer></main></div>
<script>
'use strict';
const $ = id => document.getElementById(id), c = $('canvas'), ctx = c.getContext('2d');
let objects = [], selected = null, tool = 'select', gesture = null, history = [], future = [], images = new Map(), cropMode = false, cropBox = null, textRange = { start: 0, end: 0 }, draftText = '', draftColors = [];
const message = s => $('status').textContent = s;
const state = () => JSON.stringify({ objects, w: c.width, h: c.height, bg: $('bg').value, alpha: $('bgAlpha').value });
function record() { history.push(state()); if (history.length > 60) history.shift(); future = [] }
function restore(s) { const v = JSON.parse(s); objects = v.objects; c.width = v.w; c.height = v.h; $('cw').value = v.w; $('ch').value = v.h; $('bg').value = v.bg; $('bgAlpha').value = v.alpha; $('bgText').textContent = v.alpha + '%'; selected = null; selectionIds.clear(); cancelCrop(); draw() }
function setTool(t) { if($('extras'))$('extras').open=false; cancelCrop(); tool = t; document.querySelectorAll('[data-tool]').forEach(b => b.classList.toggle('active', b.dataset.tool === t)); c.style.cursor = t === 'select' ? 'default' : 'crosshair' }
document.querySelectorAll('[data-tool]').forEach(b => b.onclick = () => setTool(b.dataset.tool));
const chosen = () => objects.find(o => o.id === selected);
function points(o) { return o.type === 'arrow' ? [{ x: o.x, y: o.y }, { x: o.x + o.w, y: o.y + o.h }] : [{ x: o.x, y: o.y }, { x: o.x + o.w, y: o.y }, { x: o.x + o.w, y: o.y + o.h }, { x: o.x, y: o.y + o.h }] }
function arrowPath(o){const len=Math.hypot(o.w,o.h);if(!len)return [];const ux=o.w/len,uy=o.h/len,head=Math.min(len/(o.double?2:1),Math.max(14,o.line*3)),half=Math.max(o.line,head*.45),shaft=o.line/2;const coords=o.double?[[0,0],[head,-half],[head,-shaft],[len-head,-shaft],[len-head,-half],[len,0],[len-head,half],[len-head,shaft],[head,shaft],[head,half]]:[[0,-shaft],[len-head,-shaft],[len-head,-half],[len,0],[len-head,half],[len-head,shaft],[0,shaft]];return coords.map(([x,y])=>({x:o.x+x*ux-y*uy,y:o.y+x*uy+y*ux}))}
function rounded(context, o) { context.beginPath(); context.roundRect(o.x, o.y, o.w, o.h, Math.max(0, Math.min(o.radius ?? 12, o.w / 2, o.h / 2))) }
function textColors(o) { return Array.from({ length: (o.text || '').length }, (_, i) => o.textColors?.[i] || o.textColor || '#172238') }
function editColors(before, after, colors, color) { let prefix = 0; while (prefix < before.length && prefix < after.length && before[prefix] === after[prefix]) prefix++; let suffix = 0; while (suffix < before.length - prefix && suffix < after.length - prefix && before[before.length - 1 - suffix] === after[after.length - 1 - suffix]) suffix++; return colors.slice(0, prefix).concat(Array(Math.max(0, after.length - prefix - suffix)).fill(color), suffix ? colors.slice(before.length - suffix) : []) }
function textDraw(context,o){if(!o.text)return;context.save();rounded(context,o);context.clip();context.globalAlpha=1;context.font=(o.fontSize||28)+'px '+(o.font||'sans-serif');context.textAlign='left';context.textBaseline='middle';const pad=Math.min(10,o.w/4,o.h/4),max=Math.max(1,o.w-pad*2),lines=[],colors=textColors(o);let row=[],offset=0,width=0;for(const char of o.text){if(char==='\n'){lines.push(row);row=[];width=0;offset++;continue}const cw=context.measureText(char).width;if(row.length&&width+cw>max){lines.push(row);row=[];width=0}row.push({char,color:colors[offset],width:cw});width+=cw;offset+=char.length}lines.push(row);const step=(o.fontSize||28)*1.3,total=step*lines.length;const yStart=o.textV==='top'?o.y+pad+step/2:o.textV==='bottom'?o.y+o.h-pad-total+step/2:o.y+o.h/2-total/2+step/2;lines.forEach((line,i)=>{const width=line.reduce((n,g)=>n+g.width,0);let x=o.textH==='left'?o.x+pad:o.textH==='right'?o.x+o.w-pad-width:o.x+(o.w-width)/2;for(const g of line){context.fillStyle=g.color;context.fillText(g.char,x,yStart+i*step);x+=g.width}});context.restore()}
function render(context, editing, background = true) {
context.clearRect(0, 0, c.width, c.height); context.save(); context.globalAlpha = background ? +$('bgAlpha').value / 100 : 0; context.fillStyle = $('bg').value; context.fillRect(0, 0, c.width, c.height); context.restore(); for (const o of objects) { context.save(); context.globalAlpha = o.opacity; context.strokeStyle = o.color; context.fillStyle = o.color; context.lineWidth = o.line; context.lineJoin = 'round'; context.lineCap = 'round'; if (o.type === 'image') { const im = images.get(o.src); if (im) context.drawImage(im, o.sx || 0, o.sy || 0, o.sw || im.width, o.sh || im.height, o.x, o.y, o.w, o.h) } else if (['number','check','cross','mouse','key'].includes(o.type)) { stampDraw(context,o) } else if (o.type === 'arrow') { const pts = arrowPath(o); if (pts.length) { context.beginPath(); pts.forEach((p, i) => i ? context.lineTo(p.x, p.y) : context.moveTo(p.x, p.y)); context.closePath(); context.fill() } } else { rounded(context, o); if (o.type === 'fill') context.fill(); else context.stroke(); textDraw(context, o); } context.restore() }
if (editing && chosen()) { const o = chosen(); context.save(); context.strokeStyle = '#2563eb'; context.lineWidth = 1; context.setLineDash([5, 4]); context.strokeRect(o.x - 4, o.y - 4, o.w + 8, o.h + 8); context.setLineDash([]); for (const p of points(o)) { context.fillStyle = 'white'; context.fillRect(p.x - 5, p.y - 5, 10, 10); context.strokeRect(p.x - 5, p.y - 5, 10, 10) } context.restore() }
}
const legacyDraw = () => { updateTextControls(); render(ctx, true); if (cropMode && cropBox) { ctx.save(); ctx.fillStyle = '#2563eb22'; ctx.fillRect(cropBox.x, cropBox.y, cropBox.w, cropBox.h); ctx.strokeStyle = '#2563eb'; ctx.lineWidth = 2; ctx.setLineDash([6, 4]); ctx.strokeRect(cropBox.x, cropBox.y, cropBox.w, cropBox.h); ctx.restore() } };
function cancelCrop() { cropMode = false; cropBox = null; $('cropApply').hidden = true; $('cropCancel').hidden = true }
$('crop').onclick = () => { if (chosen()?.type !== 'image') { message('切り抜きたい画像を先に選択してください。'); return } cropMode = true; cropBox = null; $('cropApply').hidden = false; $('cropCancel').hidden = false; message('画像内で残したい範囲をドラッグし、切り抜きを確定してください。'); draw() };
$('cropCancel').onclick = () => { cancelCrop(); draw() };
$('cropApply').onclick = () => { const o = chosen(), b = cropBox; if (!cropMode || o?.type !== 'image' || !b || b.w < 1 || b.h < 1) { message('画像内で切り抜き範囲を指定してください。'); return } record(); const im = images.get(o.src), sw = o.sw || im.width, sh = o.sh || im.height; o.sx = (o.sx || 0) + (b.x - o.x) / o.w * sw; o.sy = (o.sy || 0) + (b.y - o.y) / o.h * sh; o.sw = b.w / o.w * sw; o.sh = b.h / o.h * sh; o.x = b.x; o.y = b.y; o.w = b.w; o.h = b.h; cancelCrop(); draw(); message('画像を切り抜きました。元に戻す操作で復元できます。') };
function sync() { const o = chosen(); if (!o) return; $('color').value = o.color; $('line').value = o.line; $('opacity').value = Math.round(o.opacity * 100); $('opacityText').textContent = $('opacity').value + '%'; $('textValue').value = o.text || ''; textRange = { start: 0, end: 0 }; draftText = o.text || ''; draftColors = textColors(o); $('textColor').value = o.textColor || '#172238'; $('font').value = o.font || 'sans-serif'; $('fontSize').value = o.fontSize || 28; $('radius').value = o.radius ?? 12; $('textH').value=o.textH||'center';$('textV').value=o.textV||'middle';$('doubleArrow').checked=!!o.double;if(o.type==='key')$('keyValue').value=o.keyLabel||'Enter';if(o.type==='mouse')$('mouseKind').value=o.mouseKind||'left' }
function pos(e) { const r = c.getBoundingClientRect(); return { x: (e.clientX - r.left) * c.width / r.width, y: (e.clientY - r.top) * c.height / r.height } }
function hit(o, p) { if (o.type === 'arrow') { const len = o.w * o.w + o.h * o.h, t = len ? Math.max(0, Math.min(1, ((p.x - o.x) * o.w + (p.y - o.y) * o.h) / len)) : 0; return Math.hypot(p.x - o.x - t * o.w, p.y - o.y - t * o.h) < Math.max(10, o.line / 2 + 5) } return p.x >= o.x - 6 && p.x <= o.x + o.w + 6 && p.y >= o.y - 6 && p.y <= o.y + o.h + 6 }
c.onpointerdown = e => { if (e.button !== 0) return; c.focus(); const p = pos(e); c.setPointerCapture(e.pointerId); if (cropMode) { const o = chosen(); if (o?.type === 'image' && hit(o, p)) { gesture = { kind: 'crop', p: { x: Math.max(o.x, Math.min(o.x + o.w, p.x)), y: Math.max(o.y, Math.min(o.y + o.h, p.y)) } }; cropBox = null } return } if (tool === 'select') { const old = chosen(); const radius = 10 * c.width / c.getBoundingClientRect().width; const handle = old ? points(old).findIndex(q => Math.hypot(q.x - p.x, q.y - p.y) < radius) : -1; if (handle >= 0) { gesture = { kind: 'handle', p, original: { ...old }, handle, snapshot: state() } } else { const o = [...objects].reverse().find(o => hit(o, p)); selected = o?.id ?? null; if (o) gesture = { kind: 'move', p, original: { ...o }, snapshot: state() }; sync() } } else { if (tool === 'text' && !validTextSize()) return; record(); const o = { id: crypto.randomUUID(), type: tool, x: p.x, y: p.y, w: 0, h: 0, color: $('color').value, line: Math.max(1, Math.min(40, +$('line').value || 5)), radius: Math.max(0, Math.min(100, +$('radius').value || 0)), text: tool === 'text' ? $('textValue').value : '', textColors: tool === 'text' ? [...draftColors] : [], textColor: $('textColor').value, font: $('font').value, fontSize: +$('fontSize').value || 28, opacity: tool === 'fill' ? 0.3 : +$('opacity').value / 100 }; objects.push(o); selected = o.id; gesture = { kind: 'new', p }; sync() } draw() };
c.onpointermove = e => { if (!gesture) { if (tool === 'select' && !cropMode) { const p = pos(e), o = chosen(), radius = 10 * c.width / c.getBoundingClientRect().width; const handle = o ? points(o).findIndex(q => Math.hypot(q.x - p.x, q.y - p.y) < radius) : -1; c.style.cursor = handle >= 0 ? (o.type === 'arrow' ? 'crosshair' : handle % 2 === 0 ? 'nwse-resize' : 'nesw-resize') : objects.some(item => hit(item, p)) ? 'move' : 'default' } return; } const p = pos(e), o = chosen(), g = gesture; if (!o) return; if (g.kind === 'crop') { const q = { x: Math.max(o.x, Math.min(o.x + o.w, p.x)), y: Math.max(o.y, Math.min(o.y + o.h, p.y)) }; cropBox = { x: Math.min(q.x, g.p.x), y: Math.min(q.y, g.p.y), w: Math.abs(q.x - g.p.x), h: Math.abs(q.y - g.p.y) }; draw(); return } if (g.kind === 'move') { o.x = g.original.x + p.x - g.p.x; o.y = g.original.y + p.y - g.p.y } else if (g.kind === 'new') { let dx = p.x - g.p.x, dy = p.y - g.p.y; if (o.type === 'arrow') { const d = $('direction').value; if (d === 'horizontal' || (e.shiftKey && Math.abs(dx) >= Math.abs(dy))) dy = 0; else if (d === 'vertical' || e.shiftKey) dx = 0; o.w = dx; o.h = dy } else { o.x = Math.min(p.x, g.p.x); o.y = Math.min(p.y, g.p.y); o.w = Math.abs(dx); o.h = Math.abs(dy) } } else { const a = g.original; if (o.type === 'arrow') { if (g.handle === 0) { o.x = p.x; o.y = p.y; o.w = a.x + a.w - p.x; o.h = a.y + a.h - p.y } else { o.w = p.x - a.x; o.h = p.y - a.y } } else { const opposite = points(a)[(g.handle + 2) % 4]; let dx = p.x - opposite.x, dy = p.y - opposite.y; if (o.type === 'image' && !e.altKey) { const ratio = a.w / a.h; let w = Math.max(Math.abs(dx), Math.abs(dy) * ratio, 1); dx = (dx < 0 ? -1 : 1) * w; dy = (dy < 0 ? -1 : 1) * w / ratio } o.x = Math.min(opposite.x + dx, opposite.x); o.y = Math.min(opposite.y + dy, opposite.y); o.w = Math.max(1, Math.abs(dx)); o.h = Math.max(1, Math.abs(dy)) } } draw() };
function finish() { if (!gesture) return; const g = gesture; if (g.snapshot && g.snapshot !== state()) { history.push(g.snapshot); if (history.length > 60) history.shift(); future = [] } if (g.kind === 'new') { const o = chosen(); if (o && Math.hypot(o.w, o.h) < 3) { objects.pop(); history.pop(); selected = [...objects].reverse().find(item => hit(item, g.p))?.id ?? null; sync() } setTool('select') } gesture = null; draw() }
c.onpointerup = finish; c.onpointercancel = finish;
for (const id of ['color', 'line', 'opacity']) { $(id).oninput = () => { const o = chosen(); if (o) { record(); o.color = $('color').value; o.line = Math.max(1, Math.min(40, +$('line').value || 5)); o.opacity = +$('opacity').value / 100 } $('opacityText').textContent = $('opacity').value + '%'; draw() } }
function validTextSize() { const n = +$('fontSize').value; if (!Number.isFinite(n) || n < 8 || n > 200) { message('文字サイズを8〜200pxで指定してください。'); return false } return true }
const textObject = () => { const o = chosen(); return o && ['rect', 'fill', 'text'].includes(o.type) ? o : null };
function updateTextControls() { const enabled = !!textObject(); $('textEdit').disabled = !enabled; $('textEdit').title = enabled ? '文字入力(選択した図形内)' : '文字入力(四角形を選択してください)'; $('textValue').disabled = !enabled; $('allTextColor').disabled = !enabled; }
$('textEdit').onclick = () => { if (!textObject()) return; setTool('select'); sync(); draw(); $('settings').open = true; const input = $('textValue'); input.focus(); input.setSelectionRange(input.value.length, input.value.length); rememberTextRange(); message('選択した図形内の文字を編集できます。文字欄に入力してください。') };
function rememberTextRange() { textRange = { start: $('textValue').selectionStart, end: $('textValue').selectionEnd } }
for (const event of ['select', 'keyup', 'pointerup', 'input']) $('textValue').addEventListener(event, rememberTextRange);
$('textValue').oninput = () => { const value = $('textValue').value, o = textObject(); if (o) { record(); o.textColors = editColors(o.text || '', value, textColors(o), $('textColor').value); o.text = value; draftColors = [...o.textColors] } else draftColors = editColors(draftText, value, draftColors, $('textColor').value); draftText = value; rememberTextRange(); draw() };
function applyTextColor(all = false) { const o = textObject(), value = $('textValue').value; let colors = o ? textColors(o) : Array.from({ length: value.length }, (_, i) => draftColors[i] || '#172238'); const start = all ? 0 : Math.min(textRange.start, value.length), end = all ? value.length : Math.min(textRange.end, value.length); if (end > start) { if (o) record(); for (let i = start; i < end; i++)colors[i] = $('textColor').value; if (o) o.textColors = colors; draftColors = [...colors]; draw(); message(all ? '全文の色を変更しました。' : '選択した文字の色を変更しました。') } else message('次に入力する文字の色を設定しました。'); }
$('textColor').oninput = () => applyTextColor(); $('allTextColor').onclick = () => applyTextColor(true);
for (const id of ['font', 'fontSize', 'radius']) $(id).oninput = () => { if (!validTextSize()) return; const o = textObject(); if (o) { record(); o.font = $('font').value; o.fontSize = +$('fontSize').value; o.radius = Math.max(0, Math.min(100, +$('radius').value || 0)); draw() } };
for (const target of ['color', 'textColor', 'bg']) for (const [name, color] of [['赤', '#ef4444'], ['オレンジ', '#f97316'], ['黄色', '#facc15'], ['緑', '#16a34a'], ['青', '#2563eb'], ['紫', '#9333ea'], ['黒', '#172238'], ['白', '#ffffff']]) { const b = document.createElement('button'); b.type = 'button'; b.className = 'swatch'; b.title = name; b.setAttribute('aria-label', ({ color: '図形', textColor: '文字', bg: '背景' })[target] + 'の色:' + name); b.style.background = color; b.onpointerdown = e => e.preventDefault(); b.onclick = () => { $(target).value = color; $(target).dispatchEvent(new Event('input')) }; $(target + 'Palette').append(b) }
$('clear').onclick = () => { if (!objects.length) return; record(); objects = []; selected = null; gesture = null; cancelCrop(); setTool('select'); draw(); message('キャンバス内を一括クリアしました。「元に戻す」で復元できます。') };
document.querySelector('.file').onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $('imageFile').click() } };
function mutate(fn) { if (!chosen()) return; record(); fn(); draw() }
$('front').onclick = () => mutate(() => { const i = objects.findIndex(o => o.id === selected); objects.push(objects.splice(i, 1)[0]) });
$('back').onclick = () => mutate(() => { const i = objects.findIndex(o => o.id === selected); objects.unshift(objects.splice(i, 1)[0]) });
$('remove').onclick = () => mutate(() => { objects = objects.filter(o => o.id !== selected); selected = null });
$('duplicate').onclick = () => mutate(() => { const o = { ...chosen(), textColors: chosen().textColors ? [...chosen().textColors] : undefined, id: crypto.randomUUID() }; o.x += 20; o.y += 20; objects.push(o); selected = o.id });
$('undo').onclick = () => { if (history.length) { future.push(state()); restore(history.pop()) } }; $('redo').onclick = () => { if (future.length) { history.push(state()); restore(future.pop()) } };
$('resize').onclick = () => { const w = Math.round(+$('cw').value), h = Math.round(+$('ch').value); if (w < 100 || h < 100 || w > 6000 || h > 6000 || !Number.isFinite(w + h)) { message('幅・高さは100〜6000にしてください。'); return } record(); c.width = w; c.height = h; draw() };
for (const id of ['bg', 'bgAlpha']) $(id).oninput = () => { record(); $('bgText').textContent = $('bgAlpha').value + '%'; draw() };
async function addImage(blob) { if (!blob || !blob.type.startsWith('image/')) return; try { const src = await new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(r.result); r.onerror = reject; r.readAsDataURL(blob) }); const im = await new Promise((resolve, reject) => { const i = new Image(); i.onload = () => resolve(i); i.onerror = reject; i.src = src }); if (im.width > 6000 || im.height > 6000) { message('画像は縦・横6000ピクセル以内にしてください。'); return } record(); images.set(src, im); c.width = Math.max(c.width, im.width); c.height = Math.max(c.height, im.height); $('cw').value = c.width; $('ch').value = c.height; const ratio = 1; const o = { id: crypto.randomUUID(), type: 'image', src, x: 0, y: 0, w: im.width * ratio, h: im.height * ratio, color: '#ef4444', line: 5, opacity: 1 }; objects.push(o); selectOnly(o.id); setTool('select'); sync(); draw(); message('画像を追加しました。大きな画像はキャンバスを自動で広げます。画像をドラッグで移動するか、枠・矢印を選んで描画してください。') } catch { message('画像を読み込めませんでした。別の画像で試してください。') } }
$('imageFile').onchange = e => { addImage(e.target.files[0]); e.target.value = '' };
document.addEventListener('paste', e => { const items = [...e.clipboardData.items].filter(i => i.type.startsWith('image/')); if (items.length) { e.preventDefault(); for (const i of items) addImage(i.getAsFile()) } });
$('paste').onclick = async () => { try { if (!navigator.clipboard?.read) throw Error(); const items = await navigator.clipboard.read(); for (const i of items) { const type = i.types.find(t => t.startsWith('image/')); if (type) { await addImage(await i.getType(type)); return } } message('画像が見つかりません。スクリーンショットをコピーしてCtrl+Vで貼り付けてください。') } catch { message('この環境では貼り付けボタンを使えません。Ctrl+Vで貼り付けるか、画像を読み込んでください。') } };
function exportCanvas() { const raw = document.createElement('canvas'); raw.width = c.width; raw.height = c.height; const r = raw.getContext('2d', { willReadFrequently: true }); render(r, false, false); const data = r.getImageData(0, 0, raw.width, raw.height).data; let left = raw.width, top = raw.height, right = -1, bottom = -1; for (let y = 0; y < raw.height; y++)for (let x = 0; x < raw.width; x++) { if (data[(y * raw.width + x) * 4 + 3]) { left = Math.min(left, x); right = Math.max(right, x); top = Math.min(top, y); bottom = Math.max(bottom, y) } } if (right < 0) throw Error('empty'); const out = document.createElement('canvas'); out.width = right - left + 1; out.height = bottom - top + 1; const oc = out.getContext('2d'); oc.save(); oc.globalAlpha = +$('bgAlpha').value / 100; oc.fillStyle = $('bg').value; oc.fillRect(0, 0, out.width, out.height); oc.restore(); oc.drawImage(raw, left, top, out.width, out.height, 0, 0, out.width, out.height); return out }
const png = () => new Promise((resolve, reject) => { try { exportCanvas().toBlob(b => b ? resolve(b) : reject(Error()), 'image/png') } catch (e) { reject(e) } });
function exportName() { const d = new Date(), p = n => String(n).padStart(2, '0'); return 'EXPIC_' + p(d.getFullYear() % 100) + p(d.getMonth() + 1) + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds()) + '.png' }
$('save').onclick = async () => { try { const b = await png(), url = URL.createObjectURL(b), a = document.createElement('a'); a.href = url; a.download = exportName(); a.click(); setTimeout(() => URL.revokeObjectURL(url), 10000); message('PNG画像を保存しました。') } catch { message('保存できませんでした。画像・図形があることを確認し、大きなキャンバスはサイズを小さくしてください。') } };
$('copy').onclick = async () => { try { if (!navigator.clipboard?.write || !window.ClipboardItem) throw Error(); const job = png(); await navigator.clipboard.write([new ClipboardItem({ 'image/png': job })]); message('PNG画像をコピーしました。貼り付け先でCtrl+Vを押してください。') } catch { message('PNGをコピーできませんでした。画像・図形があることを確認し、コピーが制限される環境ではPNGを保存してください。') } };
document.addEventListener('keydown', e => { if (['INPUT', 'SELECT', 'TEXTAREA'].includes(e.target.tagName)) return; const mod = e.ctrlKey || e.metaKey; if (mod && e.key.toLowerCase() === 'z') { e.preventDefault(); $(e.shiftKey ? 'redo' : 'undo').click() } else if (mod && e.key.toLowerCase() === 'y') { e.preventDefault(); $('redo').click() } else if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); $('remove').click() } else if (e.key === 'Escape') { cancelCrop(); selected = null; setTool('select'); draw() } });
$('help').ontoggle = () => { $('help').querySelector('summary').textContent = $('help').open ? '説明を閉じる' : '説明を表示' };
// Multiple selection, compact annotation tools, and browser-local persistence.
const selectionIds=new Set();let marquee=null,saveTimer=null,autoReady=false,lastSaved='',lastObserved='',editRevision=0,saveChain=Promise.resolve();
function selectOnly(id){selectionIds.clear();selected=id;if(id)selectionIds.add(id)}
function selectedObjects(){return objects.filter(o=>selectionIds.has(o.id))}
function bounds(o){return {x:Math.min(o.x,o.x+o.w),y:Math.min(o.y,o.y+o.h),w:Math.abs(o.w),h:Math.abs(o.h)}}
function draw(){
if(selected&&!selectionIds.has(selected)){selectionIds.clear();selectionIds.add(selected)}
for(const id of selectionIds)if(!objects.some(o=>o.id===id))selectionIds.delete(id);
if(!selected)selectionIds.clear();updateTextControls();render(ctx,false);
ctx.save();ctx.strokeStyle='#2563eb';ctx.lineWidth=1;for(const o of cropMode?[]:selectedObjects()){const b=bounds(o);ctx.setLineDash([5,4]);ctx.strokeRect(b.x-4,b.y-4,b.w+8,b.h+8);ctx.setLineDash([]);if(selectionIds.size===1)for(const p of points(o)){ctx.fillStyle='white';ctx.fillRect(p.x-5,p.y-5,10,10);ctx.strokeRect(p.x-5,p.y-5,10,10)}}
const box=cropMode?cropBox:marquee;if(box){ctx.fillStyle='#2563eb22';ctx.fillRect(box.x,box.y,box.w,box.h);ctx.setLineDash([6,4]);ctx.strokeRect(box.x,box.y,box.w,box.h);if(cropMode){ctx.setLineDash([]);const size=8*c.width/c.getBoundingClientRect().width;for(const p of cropHandles(box)){ctx.fillStyle='white';ctx.fillRect(p.x-size/2,p.y-size/2,size,size);ctx.strokeRect(p.x-size/2,p.y-size/2,size,size)}}}ctx.restore();
document.querySelectorAll('[data-align]').forEach(b=>b.disabled=selectionIds.size<(b.dataset.align==='horizontal'||b.dataset.align==='vertical'?3:2));
const current=state();if(current!==lastObserved){lastObserved=current;editRevision++}if(autoReady&&!gesture&¤t!==lastSaved)scheduleSave();
}
function insideBox(b,p){return p.x>=b.x&&p.x<=b.x+b.w&&p.y>=b.y&&p.y<=b.y+b.h}
function cropClamp(o,p){const b=bounds(o);return {x:Math.max(b.x,Math.min(b.x+b.w,p.x)),y:Math.max(b.y,Math.min(b.y+b.h,p.y))}}
function cropHandles(b){return [{x:b.x,y:b.y},{x:b.x+b.w/2,y:b.y},{x:b.x+b.w,y:b.y},{x:b.x+b.w,y:b.y+b.h/2},{x:b.x+b.w,y:b.y+b.h},{x:b.x+b.w/2,y:b.y+b.h},{x:b.x,y:b.y+b.h},{x:b.x,y:b.y+b.h/2}]}
function cropHandleAt(p){if(!cropBox)return -1;const radius=9*c.width/c.getBoundingClientRect().width;return cropHandles(cropBox).findIndex(q=>Math.hypot(q.x-p.x,q.y-p.y)<=radius)}
function resizeCrop(b,handle,p){let left=b.x,right=b.x+b.w,top=b.y,bottom=b.y+b.h;if([0,6,7].includes(handle))left=Math.min(p.x,right-1);if([2,3,4].includes(handle))right=Math.max(p.x,left+1);if([0,1,2].includes(handle))top=Math.min(p.y,bottom-1);if([4,5,6].includes(handle))bottom=Math.max(p.y,top+1);return {x:left,y:top,w:right-left,h:bottom-top}}
function stampDraw(context,o){
context.save();context.translate(o.x,o.y);context.scale(o.w/100,o.h/100);context.lineWidth=7;context.strokeStyle=o.color;context.fillStyle=o.color;
if(o.type==='number'){context.beginPath();context.arc(50,50,46,0,Math.PI*2);context.fill();context.fillStyle=o.textColor||'#ffffff';context.font='bold '+(String(o.number).length>2?32:46)+'px sans-serif';context.textAlign='center';context.textBaseline='middle';context.fillText(String(o.number||1),50,52)}
else if(o.type==='check'||o.type==='cross'){context.beginPath();if(o.type==='check'){context.moveTo(12,52);context.lineTo(38,78);context.lineTo(88,20)}else{context.moveTo(18,18);context.lineTo(82,82);context.moveTo(82,18);context.lineTo(18,82)}context.stroke()}
else if(o.type==='mouse'){context.fillStyle='#ffffff';context.beginPath();context.roundRect(25,8,50,72,24);context.fill();context.stroke();context.save();context.beginPath();context.roundRect(25,8,50,72,24);context.clip();context.fillStyle=o.color;context.globalAlpha=.35;context.fillRect(o.mouseKind==='right'?50:25,8,25,28);context.restore();context.beginPath();context.moveTo(25,36);context.lineTo(75,36);context.moveTo(50,8);context.lineTo(50,36);context.stroke();context.fillStyle=o.color;context.textAlign='center';context.font='bold 15px sans-serif';context.fillText(o.mouseKind==='right'?'右クリック':o.mouseKind==='double'?'ダブルクリック':'左クリック',50,97);if(o.mouseKind==='double'){context.font='bold 18px sans-serif';context.fillText('2',50,61)}}
else if(o.type==='key'){context.fillStyle='#ffffff';context.lineWidth=3;context.beginPath();context.roundRect(2,4,96,92,12);context.fill();context.stroke();context.fillStyle=o.color;context.font='bold 30px sans-serif';context.textAlign='center';context.textBaseline='middle';context.fillText(o.keyLabel||'Enter',50,52,86)}context.restore();
}
function newObject(type,p){const stamp=['number','check','cross','mouse','key'].includes(type);const o={id:crypto.randomUUID(),type,x:p.x,y:p.y,w:stamp?(type==='key'?160:type==='mouse'?70:52):0,h:stamp?(type==='key'?44:type==='mouse'?92:52):0,color:$('color').value,line:Math.max(1,Math.min(40,+$('line').value||5)),radius:Math.max(0,+$('radius').value||0),opacity:type==='fill'?.3:+$('opacity').value/100,font:$('font').value,fontSize:+$('fontSize').value||28,textColor:type==='number'?'#ffffff':$('textColor').value,textH:$('textH').value,textV:$('textV').value,text:'',double:$('doubleArrow').checked};
if(type==='number')o.number=Math.max(1,Math.min(999,+$('numberValue').value||1));if(type==='mouse')o.mouseKind=$('mouseKind').value;if(type==='key')o.keyLabel=$('keyValue').value.trim()||'Enter';return o}
c.onpointerdown=e=>{
if(e.button!==0)return;e.preventDefault();c.focus();const p=pos(e);c.setPointerCapture(e.pointerId);
if(cropMode){const o=chosen();if(o?.type!=='image')return;const handle=cropHandleAt(p);if(handle>=0){gesture={kind:'crop-resize',p,handle,original:{...cropBox}}}else if(cropBox&&insideBox(cropBox,p)){gesture={kind:'crop-move',p,original:{...cropBox}}}else if(insideBox(bounds(o),p)){gesture={kind:'crop',p:cropClamp(o,p)};cropBox=null}draw();return}
if(tool==='select'){
const old=chosen(),radius=10*c.width/c.getBoundingClientRect().width,handle=old&&selectionIds.size===1?points(old).findIndex(q=>Math.hypot(q.x-p.x,q.y-p.y)<radius):-1;
if(handle>=0){gesture={kind:'handle',p,original:{...old},handle,snapshot:state()}}
else{const o=[...objects].reverse().find(o=>hit(o,p));if(o){if(e.shiftKey||e.ctrlKey||e.metaKey){if(selectionIds.has(o.id))selectionIds.delete(o.id);else selectionIds.add(o.id);selected=selectionIds.has(o.id)?o.id:[...selectionIds].at(-1)||null}else if(!selectionIds.has(o.id))selectOnly(o.id);else selected=o.id;if(selectionIds.has(o.id))gesture={kind:'move',p,originals:selectedObjects().map(o=>({...o})),snapshot:state()};sync()}
else{if(!(e.shiftKey||e.ctrlKey||e.metaKey))selectOnly(null);gesture={kind:'marquee',p,base:[...selectionIds]};marquee={x:p.x,y:p.y,w:0,h:0}}}
}else{const o=newObject(tool,p);objects.push(o);selectOnly(o.id);gesture={kind:'new',p,snapshot:JSON.stringify({objects:objects.slice(0,-1),w:c.width,h:c.height,bg:$('bg').value,alpha:$('bgAlpha').value}),stamp:['number','check','cross','mouse','key'].includes(tool)};sync()}draw();
};
c.onpointermove=e=>{
const p=pos(e);if(!gesture){if(cropMode){const handle=cropHandleAt(p);c.style.cursor=handle>=0?['nwse-resize','ns-resize','nesw-resize','ew-resize','nwse-resize','ns-resize','nesw-resize','ew-resize'][handle]:cropBox&&insideBox(cropBox,p)?'move':'crosshair'}else if(tool==='select')c.style.cursor=objects.some(o=>hit(o,p))?'move':'default';return}const g=gesture,o=chosen();
if(g.kind==='marquee'){marquee={x:Math.min(p.x,g.p.x),y:Math.min(p.y,g.p.y),w:Math.abs(p.x-g.p.x),h:Math.abs(p.y-g.p.y)};selectionIds.clear();g.base.forEach(id=>selectionIds.add(id));for(const item of objects){const b=bounds(item);if(b.x>=marquee.x&&b.y>=marquee.y&&b.x+b.w<=marquee.x+marquee.w&&b.y+b.h<=marquee.y+marquee.h)selectionIds.add(item.id)}selected=[...selectionIds].at(-1)||null;draw();return}
if(!o)return;if(g.kind==='crop'){const q={x:Math.max(o.x,Math.min(o.x+o.w,p.x)),y:Math.max(o.y,Math.min(o.y+o.h,p.y))};cropBox={x:Math.min(q.x,g.p.x),y:Math.min(q.y,g.p.y),w:Math.abs(q.x-g.p.x),h:Math.abs(q.y-g.p.y)}}
else if(g.kind==='crop-move'){const b=g.original,limit=bounds(o);cropBox={...b,x:Math.max(limit.x,Math.min(limit.x+limit.w-b.w,b.x+p.x-g.p.x)),y:Math.max(limit.y,Math.min(limit.y+limit.h-b.h,b.y+p.y-g.p.y))}}
else if(g.kind==='crop-resize'){cropBox=resizeCrop(g.original,g.handle,cropClamp(o,p))}
else if(g.kind==='move'){for(const original of g.originals){const item=objects.find(o=>o.id===original.id);item.x=original.x+p.x-g.p.x;item.y=original.y+p.y-g.p.y}}
else if(g.kind==='new'){let dx=p.x-g.p.x,dy=p.y-g.p.y;if(o.type==='arrow'){const d=$('direction').value;if(d==='horizontal'||(e.shiftKey&&Math.abs(dx)>=Math.abs(dy)))dy=0;else if(d==='vertical'||e.shiftKey)dx=0;o.w=dx;o.h=dy}else if(!g.stamp||Math.hypot(dx,dy)>5){o.x=Math.min(p.x,g.p.x);o.y=Math.min(p.y,g.p.y);o.w=Math.max(1,Math.abs(dx));o.h=Math.max(1,Math.abs(dy))}}
else{const a=g.original;if(o.type==='arrow'){if(g.handle===0){o.x=p.x;o.y=p.y;o.w=a.x+a.w-p.x;o.h=a.y+a.h-p.y}else{o.w=p.x-a.x;o.h=p.y-a.y}}else{const q=points(a)[(g.handle+2)%4];let dx=p.x-q.x,dy=p.y-q.y;if(o.type==='image'&&!e.altKey){const ratio=a.w/a.h,w=Math.max(Math.abs(dx),Math.abs(dy)*ratio,1);dx=(dx<0?-1:1)*w;dy=(dy<0?-1:1)*w/ratio}o.x=Math.min(q.x+dx,q.x);o.y=Math.min(q.y+dy,q.y);o.w=Math.max(1,Math.abs(dx));o.h=Math.max(1,Math.abs(dy))}}draw();
};
function endGesture(){if(!gesture)return;const g=gesture;if(g.kind==='new'){const o=chosen();if(!g.stamp&&o&&Math.hypot(o.w,o.h)<3){objects.pop();selectOnly([...objects].reverse().find(o=>hit(o,g.p))?.id||null);sync()}else if(o?.type==='number')$('numberValue').value=Math.min(999,o.number+1);setTool('select')}
if(g.snapshot&&g.snapshot!==state()){history.push(g.snapshot);if(history.length>60)history.shift();future=[]}gesture=null;marquee=null;draw()}
c.onpointerup=endGesture;c.onpointercancel=endGesture;
for(const id of ['textH','textV'])$(id).onchange=()=>{const o=textObject();if(!o)return;record();o.textH=$('textH').value;o.textV=$('textV').value;draw()};
$('keyValue').oninput=()=>{const o=chosen();if(o?.type==='key'){record();o.keyLabel=$('keyValue').value.trim()||'Enter';draw()}};
$('mouseKind').onchange=()=>{const o=chosen();if(o?.type==='mouse'){record();o.mouseKind=$('mouseKind').value;draw()}};
$('doubleArrow').onchange=()=>{const o=chosen();if(o?.type==='arrow'){record();o.double=$('doubleArrow').checked;draw()}};
document.querySelectorAll('[data-align]').forEach(b=>b.onclick=()=>alignSelection(b.dataset.align));
function alignSelection(mode){const list=selectedObjects();if(list.length<(mode==='horizontal'||mode==='vertical'?3:2))return;record();const bs=list.map(bounds),left=Math.min(...bs.map(b=>b.x)),right=Math.max(...bs.map(b=>b.x+b.w)),top=Math.min(...bs.map(b=>b.y)),bottom=Math.max(...bs.map(b=>b.y+b.h));
if(mode==='horizontal'||mode==='vertical'){const axis=mode==='horizontal'?'x':'y',size=axis==='x'?'w':'h',sorted=[...list].sort((a,b)=>bounds(a)[axis]-bounds(b)[axis]),first=bounds(sorted[0]),last=bounds(sorted.at(-1)),gap=(last[axis]+last[size]-first[axis]-sorted.reduce((n,o)=>n+bounds(o)[size],0))/(sorted.length-1);let cursor=first[axis];for(const o of sorted){const b=bounds(o);o[axis]+=cursor-b[axis];cursor+=b[size]+gap}}
else for(const o of list){const b=bounds(o);if(mode==='left')o.x+=left-b.x;if(mode==='right')o.x+=right-b.x-b.w;if(mode==='center')o.x+=(left+right-b.w)/2-b.x;if(mode==='top')o.y+=top-b.y;if(mode==='bottom')o.y+=bottom-b.y-b.h;if(mode==='middle')o.y+=(top+bottom-b.h)/2-b.y}draw();message('選択したオブジェクトを配置しました。')}
$('remove').onclick=()=>{if(!selectionIds.size)return;record();objects=objects.filter(o=>!selectionIds.has(o.id));selectOnly(null);draw()};
$('duplicate').onclick=()=>{const list=selectedObjects();if(!list.length)return;record();selectionIds.clear();for(const item of list){const o={...item,textColors:item.textColors?[...item.textColors]:undefined,id:crypto.randomUUID(),x:item.x+20,y:item.y+20};objects.push(o);selectionIds.add(o.id);selected=o.id}sync();draw()};
$('front').onclick=()=>{if(!selectionIds.size)return;record();objects=[...objects.filter(o=>!selectionIds.has(o.id)),...selectedObjects()];draw()};
$('back').onclick=()=>{if(!selectionIds.size)return;record();objects=[...selectedObjects(),...objects.filter(o=>!selectionIds.has(o.id))];draw()};
for(const id of ['color','line','opacity'])$(id).oninput=()=>{const list=selectedObjects();if(list.length){record();for(const o of list){o.color=$('color').value;o.line=Math.max(1,Math.min(40,+$('line').value||5));o.opacity=+$('opacity').value/100}}$('opacityText').textContent=$('opacity').value+'%';draw()};
const stage=document.querySelector('.stage');stage.addEventListener('dragover',e=>{e.preventDefault();e.dataTransfer.dropEffect='copy';stage.classList.add('drop-active')});stage.addEventListener('dragleave',e=>{if(!stage.contains(e.relatedTarget))stage.classList.remove('drop-active')});stage.addEventListener('drop',async e=>{e.preventDefault();stage.classList.remove('drop-active');const files=[...e.dataTransfer.files].filter(f=>f.type.startsWith('image/'));if(!files.length){message('画像ファイルをドロップしてください。');return}for(const file of files)await addImage(file)});
const openDB=()=>new Promise((resolve,reject)=>{if(!window.indexedDB){reject(Error('unavailable'));return}const req=window.indexedDB.open('screenshot-editor-autosave',1);req.onupgradeneeded=()=>req.result.createObjectStore('draft');req.onsuccess=()=>resolve(req.result);req.onerror=()=>reject(req.error);req.onblocked=()=>reject(Error('blocked'))});
let dbPromise=null;
async function draftStore(action,value){const db=await(dbPromise??=openDB());return new Promise((resolve,reject)=>{const tx=db.transaction('draft',action==='get'?'readonly':'readwrite'),store=tx.objectStore('draft'),req=action==='get'?store.get('current'):store.put(value,'current');let result;req.onsuccess=()=>{result=req.result};tx.oncomplete=()=>resolve(result);tx.onerror=()=>reject(tx.error);tx.onabort=()=>reject(tx.error||Error('aborted'))})}
function scheduleSave(){clearTimeout(saveTimer);$('autosaveStatus').textContent='自動保存待ち';saveTimer=setTimeout(saveNow,500)}
function saveNow(){clearTimeout(saveTimer);if(!autoReady)return;const snapshot=state();if(snapshot===lastSaved)return;const payload={version:1,snapshot,savedAt:Date.now()};$('autosaveStatus').textContent='保存中…';saveChain=saveChain.catch(()=>{}).then(()=>draftStore('put',payload)).then(()=>{lastSaved=snapshot;$('autosaveStatus').textContent=snapshot===state()?'自動保存済み':'自動保存待ち'}).catch(()=>{$('autosaveStatus').textContent='自動保存できません(PNGで保存)'});return saveChain}
async function restoreDraft(){const revision=editRevision;try{const draft=await draftStore('get');if(draft){if(draft.version!==1||typeof draft.snapshot!=='string')throw Error('invalid');const v=JSON.parse(draft.snapshot);if(!Array.isArray(v.objects)||!Number.isFinite(v.w)||!Number.isFinite(v.h)||v.w<1||v.h<1||v.w>6000||v.h>6000)throw Error('invalid');const loaded=await Promise.all([...new Set(v.objects.filter(o=>o.type==='image').map(o=>o.src))].map(src=>new Promise((resolve,reject)=>{if(typeof src!=='string'||!src.startsWith('data:image/')){reject(Error('invalid image'));return}const im=new Image();im.onload=()=>resolve([src,im]);im.onerror=reject;im.src=src})));if(editRevision===revision){loaded.forEach(([src,im])=>images.set(src,im));restore(draft.snapshot);setTool('select');lastSaved=state();message('前回の編集内容を復元しました。')}}$('autosaveStatus').textContent='自動保存有効';autoReady=true;draw()}catch{$('autosaveStatus').textContent='自動保存・復元を利用できません';autoReady=false}}
document.addEventListener('visibilitychange',()=>{if(document.visibilityState==='hidden')saveNow()});window.addEventListener('pagehide',saveNow);
for(const id of ['settings','extras','canvasSettings'])$(id).addEventListener('toggle',()=>{if($(id).open)for(const other of ['settings','extras','canvasSettings'])if(other!==id)$(other).open=false});
document.addEventListener('pointerdown',e=>{if(!e.target.closest('.left-sidebar'))for(const id of ['settings','extras','canvasSettings'])$(id).open=false});
draw();restoreDraft();
</script>
</html>