mirror of
https://github.com/moziar/obsidian-external-links-icon.git
synced 2026-07-22 06:45:34 +00:00
Merge pull request #25 from moziar/feat/multiple-target-support
Feat/multiple target support
This commit is contained in:
commit
20fb64c0f7
11 changed files with 704 additions and 400 deletions
File diff suppressed because one or more lines are too long
80
src/builtin-scheme-icons.ts
Normal file
80
src/builtin-scheme-icons.ts
Normal file
File diff suppressed because one or more lines are too long
209
src/builtin-web-icons.ts
Normal file
209
src/builtin-web-icons.ts
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<string, string> = 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<string, IconItem>): 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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<string, IconItem> = 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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
217
src/ui.ts
217
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<void>;
|
||||
private onSave: (data: { svgData?: string; themeDarkSvgData?: string | null; target?: string | string[] }) => void | Promise<void>;
|
||||
private hiddenInputs: HTMLInputElement[] = [];
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
icon: IconItem,
|
||||
onSave: (data: { svgData?: string; themeDarkSvgData?: string | null }) => void | Promise<void>,
|
||||
onSave: (data: { svgData?: string; themeDarkSvgData?: string | null; target?: string | string[] }) => void | Promise<void>,
|
||||
) {
|
||||
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) => {
|
||||
|
|
|
|||
125
styles.css
125
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue