js/resolveOpt.mjs

/**
 * opt 解析 —— 單一來源。WFlowVue 之每個 opt 鍵對應一個 computed(名稱即鍵名, 由 OPT_SPEC 生成),
 * 不再逐鍵手寫 fallback 樣板; 群組型(defNode / defConn / menu)以具名解析函式提供。
 *
 * kind(回退規則, 依 JSDoc 契約逐鍵指定):
 *   defined  : opt 值 !== undefined 即採用(布林/數值 0 為合法值)
 *   truthy   : opt 值為 truthy 即採用(空字串/0/null 回退)
 *   enum     : 值須在 values 內, 否則回退
 *   nonneg   : 有限且 >= 0 之數值, 否則回退
 *   padding  : 非負數值或 { top,right,bottom,left } 物件, 逐邊正規化(非法之邊回退 def)
 *   notFalse : 只有明確 false 才關閉(預設開)
 *   nullable : truthy 即採用, 否則 null
 *   fn       : 須為函式, 否則 null
 *   text     : 非空字串即採用, 否則(非字串、空字串)回退 def; 純空白照收(顯示文字用)
 */
import { NODE_DEFAULTS, CONN_DEFAULTS, SETTINGS_TRIGGERS } from './defaults.mjs'
import { resolvePadding } from './viewport.mjs'
import { settingsTextOptSpec } from './settingsTexts.mjs'

export const OPT_SPEC = {
    //尺寸
    widthInp: { key: 'width', kind: 'truthy', def: 800 },
    heightInp: { key: 'height', kind: 'truthy', def: 600 },
    //互動能力
    nodesDraggable: { kind: 'defined', def: true },
    nodesConnectable: { kind: 'defined', def: true },
    nodesResizable: { kind: 'defined', def: true },
    elementsSelectable: { kind: 'defined', def: true },
    selectNodesOnDrag: { kind: 'defined', def: true },
    multiSelectEnabled: { kind: 'defined', def: true },
    boxSelectionKeyCode: { kind: 'truthy', def: 'Shift' },
    multiSelectionKeyCode: { kind: 'truthy', def: 'Shift' },
    deleteKeyEnabled: { kind: 'defined', def: false },
    deleteKeyCode: { kind: 'truthy', def: 'Backspace' },
    snapToGrid: { kind: 'defined', def: false },
    snapGridSize: { kind: 'truthy', def: 20 },
    //設定入口: 'hover' | 'click' | 'dblclick'(非法值回退 dblclick)
    nodesSettingsEnabled: { kind: 'defined', def: true },
    connsSettingsEnabled: { kind: 'defined', def: true },
    nodesSettingsTrigger: { kind: 'enum', values: SETTINGS_TRIGGERS, def: 'dblclick' },
    connsSettingsTrigger: { kind: 'enum', values: SETTINGS_TRIGGERS, def: 'dblclick' },
    nodesSettingsExcludes: { kind: 'truthy', def: [] },
    connsSettingsExcludes: { kind: 'truthy', def: [] },
    //設定彈窗之顯示文字: 一句一鍵(節點 26 / 連線 48, 如 nodesSettingsTextLabelName), 鍵名與英文預設由 settingsTexts 產生
    ...settingsTextOptSpec(),
    //視口
    zoomOnScroll: { kind: 'defined', def: true },
    //捏合縮放(觸控雙指): 與 zoomOnScroll 各自獨立, 不想要觸控縮放者設 false
    zoomOnPinch: { kind: 'defined', def: true },
    panOnDrag: { kind: 'defined', def: true },
    zoomMin: { kind: 'defined', def: 0.5 },
    zoomMax: { kind: 'defined', def: 2 },
    zoom: { kind: 'defined', def: 1 },
    center: { kind: 'truthy', def: [0, 0] },
    panLimits: { kind: 'nullable' },
    fitViewOnInit: { kind: 'notFalse' },
    fitViewPadding: { kind: 'padding', def: 50 },
    //建線預覽線
    defConnCreatingType: { kind: 'truthy', def: 'bezier' },
    defConnCreatingEdgeColor: { kind: 'truthy', def: CONN_DEFAULTS.edgeColor },
    defConnCreatingEdgeWidth: { kind: 'defined', def: 1 },
    defConnCreatingEdgeDasharray: { kind: 'truthy', def: '5 5' },
    //畫布背景
    platformBackgroundPatternType: { kind: 'truthy', def: 'dots' },
    platformBackgroundPatternGap: { kind: 'defined', def: 20 },
    platformBackgroundPatternSize: { kind: 'defined', def: 1 },
    platformBackgroundPatternColor: { kind: 'truthy', def: '#81818a' },
    platformBackgroundColor: { kind: 'truthy', def: '#fff' },
    //popup 樣式
    settingsPopupBackgroundColor: { kind: 'truthy', def: '#fff' },
    settingsPopupTextColor: { kind: 'truthy', def: '#333' },
    settingsPopupTextFontSize: { kind: 'truthy', def: '12px' },
    //設定表單之最大高度(CSS 長度字串); 超出即表單內捲動, 使屬性多時 popup 不會長過視窗。
    //kind 為 defined 而非 truthy: 空字串是「明確不設限」之合法值, truthy 會把它回退成預設值而使該契約失效
    settingsPopupMaxHeight: { kind: 'defined', def: '400px' },
    inforPopupBackgroundColor: { kind: 'truthy', def: '#fff' },
    inforPopupTitleTextColor: { kind: 'truthy', def: '#333' },
    inforPopupTitleTextFontSize: { kind: 'truthy', def: '12px' },
    inforPopupDescriptionTextColor: { kind: 'truthy', def: '#888' },
    inforPopupDescriptionTextFontSize: { kind: 'truthy', def: '10px' },
    //callback
    funValidConnCreating: { kind: 'nullable' },
    //刪除確認(async): 未提供即直接刪除, 提供則須回傳 true 才真的刪除
    funConfirmDeleting: { kind: 'fn' },
}

