// FFI - JavaScript との相互運用
///|
/// Popover を表示してボタン位置に配置
pub extern "js" fn show_popover_at_button(popover_id : String, e : @js.Any) =
#| (popoverId, e) => {
#| const popover = document.getElementById(popoverId);
#| if (!popover) return;
#| const btn = e.currentTarget;
#| const rect = btn.getBoundingClientRect();
#| popover.showPopover();
#| popover.style.left = (rect.left + rect.width / 2) + 'px';
#| popover.style.top = (rect.bottom + 6) + 'px';
#| popover.style.transform = 'translateX(-50%)';
#| }
///|
/// Popover を非表示
pub extern "js" fn hide_popover(popover_id : String) =
#| (popoverId) => {
#| const popover = document.getElementById(popoverId);
#| if (popover) popover.hidePopover();
#| }
///|
/// デバッグログ
pub extern "js" fn debug_log(msg : String) =
#| (msg) => console.log('[DEBUG]', msg)
///|
/// window に keydown イベントリスナーを追加
pub extern "js" fn add_keyboard_listener(handler : (@js.Any) -> Unit) =
#| (handler) => window.addEventListener('keydown', handler)
///|
/// keydown リスナー
pub extern "js" fn add_keydown_listener(handler : (@js.Any) -> Unit) =
#| (handler) => window.addEventListener('keydown', handler)
///|
/// keyup リスナー
pub extern "js" fn add_keyup_listener(handler : (@js.Any) -> Unit) =
#| (handler) => window.addEventListener('keyup', handler)
///|
/// 要素から data-id を持つ最も近い親要素の data-id を取得
pub extern "js" fn get_closest_data_id_ffi(el : @js.Any) -> @js.Any =
#| (el) => {
#| const closest = el.closest('[data-id]');
#| return closest ? closest.dataset.id : null;
#| }
///|
/// SVG の viewBox を更新
pub extern "js" fn update_viewbox_ffi(svg : @js.Any, viewbox : String) =
#| (svg, viewbox) => svg.setAttribute('viewBox', viewbox)
///|
/// ファイルをダウンロード
pub extern "js" fn download_file(
content : String,
filename : String,
mime_type : String,
) =
#| (content, filename, mimeType) => {
#| const blob = new Blob([content], { type: mimeType });
#| const url = URL.createObjectURL(blob);
#| const a = document.createElement('a');
#| a.href = url;
#| a.download = filename;
#| document.body.appendChild(a);
#| a.click();
#| document.body.removeChild(a);
#| URL.revokeObjectURL(url);
#| }
///|
/// 現在時刻を取得(ミリ秒)
pub extern "js" fn get_current_time() -> Double =
#| () => performance.now()
///|
/// 指定セレクタの要素にフォーカスを遅延設定(初期値付き)
pub extern "js" fn schedule_focus_with_value_ffi(
selector : String,
value : String,
) -> Int =
#| (selector, value) => setTimeout(() => { const el = document.querySelector(selector); if (el) { el.value = value; el.focus(); el.select(); } }, 0)
///|
/// 指定セレクタのテキストエリアにフォーカスを遅延設定(IMEコンポジション対応)
pub extern "js" fn schedule_textarea_focus_ffi(
selector : String,
value : String,
) -> Int =
#| (selector, value) => setTimeout(() => {
#| const el = document.querySelector(selector);
#| if (el) {
#| el.value = value;
#| el.focus();
#| // カーソルを末尾に移動
#| el.setSelectionRange(value.length, value.length);
#| // コンポジション状態を追跡
#| window.__isComposing = false;
#| el.addEventListener('compositionstart', () => { window.__isComposing = true; });
#| el.addEventListener('compositionend', () => { window.__isComposing = false; });
#| }
#| }, 0)
///|
/// IMEコンポジション中かどうかを取得
pub extern "js" fn is_composing_ffi() -> Bool =
#| () => window.__isComposing === true
///|
/// テキストエリアを作成(IMEコンポジション対応)
pub extern "js" fn create_textarea_ffi(
style : String,
initial_value : String,
on_commit : (String) -> Unit,
on_escape : (String) -> Unit,
on_input : (String) -> Unit,
) -> @js.Any =
#| (style, initialValue, onCommit, onEscape, onInput) => {
#| const textarea = document.createElement('textarea');
#| textarea.id = 'inline-text-edit';
#| textarea.style.cssText = style;
#| textarea.rows = 1;
#| textarea.value = initialValue;
#| let isComposing = false;
#| let isClosed = false;
#| textarea.addEventListener('compositionstart', () => { isComposing = true; });
#| textarea.addEventListener('compositionend', () => { isComposing = false; });
#| textarea.addEventListener('keydown', (e) => {
#| if (e.key === 'Enter' && !isComposing) {
#| // Enter(Shift有無問わず、IMEコンポジション中でない場合): 確定
#| e.preventDefault();
#| isClosed = true;
#| onCommit(textarea.value);
#| } else if (e.key === 'Escape') {
#| // Escape: キャンセル(初期値に戻す)
#| e.preventDefault();
#| isClosed = true;
#| onEscape(initialValue);
#| }
#| });
#| textarea.addEventListener('blur', () => {
#| if (isClosed) return;
#| isClosed = true;
#| onCommit(textarea.value);
#| });
#| textarea.addEventListener('input', () => {
#| // 横幅を自動調整(テキストに合わせて伸びる)
#| textarea.style.width = 'auto';
#| textarea.style.width = Math.max(textarea.scrollWidth, 40) + 'px';
#| // リアルタイムでテキスト要素を更新(閉じられていなければ)
#| if (isClosed) return;
#| onInput(textarea.value);
#| });
#| // フォーカスを遅延設定
#| setTimeout(() => {
#| textarea.focus();
#| textarea.setSelectionRange(initialValue.length, initialValue.length);
#| }, 0);
#| return textarea;
#| }
///|
/// イベントから target.value を取得
pub extern "js" fn get_event_target_value(e : @js.Any) -> String =
#| (e) => e.target.value
///|
/// 文字列を Double にパース(FFI)
extern "js" fn parse_double_ffi(s : String) -> @js.Any =
#| (s) => { const n = parseFloat(s); return isNaN(n) ? null : n; }
///|
/// 文字列を Double にパース
pub fn parse_double(s : String) -> Double? {
let result = parse_double_ffi(s)
if @js.is_nullish(result) {
None
} else {
Some(result.cast())
}
}
///|
/// SVG text 要素に複数行テキストを設定(tspan使用)
pub extern "js" fn set_multiline_text_ffi(
text_el : @js.Any,
content : String,
x : Double,
font_size : Double,
) =
#| (textEl, content, x, fontSize) => {
#| // 単一行の場合は textContent を直接設定(高速)
#| if (!content.includes('\n')) {
#| textEl.textContent = content;
#| return;
#| }
#| // 複数行の場合は tspan を使用
#| const lines = content.split('\n');
#| const lineHeight = fontSize * 1.2;
#| const totalHeight = (lines.length - 1) * lineHeight;
#| const startOffset = -totalHeight / 2;
#| lines.forEach((line, i) => {
#| const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
#| tspan.setAttribute('x', x);
#| tspan.setAttribute('dy', i === 0 ? startOffset : lineHeight);
#| tspan.textContent = line || ' ';
#| textEl.appendChild(tspan);
#| });
#| }
///|
/// SVG コンテナの子要素をクリア
pub extern "js" fn clear_children_ffi(el : @js.Any) =
#| (el) => { while (el.firstChild) el.removeChild(el.firstChild); }
///|
/// 子要素を安全に削除(存在しない場合は何もしない)
pub extern "js" fn remove_child_safe_ffi(parent : @js.Any, child : @js.Any) =
#| (parent, child) => { try { if (child && child.parentNode === parent) parent.removeChild(child); } catch(e) {} }
///|
/// 要素を親から削除
pub extern "js" fn remove_element_ffi(el : @js.Any) =
#| (el) => { if (el && el.parentNode) el.parentNode.removeChild(el); }
///|
/// 指定した子要素の前に挿入
pub extern "js" fn insert_before_ffi(
parent : @js.Any,
new_child : @js.Any,
ref_child : @js.Any,
) =
#| (parent, newChild, refChild) => parent.insertBefore(newChild, refChild)
///|
/// ResizeObserver で要素のサイズ変更を監視
pub extern "js" fn observe_resize_ffi(
el : @js.Any,
callback : (Double, Double) -> Unit,
) =
#| (el, callback) => {
#| const observer = new ResizeObserver((entries) => {
#| for (const entry of entries) {
#| const { width, height } = entry.contentRect;
#| callback(width, height);
#| }
#| });
#| observer.observe(el);
#| }
///|
/// 入力要素にフォーカスがあるかチェック
pub extern "js" fn is_input_focused_ffi() -> Bool =
#| () => {
#| const el = document.activeElement;
#| if (!el) return false;
#| const tag = el.tagName.toLowerCase();
#| return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable;
#| }
///|
/// ウィンドウサイズを監視
pub extern "js" fn observe_window_resize_ffi(callback : (Int, Int) -> Unit) =
#| (callback) => {
#| callback(window.innerWidth, window.innerHeight);
#| window.addEventListener('resize', () => {
#| callback(window.innerWidth, window.innerHeight);
#| });
#| }
///|
/// 子要素を取得
pub extern "js" fn get_child_at_ffi(parent : @js.Any, index : Int) -> @js.Any =
#| (parent, index) => parent.children[index] || null
///|
/// SVG の背景スタイルを更新
pub extern "js" fn update_svg_background_ffi(
svg : @js.Any,
background : String,
) =
#| (svg, background) => svg.style.background = background
///|
/// URL の search パラメータを取得
pub extern "js" fn get_url_search_ffi() -> String =
#| () => window.location.search
///|
/// SVG の CSS 変数を更新
pub extern "js" fn update_svg_css_vars_ffi(
svg : @js.Any,
stroke : String,
fill : String,
text_color : String,
) =
#| (svg, stroke, fill, textColor) => {
#| svg.style.setProperty('--ml-stroke', stroke);
#| svg.style.setProperty('--ml-fill', fill);
#| svg.style.setProperty('--ml-text', textColor);
#| }
// ============================================================
// クリップボード API
// ============================================================
///|
/// テキストをクリップボードにコピー
pub extern "js" fn copy_text_to_clipboard(
text : String,
on_done : () -> Unit,
on_error : () -> Unit,
) =
#| (text, onDone, onError) => {
#| navigator.clipboard.writeText(text)
#| .then(() => onDone())
#| .catch(() => onError());
#| }
///|
/// SVGをPNG画像としてクリップボードにコピー
pub extern "js" fn copy_svg_as_image(
svg_str : String,
width : Int,
height : Int,
on_done : () -> Unit,
on_error : () -> Unit,
) =
#| (svg, width, height, onDone, onError) => {
#| const img = new Image();
#| const svgBlob = new Blob([svg], { type: 'image/svg+xml' });
#| const url = URL.createObjectURL(svgBlob);
#| img.onload = () => {
#| const canvas = document.createElement('canvas');
#| canvas.width = width;
#| canvas.height = height;
#| const ctx = canvas.getContext('2d');
#| ctx.drawImage(img, 0, 0);
#| canvas.toBlob(blob => {
#| URL.revokeObjectURL(url);
#| if (!blob) { onError(); return; }
#| const item = new ClipboardItem({ 'image/png': blob });
#| navigator.clipboard.write([item])
#| .then(() => onDone())
#| .catch(() => onError());
#| }, 'image/png');
#| };
#| img.onerror = () => {
#| URL.revokeObjectURL(url);
#| onError();
#| };
#| img.src = url;
#| }
///|
/// クリップボードからテキストを読み込み
pub extern "js" fn read_clipboard_text(
on_success : (String) -> Unit,
on_error : () -> Unit,
) =
#| (onSuccess, onError) => {
#| navigator.clipboard.readText()
#| .then(text => onSuccess(text))
#| .catch(() => onError());
#| }
///|
/// ファイル選択ダイアログを開く
pub extern "js" fn open_file_dialog(
accept : String,
on_file : (String) -> Unit,
) =
#| (accept, onFile) => {
#| const input = document.createElement('input');
#| input.type = 'file';
#| input.accept = accept;
#| input.onchange = async (e) => {
#| const file = e.target.files[0];
#| if (file) {
#| const text = await file.text();
#| onFile(text);
#| }
#| };
#| input.click();
#| }
///|
/// SVG文字列をパースしてDOM要素を返す
pub extern "js" fn parse_svg_string(svg_str : String) -> @js.Any =
#| (svg) => {
#| const parser = new DOMParser();
#| const doc = parser.parseFromString(svg, 'image/svg+xml');
#| const parseError = doc.querySelector('parsererror');
#| if (parseError) return null;
#| return doc.documentElement;
#| }
///|
/// SVG要素の子要素リストを取得
pub extern "js" fn get_svg_children(svg : @js.Any) -> @js.Any =
#| (svg) => Array.from(svg.children)
///|
/// 要素のタグ名を取得
pub extern "js" fn get_tag_name(el : @js.Any) -> String =
#| (el) => el.tagName.toLowerCase()
///|
/// 要素の属性を取得(存在しない場合はnull)
pub extern "js" fn get_attribute(el : @js.Any, name : String) -> @js.Any =
#| (el, name) => el.getAttribute(name)
///|
/// 要素のテキスト内容を取得
pub extern "js" fn get_text_content(el : @js.Any) -> String =
#| (el) => el.textContent || ''
// ============================================================
// IndexedDB API
// ============================================================
///|
/// IndexedDB に文字列を保存
pub extern "js" fn save_to_indexeddb(
db_name : String,
store_name : String,
key : String,
value : String,
on_done : () -> Unit,
on_error : () -> Unit,
) =
#| (dbName, storeName, key, value, onDone, onError) => {
#| const request = indexedDB.open(dbName, 1);
#| request.onupgradeneeded = (e) => {
#| const db = e.target.result;
#| if (!db.objectStoreNames.contains(storeName)) {
#| db.createObjectStore(storeName);
#| }
#| };
#| request.onsuccess = (e) => {
#| const db = e.target.result;
#| const tx = db.transaction(storeName, 'readwrite');
#| const store = tx.objectStore(storeName);
#| store.put(value, key);
#| tx.oncomplete = () => { db.close(); onDone(); };
#| tx.onerror = () => { db.close(); onError(); };
#| };
#| request.onerror = () => onError();
#| }
///|
/// IndexedDB から文字列を読み込み
pub extern "js" fn load_from_indexeddb(
db_name : String,
store_name : String,
key : String,
on_success : (String) -> Unit,
on_not_found : () -> Unit,
on_error : () -> Unit,
) =
#| (dbName, storeName, key, onSuccess, onNotFound, onError) => {
#| const request = indexedDB.open(dbName, 1);
#| request.onupgradeneeded = (e) => {
#| const db = e.target.result;
#| if (!db.objectStoreNames.contains(storeName)) {
#| db.createObjectStore(storeName);
#| }
#| };
#| request.onsuccess = (e) => {
#| const db = e.target.result;
#| const tx = db.transaction(storeName, 'readonly');
#| const store = tx.objectStore(storeName);
#| const getReq = store.get(key);
#| getReq.onsuccess = () => {
#| db.close();
#| if (getReq.result === undefined) {
#| onNotFound();
#| } else {
#| onSuccess(getReq.result);
#| }
#| };
#| getReq.onerror = () => { db.close(); onError(); };
#| };
#| request.onerror = () => onError();
#| }