diff --git a/src/builtin-icons.ts b/src/builtin-icons.ts index 69b4178..1b9d956 100644 --- a/src/builtin-icons.ts +++ b/src/builtin-icons.ts @@ -1,283 +1,8 @@ import type { IconItem } from './types'; +import { BUILTIN_SCHEME_ICONS } from './builtin-scheme-icons'; +import { BUILTIN_WEB_ICONS } from './builtin-web-icons'; -// Built-in icons list extracted from the authoritative source. Keep this file as the -// single source of truth for bundled icons so both src and root entry can import it. export const BUILTIN_ICONS: Record = { - "goodlinks": { - "id": "goodlinks", - "name": "goodlinks", - "svgData": "", - "order": 0, - "linkType": "scheme" - }, - "zotero": { - "id": "zotero", - "name": "zotero", - "svgData": "", - "order": 1, - "linkType": "scheme" - }, - "snippetslab": { - "id": "snippetslab", - "name": "snippetslab", - "svgData": '', - "order": 2, - "linkType": "scheme" - }, - "siyuan": { - "id": "siyuan", - "name": "siyuan", - "svgData": "", - "order": 3, - "linkType": "scheme", - "themeDarkSvgData": "" - }, - "eagle": { - "id": "eagle", - "name": "eagle", - "svgData": "", - "order": 4, - "linkType": "scheme" - }, - "bear": { - "id": "bear", - "name": "bear", - "svgData": "", - "order": 5, - "linkType": "scheme" - }, - "prodrafts": { - "id": "prodrafts", - "name": "prodrafts", - "svgData": "", - "order": 6, - "linkType": "scheme" - }, - "things": { - "id": "things", - "name": "things", - "svgData": "", - "order": 7, - "linkType": "scheme", - "themeDarkSvgData": "" - }, - "file": { - "id": "file", - "name": "file", - "svgData": '', - "order": 8, - "linkType": "scheme" - }, - "shortcut": { - "id": "shortcut", - "name": "shortcut", - "target": "shortcuts", - "svgData": "", - "order": 9, - "linkType": "scheme" - }, - "github": { - "id": "github", - "name": "GitHub", - "svgData": "", - "order": 10, - "linkType": "url", - "themeDarkSvgData": "" - }, - "sspai": { - "id": "sspai", - "name": "sspai", - "svgData": '', - "order": 11, - "linkType": "url" - }, - "mp.weixin.qq": { - "id": "mp.weixin.qq", - "name": "weixin", - "svgData": "", - "order": 12, - "linkType": "url" - }, - "medium": { - "id": "medium", - "name": "medium", - "svgData": "", - "order": 13, - "linkType": "url", - "themeDarkSvgData": "" - }, - "douban": { - "id": "douban", - "name": "douban", - "svgData": "", - "order": 14, - "linkType": "url", - "themeDarkSvgData": "", - }, - "xiaoyuzhoufm": { - "id": "xiaoyuzhoufm", - "name": "xiaoyuzhoufm", - "svgData": "", - "order": 15, - "linkType": "url" - }, - "bilibili": { - "id": "bilibili", - "name": "bilibili", - "svgData": "", - "order": 16, - "linkType": "url" - }, - "youtube": { - "id": "youtube", - "name": "youtube", - "svgData": "", - "order": 17, - "linkType": "url" - }, - "ollama": { - "id": "ollama", - "name": "ollama", - "svgData": "", - "order": 18, - "linkType": "url", - "themeDarkSvgData": "" - }, - "modelscope": { - "id": "modelscope", - "name": "modelscope", - "svgData": '', - "order": 19, - "linkType": "url" - }, - "huggingface": { - "id": "huggingface", - "name": "huggingface", - "svgData": '', - "order": 20, - "linkType": "url" - }, - "openrouter": { - "id": "openrouter", - "name": "openrouter", - "svgData": '', - "order": 21, - "linkType": "url", - "themeDarkSvgData": '' - }, - "siliconflow": { - "id": "siliconflow", - "name": "siliconflow", - "svgData": '', - "order": 22, - "linkType": "url" - }, - "douyin": { - "id": "douyin", - "name": "douyin", - "svgData": '', - "order": 23, - "linkType": "url" - }, - "tiktok": { - "id": "tiktok", - "name": "tiktok", - "svgData": '', - "order": 24, - "linkType": "url" - }, - "baidu": { - "id": "baidu", - "name": "baidu", - "svgData": '', - "order": 25, - "linkType": "url" - }, - "flomo": { - "id": "flomo", - "name": "flomo", - "svgData": '', - "order": 26, - "linkType": "url" - }, - "wikipedia": { - "id": "wikipedia", - "name": "wikipedia", - "svgData": '', - "order": 27, - "linkType": "url" - }, - "archive": { - "id": "archive", - "name": "archive", - "svgData": "", - "order": 28, - "linkType": "url", - "themeDarkSvgData": "" - }, - "docs.google": { - "id": "docs.google", - "name": "google docs", - "svgData": "", - "order": 29, - "linkType": "url" - }, - "cloud.google": { - "id": "cloud.google", - "name": "google cloud", - "svgData": '', - "order": 30, - "linkType": "url" - }, - "google": { - "id": "google", - "name": "google", - "svgData": '', - "order": 31, - "linkType": "url" - }, - "obsidianweb": { - "id": "obsidianweb", - "name": "obsidian web", - "svgData": '', - "order": 32, - "linkType": "url" - }, - "obsidiannote": { - "id": "obsidiannote", - "name": "obsidian note", - "svgData": '', - "order": 33, - "linkType": "scheme" - }, - "advancedurisetting": { - "id": "advancedurisetting", - "name": "advanceduri setting", - "svgData": "", - "order": 34, - "linkType": "scheme", - "themeDarkSvgData": "" - }, - "craft": { - "id": "craft", - "name": "craft", - "svgData": "", - "order": 35, - "linkType": "scheme", - "target": "craftdocs", - }, - "zhihu": { - "id": "zhihu", - "name": "zhihu", - "svgData": "", - "order": 36, - "linkType": "url" - }, - "latepost": { - "id": "latepost", - "name": "latepost", - "svgData": "", - "order": 37, - "linkType": "url" - }, + ...BUILTIN_SCHEME_ICONS, + ...BUILTIN_WEB_ICONS, }; diff --git a/src/builtin-scheme-icons.ts b/src/builtin-scheme-icons.ts new file mode 100644 index 0000000..1576493 --- /dev/null +++ b/src/builtin-scheme-icons.ts @@ -0,0 +1,80 @@ +import type { IconItem } from './types'; + +export const BUILTIN_SCHEME_ICONS: Record = { +"goodlinks": { + "id": "goodlinks", + "svgData": "", + "linkType": "scheme" + }, + "zotero": { + "id": "zotero", + "svgData": "", + "linkType": "scheme" + }, + "snippetslab": { + "id": "snippetslab", + "svgData": '', + "linkType": "scheme" + }, + "siyuan": { + "id": "siyuan", + "svgData": "", + "linkType": "scheme", + "themeDarkSvgData": "" + }, + "eagle": { + "id": "eagle", + "svgData": "", + "linkType": "scheme" + }, + "bear": { + "id": "bear", + "svgData": "", + "linkType": "scheme" + }, + "prodrafts": { + "id": "prodrafts", + "svgData": "", + "linkType": "scheme" + }, + "things": { + "id": "things", + "svgData": "", + "linkType": "scheme", + "themeDarkSvgData": "" + }, + "file": { + "id": "file", + "svgData": '', + "linkType": "scheme" + }, + "shortcut": { + "id": "shortcut", + "target": "shortcuts", + "svgData": "", + "linkType": "scheme" + }, + "obsidiannote": { + "id": "obsidiannote", + "svgData": '', + "linkType": "scheme" + }, + "advancedurisetting": { + "id": "advancedurisetting", + "svgData": "", + "linkType": "scheme", + "themeDarkSvgData": "" + }, + "craft": { + "id": "craft", + "svgData": "", + "linkType": "scheme", + "target": "craftdocs", + }, + "notion-scheme": { + "id": "notion-scheme", + "svgData": "", + "linkType": "scheme", + "target": "notion" + } +}; diff --git a/src/builtin-web-icons.ts b/src/builtin-web-icons.ts new file mode 100644 index 0000000..7f35d63 --- /dev/null +++ b/src/builtin-web-icons.ts @@ -0,0 +1,209 @@ +import type { IconItem } from './types'; + +export const BUILTIN_WEB_ICONS: Record = { + "github": { + "id": "github", + "svgData": "", + "linkType": "url", + "target": "github.com", + "themeDarkSvgData": "" + }, + "sspai": { + "id": "sspai", + "svgData": '', + "linkType": "url", + "target": "sspai.com" + }, + "mp.weixin.qq": { + "id": "mp.weixin.qq", + "svgData": "", + "linkType": "url", + "target": "mp.weixin.qq.com" + }, + "medium": { + "id": "medium", + "svgData": "", + "linkType": "url", + "target": "medium.com", + "themeDarkSvgData": "" + }, + "douban": { + "id": "douban", + "svgData": "", + "linkType": "url", + "target": "douban.com", + "themeDarkSvgData": "", + }, + "xiaoyuzhoufm": { + "id": "xiaoyuzhoufm", + "svgData": "", + "linkType": "url", + "target": "xiaoyuzhoufm.com" + }, + "bilibili": { + "id": "bilibili", + "svgData": "", + "linkType": "url", + "target": "bilibili.com" + }, + "youtube": { + "id": "youtube", + "svgData": "", + "linkType": "url", + "target": "youtube.com" + }, + "ollama": { + "id": "ollama", + "svgData": "", + "linkType": "url", + "target": "ollama.com", + "themeDarkSvgData": "" + }, + "modelscope": { + "id": "modelscope", + "svgData": '', + "linkType": "url", + "target": "modelscope.cn" + }, + "huggingface": { + "id": "huggingface", + "svgData": '', + "linkType": "url", + "target": "huggingface.co" + }, + "openrouter": { + "id": "openrouter", + "svgData": '', + "linkType": "url", + "target": "openrouter.ai", + "themeDarkSvgData": '' + }, + "siliconflow": { + "id": "siliconflow", + "svgData": '', + "linkType": "url", + "target": "siliconflow.cn" + }, + "douyin": { + "id": "douyin", + "svgData": '', + "linkType": "url", + "target": [ + "douyin.com", + "tiktok.com" + ] + }, + "baidu": { + "id": "baidu", + "svgData": '', + "linkType": "url", + "target": "baidu.com" + }, + "flomo": { + "id": "flomo", + "svgData": '', + "linkType": "url", + "target": "v.flomoapp.com" + }, + "wikipedia": { + "id": "wikipedia", + "svgData": '', + "linkType": "url", + "target": "wikipedia.org" + }, + "archive": { + "id": "archive", + "svgData": "", + "linkType": "url", + "target": "archive.org", + "themeDarkSvgData": "" + }, + "docs.google": { + "id": "docs.google", + "svgData": "", + "linkType": "url", + "target": "docs.google.com/document" + }, + "google sheet": { + "id": "google sheet", + "svgData": "", + "linkType": "url", + "target": "docs.google.com/spreadsheets" + }, + "google slides": { + "id": "google slides", + "svgData": "", + "linkType": "url", + "target": "docs.google.com/presentation" + }, + "google play": { + "id": "google play", + "svgData": "", + "linkType": "url", + "target": "play.google.com/store" + }, + "google maps": { + "id": "google maps", + "svgData": "", + "linkType": "url", + "target": [ + "maps.google.com", + "google.com/maps" + ] + }, + "cloud.google": { + "id": "cloud.google", + "svgData": '', + "linkType": "url", + "target": "cloud.google.com" + }, + "google": { + "id": "google", + "svgData": '', + "linkType": "url", + "target": "google.com" + }, + "obsidianweb": { + "id": "obsidianweb", + "name": "obsidian web", + "svgData": '', + "linkType": "url", + "target": "obsidian.md" + }, + "zhihu": { + "id": "zhihu", + "svgData": "", + "linkType": "url", + "target": "zhihu.com" + }, + "latepost": { + "id": "latepost", + "svgData": "", + "linkType": "url", + "target": "latepost.com" + }, + "feishu": { + "id": "feishu", + "svgData": "", + "linkType": "url", + "target": [ + "feishu.cn", + "larksuite.com" + ] + }, + "notion": { + "id": "notion", + "svgData": "", + "linkType": "url", + "target": [ + "notion.com", + "notion.so" + ] + }, + "app store": { + "id": "app store", + "svgData": "", + "linkType": "url", + "target": "apps.apple.com" + } +}; diff --git a/src/constants.ts b/src/constants.ts index ac92e3e..a6c677d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,36 +2,17 @@ import type { ExternalLinksIconSettings } from './types'; export const ICON_CATEGORIES = { URL_SCHEME: [ - 'goodlinks', 'zotero', 'snippetslab', 'siyuan', 'eagle', - 'bear', 'prodrafts', 'things', 'shortcut', 'file', 'craft', 'obsidiannote', 'advancedurisetting' + 'goodlinks', 'zotero', 'snippetslab', 'siyuan', 'eagle', + 'bear', 'prodrafts', 'things', 'shortcut', 'file', 'craft', 'obsidiannote', 'advancedurisetting','notion-scheme' + ] as const, + WEB: [ + 'github', 'sspai', 'mp.weixin.qq', 'xiaoyuzhoufm', 'douban', + 'bilibili', 'youtube', 'medium', 'ollama', 'modelscope', + 'huggingface', 'openrouter', 'siliconflow', 'douyin', 'baidu', + 'flomo', 'wikipedia', 'archive', 'docs.google', 'google sheet', + 'google slides', 'google play', 'cloud.google', 'google maps', + 'google', 'obsidianweb', 'zhihu', 'latepost', 'feishu', 'notion', 'app store' ] as const, - WEB: { - 'github': 'github.com', - 'sspai': 'sspai.com', - 'mp.weixin.qq': 'mp.weixin.qq.com', - 'xiaoyuzhoufm': 'xiaoyuzhoufm.com', - 'douban': 'douban.com', - 'bilibili': 'bilibili.com', - 'youtube': 'youtube.com', - 'medium': 'medium.com', - 'ollama': 'ollama.com', - 'modelscope': 'modelscope.cn', - 'huggingface': 'huggingface.co', - 'openrouter': 'openrouter.ai', - 'siliconflow': 'siliconflow.cn', - 'douyin': 'douyin.com', - 'v.douyin': 'v.douyin.com', - 'tiktok': 'tiktok.com', - 'baidu': 'baidu.com', - 'v.flomo': 'v.flomoapp.com', - 'wikipedia': 'wikipedia.org', - 'archive': 'archive.org', - 'google': 'google.com', - 'docs.google': 'docs.google.com', - 'cloud.google': 'cloud.google.com', - 'zhihu': 'zhihu.com', - 'latepost': 'latepost.com' - } }; import { BUILTIN_ICONS } from './builtin-icons'; diff --git a/src/icon-matcher.ts b/src/icon-matcher.ts index f3dc7f4..7bf3780 100644 --- a/src/icon-matcher.ts +++ b/src/icon-matcher.ts @@ -65,27 +65,6 @@ export function iconMatchesContext(icon: IconItem, ctx: MatchContext): boolean { if (!hrefLower.startsWith('obsidian://adv-uri')) return false; return hrefLower.indexOf('settingid') !== -1; } - case 'google': { - if (!ctx.fancyWebLink) return false; - if (!ctx.isExternal) return false; - if (!hrefLower.startsWith('https://')) return false; - if (hrefLower.indexOf('google.com') === -1) return false; - if (hrefLower.indexOf('docs.google.com') !== -1) return false; - if (hrefLower.indexOf('cloud.google.com') !== -1) return false; - return true; - } - case 'docs.google': { - if (!ctx.fancyWebLink) return false; - if (!ctx.isExternal) return false; - if (!hrefLower.startsWith('https://')) return false; - return hrefLower.indexOf('docs.google.com') !== -1; - } - case 'cloud.google': { - if (!ctx.fancyWebLink) return false; - if (!ctx.isExternal) return false; - if (!hrefLower.startsWith('https://')) return false; - return hrefLower.indexOf('cloud.google.com') !== -1; - } default: break; } @@ -96,7 +75,7 @@ export function iconMatchesContext(icon: IconItem, ctx: MatchContext): boolean { const idx = hrefLower.indexOf('://'); if (idx <= 0) return false; const scheme = hrefLower.slice(0, idx); - const expected = (icon.target || icon.id || '').toLowerCase(); + const expected = ((icon.target as string) || icon.id || '').toLowerCase(); if (!expected) return false; return scheme === expected; } @@ -105,11 +84,8 @@ export function iconMatchesContext(icon: IconItem, ctx: MatchContext): boolean { if (!ctx.fancyWebLink) return false; if (!ctx.isExternal) return false; if (!hrefLower.startsWith('http://') && !hrefLower.startsWith('https://')) return false; - const webMap: Record = ICON_CATEGORIES.WEB; - const mapped = webMap[icon.id]; - const pattern = (mapped || icon.target || icon.id || '').toLowerCase(); - if (!pattern) return false; - return hrefLower.indexOf(pattern) !== -1; + const patterns = [icon.target || icon.id || ''].flat().map(p => p.toLowerCase()); + return patterns.some(p => p && hrefLower.indexOf(p) !== -1); } return false; @@ -119,8 +95,32 @@ export function getSortedIcons(icons: Record): IconItem[] { return Object.values(icons).sort((a, b) => (a.order || 0) - (b.order || 0)); } +export function getUrlTarget(icon: IconItem): string { + const targets = [icon.target || icon.id || ''].flat(); + return targets.reduce((a, b) => b.length > a.length ? b : a, '').toLowerCase(); +} + export function getAllIconsSorted(settings: ExternalLinksIconSettings): IconItem[] { - return getSortedIcons(DEFAULT_SETTINGS.icons || {}).concat(getSortedIcons(settings.customIcons || {})); + const customUrl = getSortedIcons(settings.customIcons || {}).filter(i => i.linkType === 'url'); + const builtinUrl = getBuiltinIconsByOrder('url'); + const builtinScheme = getBuiltinIconsByOrder('scheme'); + const customScheme = getSortedIcons(settings.customIcons || {}).filter(i => i.linkType === 'scheme'); + + // URL: custom first, then builtin, sorted by target length descending (most specific first) + const urlIcons = [...customUrl, ...builtinUrl].sort((a, b) => { + return getUrlTarget(b).length - getUrlTarget(a).length; + }); + + // Scheme: builtin first, then custom + const schemeIcons = [...builtinScheme, ...customScheme]; + + return [...urlIcons, ...schemeIcons]; +} + +function getBuiltinIconsByOrder(linkType: 'url' | 'scheme'): IconItem[] { + const keys = linkType === 'url' ? ICON_CATEGORIES.WEB : ICON_CATEGORIES.URL_SCHEME; + const icons = DEFAULT_SETTINGS.icons || {}; + return keys.map(key => icons[key]).filter(Boolean); } export function matchIcon( @@ -130,7 +130,7 @@ export function matchIcon( settings: ExternalLinksIconSettings ): IconItem | null { const ctx = getMatchContext(href, isExternal, isInternal, settings); - const icons: IconItem[] = getSortedIcons(DEFAULT_SETTINGS.icons || {}).concat(getSortedIcons(settings.customIcons || {})); + const icons = getAllIconsSorted(settings); if (!icons.length) return null; for (const icon of icons) { diff --git a/src/lang/locale/en.ts b/src/lang/locale/en.ts index 6c0395e..eb97bfd 100644 --- a/src/lang/locale/en.ts +++ b/src/lang/locale/en.ts @@ -11,7 +11,7 @@ export default { 'Add URL scheme': 'Add URL scheme', 'Icon name (unique)': 'Icon name (unique)', 'Website or scheme identifier': 'Website or scheme identifier', - 'Domain (e.g. baidu.com or https://baidu.com)': 'Domain (e.g. baidu.com or https://baidu.com)', + 'Domain (e.g. baike.baidu.com or baidu.com/about)': 'Domain (e.g. baike.baidu.com or baidu.com/about)', 'Scheme identifier (e.g. zotero)': 'Scheme identifier (e.g. zotero)', 'Upload icon': 'Upload icon', 'Cancel': 'Cancel', @@ -36,10 +36,11 @@ export default { 'Edit': 'Edit', 'Save': 'Save', 'Failed to save': 'Failed to save', - 'Upload new icon': 'Upload new icon', + 'Update': 'Update', 'Remove': 'Remove', 'Will be removed on save': 'Will be removed on save', 'Default icon is required when uploading a dark mode icon': 'Default icon is required when uploading a dark mode icon', + 'Default icon is required': 'Default icon is required', 'Are you sure you want to delete the icon': 'Are you sure you want to delete the icon', 'Download icon': 'Download icon', 'Copy to dark': 'Copy to dark', @@ -77,13 +78,16 @@ export default { 'icon-name.huggingface': 'Hugging Face', 'icon-name.openrouter': 'OpenRouter', 'icon-name.siliconflow': 'SiliconFlow', - 'icon-name.douyin': 'Douyin', - 'icon-name.tiktok': 'TikTok', + 'icon-name.douyin': 'TikTok', 'icon-name.baidu': 'Baidu', 'icon-name.flomo': 'flomo', 'icon-name.wikipedia': 'Wikipedia', 'icon-name.archive': 'Internet Archive', 'icon-name.docs.google': 'Google Docs', + 'icon-name.google sheet': 'Google Sheet', + 'icon-name.google slides': 'Google Slides', + 'icon-name.google play': 'Google Play', + 'icon-name.google maps': 'Google Maps', 'icon-name.cloud.google': 'Google Cloud', 'icon-name.google': 'Google', 'icon-name.obsidianweb': 'Obsidian Web', @@ -92,6 +96,10 @@ export default { 'icon-name.craft': 'Craft', 'icon-name.zhihu': 'Zhihu', 'icon-name.latepost': 'LatePost', + 'icon-name.feishu': 'Lark', + 'icon-name.notion': 'Notion', + 'icon-name.app store': 'App Store', + 'icon-name.notion-scheme': 'Notion', 'Appearance': 'Appearance', 'Fancy url scheme': 'Fancy url scheme', 'Enable icons for url schemes.': 'Enable icons for url schemes.', @@ -110,5 +118,8 @@ export default { 'Icon position': 'Icon position', 'Choose whether the icon appears before or after the link text.': 'Choose whether the icon appears before or after the link text.', 'After link': 'After link', - 'Before link': 'Before link' + 'Before link': 'Before link', + 'Add domain': 'Add domain', + 'Domains': 'Domains', + 'Domain already added': 'Domain already added', } as const; diff --git a/src/lang/locale/zh-cn.ts b/src/lang/locale/zh-cn.ts index a459066..cb3c88e 100644 --- a/src/lang/locale/zh-cn.ts +++ b/src/lang/locale/zh-cn.ts @@ -11,7 +11,7 @@ export default { 'Add URL scheme': '添加 URL Scheme', 'Icon name (unique)': '图标名称(唯一)', 'Website or scheme identifier': '网站或协议标识符', - 'Domain (e.g. baidu.com or https://baidu.com)': '域名(例如 baidu.com 或 https://baidu.com)', + 'Domain (e.g. baike.baidu.com or baidu.com/about)': '域名(例如 baike.baidu.com 或 baidu.com/about)', 'Scheme identifier (e.g. zotero)': '协议标识符(例如 zotero)', 'Upload icon': '上传图标', 'Cancel': '取消', @@ -36,10 +36,11 @@ export default { 'Edit': '编辑', 'Save': '保存', 'Failed to save': '保存失败', - 'Upload new icon': '上传新图标', + 'Update': '更新', 'Remove': '移除', 'Will be removed on save': '保存后将移除', 'Default icon is required when uploading a dark mode icon': '上传暗色模式图标时,默认图标为必选', + 'Default icon is required': '请上传默认图标', 'Are you sure you want to delete the icon': '确定要删除图标', 'Download icon': '下载图标', 'Copy to dark': '复制到暗色', @@ -78,12 +79,15 @@ export default { 'icon-name.openrouter': 'OpenRouter', 'icon-name.siliconflow': '硅基流动', 'icon-name.douyin': '抖音', - 'icon-name.tiktok': 'TikTok', 'icon-name.baidu': '百度', 'icon-name.flomo': 'flomo', 'icon-name.wikipedia': '维基百科', 'icon-name.archive': '互联网档案馆', 'icon-name.docs.google': 'Google 文档', + 'icon-name.google sheet': 'Google 表格', + 'icon-name.google slides': 'Google 幻灯片', + 'icon-name.google play': 'Google Play', + 'icon-name.google maps': 'Google 地图', 'icon-name.cloud.google': 'Google Cloud', 'icon-name.google': 'Google', 'icon-name.obsidianweb': 'Obsidian 网页', @@ -92,6 +96,10 @@ export default { 'icon-name.craft': 'Craft', 'icon-name.zhihu': '知乎', 'icon-name.latepost': '晚点', + 'icon-name.feishu': '飞书', + 'icon-name.notion': 'Notion', + 'icon-name.app store': 'App Store', + 'icon-name.notion-scheme': 'Notion', 'Appearance': '外观', 'Fancy url scheme': 'URL scheme', 'Enable icons for url schemes.': '显示 URL scheme 链接图标。', @@ -110,5 +118,8 @@ export default { 'Icon position': '图标位置', 'Choose whether the icon appears before or after the link text.': '选择图标显示在链接文字的前面还是后面。', 'After link': '链接后', - 'Before link': '链接前' + 'Before link': '链接前', + 'Add domain': '添加域名', + 'Domains': '域名', + 'Domain already added': '域名已添加', } as const; diff --git a/src/settings.ts b/src/settings.ts index 6b7f319..fcd1a79 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -11,7 +11,8 @@ function getIconDisplayName(icon: IconItem): string { const key = `icon-name.${icon.id}` as keyof typeof import('./lang/locale/en').default; return t(key); } - return icon.name; + // return icon id if icon name did not provide + return icon.name ?? icon.id; } import { preferDarkThemeFromDocument, prepareSvgForSettings, getSvgSourceForTheme } from './svg'; import { clearIconCache } from './utils'; @@ -194,15 +195,14 @@ export class ExternalLinksIconSettingTab extends PluginSettingTab { builtinsDetails.createEl('summary', { text: t('Built-in icons') }); const builtinRow = builtinsDetails.createDiv({ cls: 'builtin-row' }); - const builtinIconsMap: Record = Object.assign({}, DEFAULT_SETTINGS.icons || {}); - const builtinIcons = Object.values(builtinIconsMap) - .sort((a: IconItem, b: IconItem) => (a.order || 0) - (b.order || 0)) - .filter((ic: IconItem) => ic.linkType === 'url'); - builtinIcons.forEach((icon: IconItem) => { - const box = builtinRow.createDiv({ cls: 'website-item' }); - const iconEl = box.createDiv({ cls: 'item-icon' }); - renderIconImage(iconEl, icon, 'url', true); - box.createSpan({ text: getIconDisplayName(icon) }); + (ICON_CATEGORIES.WEB || []).forEach((key: string) => { + const icon = (DEFAULT_SETTINGS.icons || {})[key] || (this.plugin.settings.icons || {})[key]; + if (icon) { + const box = builtinRow.createDiv({ cls: 'website-item' }); + const iconEl = box.createDiv({ cls: 'item-icon' }); + renderIconImage(iconEl, icon, 'url', true); + box.createSpan({ text: getIconDisplayName(icon) }); + } }); }, }); @@ -272,7 +272,7 @@ export class ExternalLinksIconSettingTab extends PluginSettingTab { }; } - private async addIconWithData(data: { linkType: LinkType; name: string; target: string; svgData?: string; themeDarkSvgData?: string }) { + private async addIconWithData(data: { linkType: LinkType; name: string; target: string | string[]; svgData?: string; themeDarkSvgData?: string }) { const { linkType, name, target, svgData, themeDarkSvgData } = data; const id = name; const customIcons = this.plugin.settings.customIcons || {}; @@ -281,9 +281,12 @@ export class ExternalLinksIconSettingTab extends PluginSettingTab { return; } - let normalized = target.trim(); + let normalized: string | string[]; if (linkType === 'url') { - normalized = normalized.replace(/^https?:\/\//i, '').replace(/\/$/, ''); + normalized = [target].flat().map(t => t.trim().replace(/^https?:\/\//i, '').replace(/\/$/, '')); + if (normalized.length === 1) normalized = normalized[0]; + } else { + normalized = (target as string).trim(); } const maxOrder = Object.values(customIcons).reduce((max, ic: IconItem) => Math.max(max, ic.order || 0), -1); @@ -414,18 +417,11 @@ export class ExternalLinksIconSettingTab extends PluginSettingTab { previewContainer.createSpan({ text: getIconDisplayName(icon) }); } - private addNameInput(settingItem: Setting, icon: IconItem): void { - const placeholder = icon.linkType === 'url' ? t('Example.com') : t('Scheme identifier'); - settingItem.addText(text => { - text.setPlaceholder(placeholder) - .setValue(icon.target || '') - .onChange((value) => { - this.debounceUpdateTarget(icon.id, value); - }); - }); + private addNameInput(_settingItem: Setting, _icon: IconItem): void { + // Domain/scheme editing has been moved to EditIconModal } - private debounceUpdateTarget(id: string, newTarget: string): void { + private debounceUpdateTarget(id: string, newTarget: string | string[]): void { const timerId = this.debounceTimers.get(`target-${id}`); if (timerId) { window.clearTimeout(timerId); @@ -435,7 +431,7 @@ export class ExternalLinksIconSettingTab extends PluginSettingTab { (async () => { const icons = this.plugin.settings.customIcons || {}; if (icons[id]) { - icons[id].target = newTarget.trim(); + icons[id].target = typeof newTarget === 'string' ? newTarget.trim() : newTarget; await this.plugin.saveSettings(); this.update(); } @@ -459,6 +455,9 @@ export class ExternalLinksIconSettingTab extends PluginSettingTab { } else if (data.themeDarkSvgData) { icon.themeDarkSvgData = data.themeDarkSvgData; } + if (data.target !== undefined) { + icon.target = data.target; + } await this.plugin.saveSettings(); this.update(); }); diff --git a/src/types.ts b/src/types.ts index dd0b7c4..02fa462 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,12 +2,12 @@ export type LinkType = 'url' | 'scheme'; export interface IconItem { id: string; - name: string; + name?: string; svgData: string; - order: number; + order?: number; linkType: LinkType; themeDarkSvgData?: string; - target?: string; + target?: string | string[]; } export interface ExternalLinksIconSettings { diff --git a/src/ui.ts b/src/ui.ts index 0301058..1bd368c 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -1,4 +1,4 @@ -import { App, Modal, Notice, setIcon } from 'obsidian'; +import { App, Modal, Notice, Platform, Setting, setIcon } from 'obsidian'; import type { IconItem, LinkType } from './types'; import { t } from './lang/helper'; import { prepareSvgForSettings } from './svg'; @@ -35,7 +35,7 @@ export class ConfirmModal extends Modal { export interface NewIconData { linkType: LinkType; name: string; - target: string; + target: string | string[]; svgData?: string; themeDarkSvgData?: string; } @@ -63,16 +63,69 @@ export class NewIconModal extends Modal { nameInput.placeholder = t('Icon name (unique)'); nameInput.type = 'text'; - const targetInput = contentEl.createEl('input', { cls: 'external-links-icon-modal-input' }); - targetInput.type = 'text'; + const isUrl = this._defaultLinkType === 'url'; - const defaultType = this._defaultLinkType || 'url'; - targetInput.placeholder = defaultType === 'url' ? t('Domain (e.g. baidu.com or https://baidu.com)') : t('Scheme identifier (e.g. zotero)'); + // For URL type: multi-value domain input; for Scheme: single input + let domainList: string[] = []; + let targetInput!: HTMLInputElement; + let domainListEl: HTMLUListElement | undefined; + + if (isUrl) { + const inputRow = contentEl.createDiv({ cls: 'external-links-icon-domain-input-row' }); + targetInput = inputRow.createEl('input', { cls: 'external-links-icon-domain-input' }); + targetInput.type = 'text'; + targetInput.placeholder = t('Domain (e.g. baike.baidu.com or baidu.com/about)'); + const addBtn = inputRow.createEl('button', { cls: 'external-links-icon-domain-add-btn clickable-icon' }); + setIcon(addBtn, 'lucide-plus'); + + domainListEl = contentEl.createEl('ul', { cls: 'external-links-icon-domain-list' }); + + const renderDomains = () => { + domainListEl!.empty(); + domainList.forEach((domain, idx) => { + const li = domainListEl!.createEl('li', { cls: 'external-links-icon-domain-item' }); + li.createSpan({ text: domain }); + const removeBtn = li.createEl('button', { cls: 'external-links-icon-domain-item-remove clickable-icon' }); + setIcon(removeBtn, 'lucide-x'); + removeBtn.onclick = () => { + domainList.splice(idx, 1); + renderDomains(); + }; + }); + }; + + const addDomain = () => { + const val = targetInput.value.trim().replace(/^https?:\/\//i, '').replace(/\/$/, ''); + if (!val) return; + if (domainList.includes(val)) { + new Notice(t('Domain already added')); + return; + } + domainList.push(val); + targetInput.value = ''; + renderDomains(); + }; + + addBtn.onclick = addDomain; + targetInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addDomain(); + } + }); + } else { + targetInput = contentEl.createEl('input', { cls: 'external-links-icon-modal-input' }); + targetInput.type = 'text'; + targetInput.placeholder = t('Scheme identifier (e.g. zotero)'); + } let uploadedSvgData: string | undefined; let uploadedDarkSvgData: string | undefined; - const defaultSection = contentEl.createDiv({ cls: 'external-links-icon-upload-section' }); + const isDesktop = !Platform.isMobile; + const iconContainer = contentEl.createDiv({ cls: isDesktop ? 'external-links-icon-upload-columns' : '' }); + + const defaultSection = iconContainer.createDiv({ cls: 'external-links-icon-upload-section' }); defaultSection.createEl('div', { text: t('Default icon (light mode)'), cls: 'external-links-icon-upload-label' }); const lightBody = defaultSection.createDiv({ cls: 'external-links-icon-section-body' }); @@ -94,7 +147,7 @@ export class NewIconModal extends Modal { doc.body.appendChild(lightInput); lightUploadBtn.onclick = () => lightInput.click(); - const darkSection = contentEl.createDiv({ cls: 'external-links-icon-upload-section' }); + const darkSection = iconContainer.createDiv({ cls: 'external-links-icon-upload-section' }); darkSection.createEl('div', { text: t('Dark mode icon (optional)'), cls: 'external-links-icon-upload-label' }); const darkBody = darkSection.createDiv({ cls: 'external-links-icon-section-body' }); @@ -106,7 +159,6 @@ export class NewIconModal extends Modal { const darkUploadBtn = darkRow.createEl('button', { cls: 'external-links-icon-btn' }); setIcon(darkUploadBtn, 'lucide-upload'); darkUploadBtn.appendText(` ${t('Upload icon')}`); - darkSection.createEl('div', { text: t('Dark mode icon hint'), cls: 'external-links-icon-upload-hint' }); const darkInput = createFileInput(doc, (content) => { uploadedDarkSvgData = content; @@ -123,15 +175,25 @@ export class NewIconModal extends Modal { const addBtn = buttonContainer.createEl('button', { text: t('Add icon') }); addBtn.onclick = () => { const name = nameInput.value.trim(); - let target = targetInput.value.trim(); if (!name) { new Notice(t('Name is required')); return; } - if (!target) { new Notice(t('Target is required')); return; } - if (!uploadedSvgData && uploadedDarkSvgData) { - new Notice(t('Default icon is required when uploading a dark mode icon')); - return; + + let target: string | string[]; + if (isUrl) { + // Also pick up any text still in the input that hasn't been added + const remaining = targetInput.value.trim().replace(/^https?:\/\//i, '').replace(/\/$/, ''); + if (remaining && !domainList.includes(remaining)) { + domainList.push(remaining); + } + if (domainList.length === 0) { new Notice(t('Target is required')); return; } + target = domainList.length === 1 ? domainList[0] : domainList; + } else { + target = targetInput.value.trim(); + if (!target) { new Notice(t('Target is required')); return; } } - if (this._defaultLinkType === 'url') { - target = target.replace(/^https?:\/\//i, '').replace(/\/$/, ''); + + if (!uploadedSvgData) { + new Notice(t('Default icon is required')); + return; } const result = this.onSubmit({ linkType: this._defaultLinkType, name, target, svgData: uploadedSvgData, themeDarkSvgData: uploadedDarkSvgData }); if (result instanceof Promise) { @@ -156,13 +218,13 @@ export class NewIconModal extends Modal { export class EditIconModal extends Modal { private icon: IconItem; - private onSave: (data: { svgData?: string; themeDarkSvgData?: string | null }) => void | Promise; + private onSave: (data: { svgData?: string; themeDarkSvgData?: string | null; target?: string | string[] }) => void | Promise; private hiddenInputs: HTMLInputElement[] = []; constructor( app: App, icon: IconItem, - onSave: (data: { svgData?: string; themeDarkSvgData?: string | null }) => void | Promise, + onSave: (data: { svgData?: string; themeDarkSvgData?: string | null; target?: string | string[] }) => void | Promise, ) { super(app); this.icon = icon; @@ -176,6 +238,69 @@ export class EditIconModal extends Modal { contentEl.createEl('h3', { text: `${t('Edit icon')}: ${this.icon.name}` }); + // Domain editing for URL type + let domainList: string[] = []; + let domainListEl: HTMLUListElement | undefined; + let targetInput: HTMLInputElement | undefined; + + if (this.icon.linkType === 'url') { + domainList = [this.icon.target || ''].flat().filter(Boolean); + + const inputRow = contentEl.createDiv({ cls: 'external-links-icon-domain-input-row' }); + targetInput = inputRow.createEl('input', { cls: 'external-links-icon-domain-input' }); + targetInput.type = 'text'; + targetInput.placeholder = t('Domain (e.g. baike.baidu.com or baidu.com/about)'); + const addBtn = inputRow.createEl('button', { cls: 'external-links-icon-domain-add-btn clickable-icon' }); + setIcon(addBtn, 'lucide-plus'); + + domainListEl = contentEl.createEl('ul', { cls: 'external-links-icon-domain-list' }); + + const renderDomains = () => { + domainListEl!.empty(); + domainList.forEach((domain, idx) => { + const li = domainListEl!.createEl('li', { cls: 'external-links-icon-domain-item' }); + li.createSpan({ text: domain }); + const removeBtn = li.createEl('button', { cls: 'external-links-icon-domain-item-remove clickable-icon' }); + setIcon(removeBtn, 'lucide-x'); + removeBtn.onclick = () => { + domainList.splice(idx, 1); + renderDomains(); + }; + }); + }; + renderDomains(); + + const addDomain = () => { + const val = targetInput!.value.trim().replace(/^https?:\/\//i, '').replace(/\/$/, ''); + if (!val) return; + if (domainList.includes(val)) { + new Notice(t('Domain already added')); + return; + } + domainList.push(val); + targetInput!.value = ''; + renderDomains(); + }; + + addBtn.onclick = addDomain; + targetInput!.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addDomain(); + } + }); + } else { + // Scheme type: single target input + new Setting(contentEl) + .setName(t('Scheme identifier')) + .addText(text => { + targetInput = text.inputEl; + text.setPlaceholder(t('Scheme identifier (e.g. zotero)')); + const targetStr = typeof this.icon.target === 'string' ? this.icon.target : (Array.isArray(this.icon.target) ? this.icon.target[0] || '' : ''); + text.setValue(targetStr); + }); + } + let newSvgData: string | undefined; let newDarkSvgData: string | undefined; let removeDark = false; @@ -184,7 +309,10 @@ export class EditIconModal extends Modal { let darkBadge: HTMLDivElement; let darkPreview: HTMLDivElement; - const lightSection = contentEl.createDiv({ cls: 'external-links-icon-upload-section' }); + const isDesktop = !Platform.isMobile; + const iconContainer = contentEl.createDiv({ cls: isDesktop ? 'external-links-icon-upload-columns' : '' }); + + const lightSection = iconContainer.createDiv({ cls: 'external-links-icon-upload-section' }); lightSection.createEl('div', { text: t('Default icon (light mode)'), cls: 'external-links-icon-upload-label' }); const lightBody = lightSection.createDiv({ cls: 'external-links-icon-section-body' }); @@ -195,12 +323,19 @@ export class EditIconModal extends Modal { const lightRow = lightControls.createDiv({ cls: 'external-links-icon-control-row' }); const lightUploadBtn = lightRow.createEl('button', { cls: 'external-links-icon-btn' }); setIcon(lightUploadBtn, 'lucide-upload'); - lightUploadBtn.appendText(` ${t('Upload new icon')}`); + lightUploadBtn.appendText(` ${t('Update')}`); if (this.icon.svgData && !this.icon.themeDarkSvgData) { - const copyBtn = lightRow.createEl('button', { cls: 'external-links-icon-btn external-links-icon-btn-copy' }); - setIcon(copyBtn, 'lucide-copy'); - copyBtn.appendText(` ${t('Copy to dark')}`); + const copyBtnCls = isDesktop ? 'clickable-icon' : 'external-links-icon-btn external-links-icon-btn-copy'; + const copyBtn = lightRow.createEl('button', { cls: copyBtnCls }); + if (isDesktop) { + setIcon(copyBtn, 'lucide-square-arrow-right'); + copyBtn.setAttribute('aria-label', t('Copy to dark')); + copyBtn.style.marginLeft = 'auto'; + } else { + setIcon(copyBtn, 'lucide-copy'); + copyBtn.appendText(` ${t('Copy to dark')}`); + } copyBtn.onclick = () => { newDarkSvgData = newSvgData || this.icon.svgData; removeDark = false; @@ -221,7 +356,7 @@ export class EditIconModal extends Modal { doc.body.appendChild(lightInput); lightUploadBtn.onclick = () => lightInput.click(); - const darkSection = contentEl.createDiv({ cls: 'external-links-icon-upload-section' }); + const darkSection = iconContainer.createDiv({ cls: 'external-links-icon-upload-section' }); darkSection.createEl('div', { text: t('Dark mode icon (optional)'), cls: 'external-links-icon-upload-label' }); const darkBody = darkSection.createDiv({ cls: 'external-links-icon-section-body' }); @@ -234,7 +369,7 @@ export class EditIconModal extends Modal { const darkRow = darkControls.createDiv({ cls: 'external-links-icon-control-row' }); const darkUploadBtn = darkRow.createEl('button', { cls: 'external-links-icon-btn' }); setIcon(darkUploadBtn, 'lucide-upload'); - darkUploadBtn.appendText(` ${t('Upload new icon')}`); + darkUploadBtn.appendText(` ${this.icon.themeDarkSvgData ? t('Update') : t('Upload icon')}`); if (this.icon.themeDarkSvgData) { removeBtn = darkRow.createEl('button', { text: t('Remove'), cls: 'external-links-icon-btn external-links-icon-btn-danger' }); @@ -243,10 +378,12 @@ export class EditIconModal extends Modal { removeDark = !removeDark; removeIndicator!.textContent = removeDark ? ` ✓ ${t('Will be removed on save')}` : ''; removeBtn!.classList.toggle('is-active', removeDark); + if (isDesktop) { + darkUploadBtn.style.display = removeDark ? 'none' : ''; + } }; } - darkSection.createEl('div', { text: t('Dark mode icon hint'), cls: 'external-links-icon-upload-hint' }); const darkInput = createFileInput(doc, (content) => { newDarkSvgData = content; @@ -269,13 +406,39 @@ export class EditIconModal extends Modal { new Notice(t('Default icon is required when uploading a dark mode icon')); return; } - const data: { svgData?: string; themeDarkSvgData?: string | null } = {}; + const data: { svgData?: string; themeDarkSvgData?: string | null; target?: string | string[] } = {}; if (newSvgData) data.svgData = newSvgData; if (removeDark) { data.themeDarkSvgData = null; } else if (newDarkSvgData) { data.themeDarkSvgData = newDarkSvgData; } + + // Target editing + if (this.icon.linkType === 'url') { + // Also pick up any text still in the input + const remaining = targetInput!.value.trim().replace(/^https?:\/\//i, '').replace(/\/$/, ''); + if (remaining && !domainList.includes(remaining)) { + domainList.push(remaining); + } + if (domainList.length > 0) { + const newTarget = domainList.length === 1 ? domainList[0] : [...domainList]; + // Only include target if it changed + const oldTargets = [this.icon.target || ''].flat().filter(Boolean); + if (JSON.stringify(oldTargets) !== JSON.stringify(domainList)) { + data.target = newTarget; + } + } else { + data.target = ''; + } + } else { + // Scheme type + const newSchemeTarget = targetInput!.value.trim(); + if (newSchemeTarget !== (typeof this.icon.target === 'string' ? this.icon.target : '')) { + data.target = newSchemeTarget; + } + } + const result = this.onSave(data); if (result instanceof Promise) { result.catch((e) => { diff --git a/styles.css b/styles.css index 8a7e7ba..7019d02 100644 --- a/styles.css +++ b/styles.css @@ -364,6 +364,16 @@ body.theme-dark .external-links-icon-badge-dark { .external-links-icon-desc { margin-bottom: 10px; } +.external-links-icon-upload-columns { + display: flex; + gap: 16px; +} + +.external-links-icon-upload-columns > .external-links-icon-upload-section { + flex: 1; + min-width: 0; +} + .external-links-icon-upload-section { margin-bottom: 16px; } @@ -535,3 +545,118 @@ details.builtin-list summary { box-shadow: none; margin: 0; } + +/*** +Domain list (multi-target UI in Modal) +***/ + +.external-links-icon-domain-input-row { + display: flex; + gap: 4px; + align-items: center; + margin-bottom: 8px; +} + +.external-links-icon-domain-input-row + .external-links-icon-domain-list { + max-width: calc(100% - 30px); +} + +.external-links-icon-domain-input { + flex: 1; + min-width: 0; + padding: 6px 10px; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background: var(--background-modifier-form-field); + color: var(--text-normal); + font-size: 13px; +} + +.external-links-icon-domain-input:focus { + outline: none; + border-color: var(--interactive-accent); + box-shadow: 0 0 0 2px var(--background-modifier-active); +} + +.external-links-icon-domain-input::placeholder { + color: var(--text-faint); +} + +.external-links-icon-domain-add-btn { + background: none; + border: none; + padding: 6px; + cursor: pointer; + color: var(--text-muted); + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; + flex-shrink: 0; +} + +.external-links-icon-domain-add-btn:hover { + color: var(--interactive-accent); + background: var(--background-modifier-hover); +} + +.external-links-icon-domain-add-btn svg { + width: 18px; + height: 18px; +} + +.external-links-icon-domain-list { + list-style: none; + padding: 0; + margin: 0 0 12px 0; + border-radius: 6px; + overflow: hidden; +} + +.external-links-icon-domain-item { + display: flex; + align-items: center; + padding: 6px 10px; + font-size: 13px; + color: var(--text-normal); + background: var(--setting-items-background); + border-bottom: 1px solid var(--background-modifier-border); +} + +.external-links-icon-domain-item:last-child { + border-bottom: none; +} + +.external-links-icon-domain-item span { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.external-links-icon-domain-item-remove { + background: none; + border: none; + padding: 2px; + cursor: pointer; + color: var(--text-faint); + display: inline-flex; + align-items: center; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s ease, color 0.15s ease; +} + +.external-links-icon-domain-item:hover .external-links-icon-domain-item-remove { + opacity: 1; +} + +.external-links-icon-domain-item-remove:hover { + color: var(--text-error); +} + +.external-links-icon-domain-item-remove svg { + width: 14px; + height: 14px; +}