/** 依 OPT_SPEC 解析單一鍵 */
export function resolveOptValue(opt, name) {
    const spec = OPT_SPEC[name]
    if (!spec) throw new Error(`resolveOpt: unknown opt name '${name}'`)
    const v = (opt || {})[spec.key || name]
    switch (spec.kind) {
    case 'defined': return v !== undefined ? v : spec.def
    case 'truthy': return v || spec.def
    case 'enum': return spec.values.indexOf(v) >= 0 ? v : spec.def
    case 'nonneg': return (typeof v === 'number' && isFinite(v) && v >= 0) ? v : spec.def
    case 'padding': return resolvePadding(v, spec.def)
    case 'notFalse': return v !== false
    case 'nullable': return v || null
    case 'fn': return typeof v === 'function' ? v : null
    case 'text': return (typeof v === 'string' && v !== '') ? v : spec.def
    default: throw new Error(`resolveOpt: unknown kind '${spec.kind}'`)
    }
}

/** 已移除之 opt 鍵 → 取代之鍵(墓碑: 只用於點名新鍵, 不讀其值、不生效, 故非相容層) */
export const REMOVED_OPT_KEYS = Object.freeze({
    //色票確認鈕文字由節點、連線共用一個鍵改為各一個(節點與連線分開命名)
    settingsColorConfirmText: 'opt.nodesSettingsColorConfirmText and opt.connsSettingsColorConfirmText',
})

//拼錯即無聲失效之鍵族: 以此前綴開頭而不在 OPT_SPEC 者視為未知鍵(其他 opt 鍵另有具名解析, 不在此檢查範圍)
const CHECKED_PREFIXES = ['nodesSettings', 'connsSettings', 'settings']

/**
 * opt 之誤用(供 WFlowVue 逐則警告一次): 已移除之鍵、上列鍵族中之未知鍵、顯示文字鍵之值非字串。
 * 只讀取需檢查之鍵之值(不碰 nodes / conns 等資料鍵), 避免警告之 computed 依賴到圖資料。
 */
export function collectOptIssues(opt) {
    const o = Object.prototype.toString.call(opt) === '[object Object]' ? opt : {}
    const specByKey = {}
    for (const name of Object.keys(OPT_SPEC)) specByKey[OPT_SPEC[name].key || name] = OPT_SPEC[name]
    const msgs = []
    const unknown = []
    const notText = []
    for (const k of Object.keys(o)) {
        if (Object.prototype.hasOwnProperty.call(REMOVED_OPT_KEYS, k)) {
            if (o[k] !== undefined) msgs.push(`opt.${k} has been removed and has no effect; use ${REMOVED_OPT_KEYS[k]} instead`)
            continue
        }
        const spec = Object.prototype.hasOwnProperty.call(specByKey, k) ? specByKey[k] : null
        if (!spec) {
            if (CHECKED_PREFIXES.some(p => k.indexOf(p) === 0)) unknown.push(k)
            continue
        }
        if (spec.kind === 'text') {
            const v = o[k]
            if (v !== undefined && v !== null && typeof v !== 'string') notText.push(k)
        }
    }
    if (unknown.length) msgs.push(`unknown opt keys ignored: ${unknown.join(', ')}`)
    if (notText.length) msgs.push(`opt texts must be strings, defaults used for: ${notText.join(', ')}`)
    return msgs
}

/** 全部純量鍵一次解析(測試/除錯用; 元件內以 optComputeds 逐鍵 computed, 避免整包失效) */
export function resolveOpt(opt) {
    const r = {}
    for (const name of Object.keys(OPT_SPEC)) r[name] = resolveOptValue(opt, name)
    return r
}

/** 供 Vue computed 展開: 每個 OPT_SPEC 鍵一個 computed(讀 this.opt) */
export function optComputeds() {
    const c = {}
    for (const name of Object.keys(OPT_SPEC)) {
        c[name] = function() {
            return resolveOptValue(this.opt, name)
        }
    }
    return c
}

/** 節點預設(opt.defNode* / opt.defHandle* → NODE_DEFAULTS); 數值型以 !== undefined 判斷(0 為合法之框線寬) */
export function resolveDefNode(opt) {
    const o = opt || {}
    const d = NODE_DEFAULTS
    return {
        shape: o.defNodeShape || d.shape,
        width: o.defNodeWidth || d.width,
        height: o.defNodeHeight || d.height,
        fontSize: o.defNodeFontSize || d.fontSize,
        fontSizeMin: o.defNodeFontSizeMin || d.fontSizeMin,
        fontSizeMax: o.defNodeFontSizeMax || d.fontSizeMax,
        fontColor: o.defNodeFontColor || d.fontColor,
        faceColor: o.defNodeFaceColor || d.faceColor,
        edgeColor: o.defNodeEdgeColor || d.edgeColor,
        edgeWidth: o.defNodeEdgeWidth !== undefined ? o.defNodeEdgeWidth : d.edgeWidth,
        popupDirection: o.defNodePopupDirection || d.popupDirection,
        //連接點(把手)樣式(四把手同一組)
        handleFaceColor: o.defHandleFaceColor || d.handleFaceColor,
        handleEdgeColor: o.defHandleEdgeColor || d.handleEdgeColor,
        handleEdgeWidth: o.defHandleEdgeWidth !== undefined ? o.defHandleEdgeWidth : d.handleEdgeWidth,
        handleSize: o.defHandleSize || d.handleSize,
    }
}

/** 連線預設(opt.defConn* → CONN_DEFAULTS): 兩端方位/箭頭/animated/step offset 皆在此 */
export function resolveDefConn(opt) {
    const o = opt || {}
    const d = CONN_DEFAULTS
    return {
        type: o.defConnType || d.type,
        fontSize: o.defConnFontSize || d.fontSize,
        fontSizeMin: o.defConnFontSizeMin || d.fontSizeMin,
        fontSizeMax: o.defConnFontSizeMax || d.fontSizeMax,
        fontColor: o.defConnFontColor || d.fontColor,
        edgeColor: o.defConnEdgeColor || d.edgeColor,
        edgeWidth: o.defConnEdgeWidth !== undefined ? o.defConnEdgeWidth : d.edgeWidth,
        edgeDasharray: o.defConnEdgeDasharray || '',
        fromPosition: o.defConnFromPosition || d.fromPosition,
        toPosition: o.defConnToPosition || d.toPosition,
        markerFrom: o.defConnMarkerFrom || d.markerFrom,
        markerFromSize: o.defConnMarkerFromSize || d.markerFromSize,
        markerFromFaceColor: o.defConnMarkerFromFaceColor || d.markerFromFaceColor,
        markerFromEdgeColor: o.defConnMarkerFromEdgeColor || d.markerFromEdgeColor,
        markerTo: o.defConnMarkerTo || d.markerTo,
        markerToSize: o.defConnMarkerToSize || d.markerToSize,
        markerToFaceColor: o.defConnMarkerToFaceColor || d.markerToFaceColor,
        markerToEdgeColor: o.defConnMarkerToEdgeColor || d.markerToEdgeColor,
        animated: o.defConnAnimated !== undefined ? o.defConnAnimated : d.animated,
        defOffset: o.defOffset != null ? o.defOffset : d.defOffset,
        popupDirection: o.defConnPopupDirection || d.popupDirection,
    }
}

/**
 * 垂直選單設定: 自 opt 原樣透傳(不套預設)——各項未給或型別不符由 Controls.vue 之 menuDef 回退,
 * 預設值單一來源在 Controls(icon 預設需 @mdi/js 常數), 本模組刻意不複製一份。
 */
export const MENU_OPT_KEYS = [
    'useMenu', 'menuPosition', 'menuYShift', 'useSetting',
    'menuSettingIcon', 'menuSettingTooltip',
    'useMenuItemZoomIn', 'menuZoomInIcon', 'menuZoomInTooltip',
    'useMenuItemZoomOut', 'menuZoomOutIcon', 'menuZoomOutTooltip',
    'useMenuItemFitView', 'menuFitViewIcon', 'menuFitViewTooltip',
    'useMenuItemLock', 'menuLockIcon', 'menuLockTooltip', 'menuLockIconLocked', 'menuLockTooltipLocked',
    'menuIconColor', 'menuIconColorHover', 'menuIconColorFocus', 'menuIconSize',
    'menuBackgroundColor', 'menuBackgroundColorHover', 'menuBackgroundColorFocus',
    'menuSeparatorColor', 'menuShadow',
    'menuTooltipTextColor', 'menuTooltipTextFontSize', 'menuTooltipBackgroundColor',
]
export function pickMenuOpt(opt) {
    const o = opt || {}
    const r = {}
    for (const k of MENU_OPT_KEYS) r[k] = o[k]
    return r
}