Merge pull request #25 from moziar/feat/multiple-target-support

Feat/multiple target support
This commit is contained in:
Moz 2026-06-14 15:24:15 +08:00 committed by GitHub
commit 20fb64c0f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 704 additions and 400 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

209
src/builtin-web-icons.ts Normal file

File diff suppressed because one or more lines are too long

View file

@ -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';

View file

@ -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) {

View file

@ -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;

View file

@ -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;

View file

@ -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();
});

View file

@ -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
View file

@ -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) => {

View file

@ -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;
}