diff --git a/Ink Canvas/Helpers/LocalizationHelper.cs b/Ink Canvas/Helpers/LocalizationHelper.cs index 6b79567e..3213e6c1 100644 --- a/Ink Canvas/Helpers/LocalizationHelper.cs +++ b/Ink Canvas/Helpers/LocalizationHelper.cs @@ -86,6 +86,10 @@ private static void PatchModernStrings(string onText, string offText) var current = (ResourceManager)resourceManField.GetValue(null); + // iNKORE Strings 尚未惰性初始化时为 null,此时装补丁会留下空回退, + // 触发 TextContextMenu 等查未知键时 NRE,故直接跳过 + if (current == null) return; + if (!_modernStringsPatched) { _originalModernStringsRM = current; @@ -122,22 +126,22 @@ public ToggleSwitchResourceManager(ResourceManager fallback, Dictionary _fallback.GetObject(name); - public override object GetObject(string name, CultureInfo culture) => _fallback.GetObject(name, culture); + public override object GetObject(string name) => _fallback?.GetObject(name); + public override object GetObject(string name, CultureInfo culture) => _fallback?.GetObject(name, culture); public override ResourceSet GetResourceSet(CultureInfo culture, bool createIfNotExists, bool tryParents) - => _fallback.GetResourceSet(culture, createIfNotExists, tryParents); + => _fallback?.GetResourceSet(culture, createIfNotExists, tryParents); } /// diff --git a/Ink Canvas/MainWindow_cs/MW_FloatingBarIcons.cs b/Ink Canvas/MainWindow_cs/MW_FloatingBarIcons.cs index b0d01757..acf49423 100644 --- a/Ink Canvas/MainWindow_cs/MW_FloatingBarIcons.cs +++ b/Ink Canvas/MainWindow_cs/MW_FloatingBarIcons.cs @@ -39,6 +39,9 @@ public partial class MainWindow : Ink_Canvas.Helpers.PerformanceTransparentWin private static Windows.SettingsViews.SettingsWindow _settingsWindow = null; + private string _pendingSettingsPageTag = null; + private string _pendingSettingsEntryPath = null; + #region "手勢"按鈕 /// @@ -4403,6 +4406,7 @@ internal async void BtnSettings_Click(object sender, RoutedEventArgs e) _settingsWindow.WindowState = System.Windows.WindowState.Normal; _settingsWindow.Activate(); _settingsWindow.Focus(); + FlushPendingSettingsDeepLink(); return; } @@ -4411,12 +4415,13 @@ internal async void BtnSettings_Click(object sender, RoutedEventArgs e) if (Ink_Canvas.Helpers.SecurityManager.IsPasswordRequiredForEnterSettings(Settings)) { bool ok = await Ink_Canvas.Helpers.SecurityManager.PromptAndVerifyPasswordOrTotpAsync(Settings, this, Properties.MainWindowStrings.Main_EnterSettings, Properties.MainWindowStrings.Main_EnterSettings_Message); - if (!ok) return; + if (!ok) { ClearPendingSettingsDeepLink(); return; } } } catch (Exception ex) { LogHelper.WriteLogToFile($"安全密码校验失败: {ex}", LogHelper.LogType.Error); + ClearPendingSettingsDeepLink(); return; } @@ -4429,6 +4434,33 @@ internal async void BtnSettings_Click(object sender, RoutedEventArgs e) _settingsWindow.Topmost = this.Topmost; _settingsWindow.Closed += (s, args) => _settingsWindow = null; _settingsWindow.Show(); + // 等窗口完成默认导航后再定位深链目标 + _ = Dispatcher.InvokeAsync(new Action(FlushPendingSettingsDeepLink), DispatcherPriority.ApplicationIdle); + } + + /// + /// 通过 icc: 深链打开设置窗口并定位到指定设置项。 + /// + private void OpenSettingsAtEntry(string pageTag, string propertyPath) + { + _pendingSettingsPageTag = pageTag; + _pendingSettingsEntryPath = propertyPath; + BtnSettings_Click(null, null); + } + + private void FlushPendingSettingsDeepLink() + { + if (_settingsWindow == null || string.IsNullOrEmpty(_pendingSettingsEntryPath)) return; + string pageTag = _pendingSettingsPageTag; + string path = _pendingSettingsEntryPath; + ClearPendingSettingsDeepLink(); + _settingsWindow.NavigateToEntry(pageTag, path); + } + + private void ClearPendingSettingsDeepLink() + { + _pendingSettingsPageTag = null; + _pendingSettingsEntryPath = null; } private bool forceEraser; diff --git a/Ink Canvas/MainWindow_cs/MW_UriHandler.cs b/Ink Canvas/MainWindow_cs/MW_UriHandler.cs index cb66d5ea..1d45794c 100644 --- a/Ink Canvas/MainWindow_cs/MW_UriHandler.cs +++ b/Ink Canvas/MainWindow_cs/MW_UriHandler.cs @@ -264,6 +264,19 @@ public void HandleUriCommand(string uri) return; } + if (pathLower == "settings/entry") + { + string pageTag = GetUriQueryValue(uri, "page"); + string entryPath = GetUriQueryValue(uri, "path"); + if (string.IsNullOrEmpty(entryPath)) + { + LogHelper.WriteLogToFile("URI 打开设置项缺少参数 path", LogHelper.LogType.Warning); + return; + } + OpenSettingsAtEntry(pageTag, entryPath); + return; + } + LogHelper.WriteLogToFile($"未知的 URI 命令: {command}", LogHelper.LogType.Warning); } catch (Exception ex) diff --git a/Ink Canvas/Properties/CommonStrings.Designer.cs b/Ink Canvas/Properties/CommonStrings.Designer.cs index 6d72bbb5..9dad0282 100644 --- a/Ink Canvas/Properties/CommonStrings.Designer.cs +++ b/Ink Canvas/Properties/CommonStrings.Designer.cs @@ -76,8 +76,14 @@ public static string GetString(string key) public static string SettingsTag_Favourite => ResourceManager.GetString(nameof(SettingsTag_Favourite), _resourceCulture); - public static string SettingsTag_FavouriteAdd => ResourceManager.GetString(nameof(SettingsTag_FavouriteAdd), _resourceCulture); + public static string SettingsItemMore => ResourceManager.GetString(nameof(SettingsItemMore), _resourceCulture); - public static string SettingsTag_FavouriteRemove => ResourceManager.GetString(nameof(SettingsTag_FavouriteRemove), _resourceCulture); + public static string SettingsItemCopyKey => ResourceManager.GetString(nameof(SettingsItemCopyKey), _resourceCulture); + + public static string SettingsItemCopyUrl => ResourceManager.GetString(nameof(SettingsItemCopyUrl), _resourceCulture); + + public static string SettingsItemAddFavourite => ResourceManager.GetString(nameof(SettingsItemAddFavourite), _resourceCulture); + + public static string SettingsItemRemoveFavourite => ResourceManager.GetString(nameof(SettingsItemRemoveFavourite), _resourceCulture); } } \ No newline at end of file diff --git a/Ink Canvas/Properties/CommonStrings.resx b/Ink Canvas/Properties/CommonStrings.resx index 6e2671a3..42313f36 100644 --- a/Ink Canvas/Properties/CommonStrings.resx +++ b/Ink Canvas/Properties/CommonStrings.resx @@ -96,10 +96,19 @@ 收藏 - - 收藏此设置 + + 更多操作 - + + 复制设置键名 + + + 复制URL + + + 设置为收藏 + + 取消收藏 diff --git a/Ink Canvas/Properties/NavStrings.resx b/Ink Canvas/Properties/NavStrings.resx index 990657f7..71add087 100644 --- a/Ink Canvas/Properties/NavStrings.resx +++ b/Ink Canvas/Properties/NavStrings.resx @@ -340,7 +340,7 @@ 通知设置 - 自动收纳 + 自动收纳/查杀 自动收纳与浮动窗口拦截设置 diff --git a/Ink Canvas/Windows/SettingsViews/Helpers/SettingsTagBehavior.cs b/Ink Canvas/Windows/SettingsViews/Helpers/SettingsTagBehavior.cs index 044a6163..f7cd574e 100644 --- a/Ink Canvas/Windows/SettingsViews/Helpers/SettingsTagBehavior.cs +++ b/Ink Canvas/Windows/SettingsViews/Helpers/SettingsTagBehavior.cs @@ -6,7 +6,9 @@ using iNKORE.UI.WPF.Modern.Controls; using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using System.Runtime.InteropServices; using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; @@ -35,21 +37,82 @@ public static string GetPropertyPath(DependencyObject obj) => public static void SetPropertyPath(DependencyObject obj, string value) => obj.SetValue(PropertyPathProperty, value); + private static readonly DependencyProperty MoreInjectedProperty = + DependencyProperty.RegisterAttached( + "MoreInjected", + typeof(bool), + typeof(SettingsTags), + new PropertyMetadata(false)); + + public struct CardEntry + { + public FrameworkElement Element; + public string Identity; + public bool HasRealKey; + public bool IsGroupHeader; + } + /// - /// 页内所有可收藏卡片及其身份。身份优先级: - /// 1. 显式附加属性 PropertyPath(真属性路径,带 tag / chip) + /// 页内所有卡片及其身份。身份优先级: + /// 1. 解析出的真实设置键(卡片 SettingsTags.PropertyPath,或卡片/卡内控件的 SettingsBinder.PropertyPath) /// 2. 卡片 x:Name → "{pageTag}:{xName}" - /// 3. 兜底 → "{pageTag}:card{序号}"(序号 = 页面逻辑树遍历序,注意卡片重排会导致收藏错位) + /// 3. 兜底 → "{pageTag}:card{序号}"(序号 = 页面逻辑树遍历序) /// 遍历顺序与 SettingsWindow.CollectEntriesFromPage 完全一致,保证两侧序号一致。 /// - public static List> EnumerateCardIdentities(DependencyObject root, string pageTag) + public static List EnumerateCardIdentities(DependencyObject root, string pageTag) { - var result = new List>(); + var result = new List(); if (root == null) return result; + var seen = new HashSet(); int ordinal = 0; + + void AppendCard(FrameworkElement element, string identity, bool hasRealKey, bool isGroupHeader) + { + if (!seen.Add(element)) return; + result.Add(new CardEntry + { + Element = element, + Identity = identity, + HasRealKey = hasRealKey, + IsGroupHeader = isGroupHeader, + }); + ordinal++; + } + + void AppendOrdinaryCard(FrameworkElement element) + { + bool hasRealKey = ResolveSettingKey(element, out string key); + string identity = hasRealKey ? key + : !string.IsNullOrEmpty(element.Name) ? pageTag + ":" + element.Name + : pageTag + ":card" + ordinal; + AppendCard(element, identity, hasRealKey, isGroupHeader: false); + } + foreach (var node in EnumerateLogicalDescendants(root)) { + // SettingsExpander 折叠时内部卡片尚未物化,不在逻辑树中;直接从 Items 集合补齐。 + // 组顶行(如"启用启动动画"顶行开关):身份锚定在 expander 逻辑节点而非模板内 Header 卡, + // 使"收藏时(模板已物化)"与"索引构建时(页面可能未物化)"解析出完全一致的序号。 + if (node is SettingsExpander expander) + { + var headerCard = FindExpanderGroupHeaderCard(expander); + bool hasRealKey = ResolveContentSettingKey(expander, out string key); + string identity = hasRealKey ? key + : !string.IsNullOrEmpty(expander.Name) ? pageTag + ":" + expander.Name + : pageTag + ":card" + ordinal; + // 模板物化时拿到组顶行 SettingsCard;未物化时退回 expander 本身,索引/深链仍可定位 + var groupElement = headerCard as FrameworkElement ?? expander; + AppendCard(groupElement, identity, hasRealKey, isGroupHeader: true); + + foreach (var item in expander.Items) + { + if (item is LabeledSettingsCard lscItem) AppendOrdinaryCard(lscItem); + else if (item is SettingsCard scItem && !HasAncestor(scItem)) AppendOrdinaryCard(scItem); + } + continue; + } + FrameworkElement element; if (node is LabeledSettingsCard lsc) { @@ -64,42 +127,144 @@ public static List> EnumerateCardIdentiti continue; } - string identity = GetIdentity(element, pageTag, ordinal); - result.Add(new KeyValuePair(element, identity)); - ordinal++; + AppendOrdinaryCard(element); } return result; } /// - /// 为页面全部卡片注入星标(chip 仅当身份解析出真实 tag 时显示)。 + /// 为页面全部卡片注入更多按钮 + 标签 chip。仅"真设置项"卡片注入: + /// 常规设置卡、声明了 PropertyPath 的卡、以及内容含真开关的 SettingsExpander 组顶行。 /// public static void InjectStarsIntoPage(FrameworkElement pageRoot, string pageTag) { if (pageRoot == null) return; - foreach (var pair in EnumerateCardIdentities(pageRoot, pageTag)) + foreach (var entry in EnumerateCardIdentities(pageRoot, pageTag)) { - Inject(pair.Key, pair.Value); + InjectWhenReady(entry, pageTag); } } - private static string GetIdentity(FrameworkElement element, string pageTag, int ordinal) + /// + /// 卡片模板已就绪则立即注入,否则挂 Loaded 等其进入可视树后再注入。 + /// 未选中的 TabItem / 折叠的 SettingsExpander 内卡片在页面 Loaded 时尚未实化, + /// 此时 FindVisualChild 取不到内层 SettingsCard,必须延迟到真正可见时补注入。 + /// + private static void InjectWhenReady(CardEntry entry, string pageTag) { - string explicitPath = GetPropertyPath(element); - if (!string.IsNullOrWhiteSpace(explicitPath)) return explicitPath.Trim(); - if (!string.IsNullOrEmpty(element.Name)) return pageTag + ":" + element.Name; - return pageTag + ":card" + ordinal; - } + var element = entry.Element; + if (element == null) return; + if ((bool)element.GetValue(MoreInjectedProperty)) return; + if (!IsTrueSettingItem(element, entry.IsGroupHeader)) return; - private static void Inject(FrameworkElement element, string identity) - { // 定位内层 SettingsCard(LabeledSettingsCard 包裹了一个 SettingsCard) var card = element as SettingsCard ?? FindVisualChild(element); - if (card == null) return; + if (card == null || !IsCardTemplateReady(card)) + { + if (!element.IsLoaded) + { + RoutedEventHandler once = null; + once = (s, e) => + { + element.Loaded -= once; + InjectWhenReady(entry, pageTag); + }; + element.Loaded += once; + } + return; + } + + element.SetValue(MoreInjectedProperty, true); + Inject(card, entry.Identity, pageTag, entry.HasRealKey); + } - // 强制应用模板,覆盖折叠展开器内尚未实化的卡片 + private static bool IsCardTemplateReady(SettingsCard card) + { try { card.ApplyTemplate(); } catch { } + return card.Template?.FindName("PART_HeaderPresenter", card) is FrameworkElement; + } + /// + /// 解析卡片的真实设置键(用于收藏/深链身份)。 + /// 优先级:卡片 SettingsTags.PropertyPath → 卡片或卡内控件 SettingsBinder.PropertyPath。 + /// + private static bool ResolveSettingKey(FrameworkElement element, out string key) + { + foreach (var node in EnumerateLogicalDescendants(element)) + { + if (!(node is FrameworkElement fe)) continue; + string p = SettingsTags.GetPropertyPath(fe); + if (string.IsNullOrWhiteSpace(p)) p = SettingsBinder.GetPropertyPath(fe); + if (!string.IsNullOrWhiteSpace(p)) + { + key = p.Trim(); + return true; + } + } + key = null; + return false; + } + + /// + /// 组顶行身份用键:只扫描 SettingsExpander 的 Content(即组顶行承载的开关等), + /// 不扫 Items,避免把折叠组内子项的真实键误当作组顶行的键。 + /// + private static bool ResolveContentSettingKey(SettingsExpander expander, out string key) + { + if (expander?.Content is FrameworkElement content) + { + return ResolveSettingKey(content, out key); + } + key = null; + return false; + } + + /// + /// 是否真设置项:LabeledSettingsCard(本身即开关)恒真;普通 SettingsCard 需内容子树含设置控件, + /// 且不是折叠分组 header。组顶行卡(expander header 内含真开关,如"启用启动动画")经显式补位后放行。 + /// + private static bool IsTrueSettingItem(FrameworkElement element, bool allowGroupHeader = false) + { + if (element is LabeledSettingsCard) return true; + // 显式声明了 PropertyPath 的卡片视为真设置项(作者声明该行可收藏 / 可深链) + if (ResolveSettingKey(element, out _)) return true; + if (!allowGroupHeader && IsSettingsExpanderGroupHeaderCard(element)) return false; + return ContainsSettingControl(element); + } + + /// + /// 内容子树是否含"设置控件"。决定卡片是否算可操作设置行。 + /// + private static bool ContainsSettingControl(FrameworkElement element) + { + foreach (var node in EnumerateLogicalDescendants(element)) + { + if (node is iNKORE.UI.WPF.Modern.Controls.ToggleSwitch + || node is iNKORE.UI.WPF.Modern.Controls.NumberBox + || node is ComboBox || node is Slider || node is TextBox + || node is CheckBox || node is RadioButton || node is PasswordBox) + return true; + } + return false; + } + + /// + /// SettingsExpander 模板内的分组 header 卡(视觉祖先经过名为 ExpanderHeader 的折叠按钮)。 + /// 分组 header 视为"其他显示内容",不注入。 + /// + private static bool IsSettingsExpanderGroupHeaderCard(FrameworkElement card) + { + DependencyObject cur = card; + while (cur != null) + { + cur = VisualTreeHelper.GetParent(cur); + if (cur is ToggleButton tb && tb.Name == "ExpanderHeader") return true; + } + return false; + } + + private static void Inject(SettingsCard card, string identity, string pageTag, bool hasRealKey) + { SettingsTag tags = SettingsTagResolver.GetTags(identity); bool isFavourite = SettingsTagResolver.IsFavourite(identity); @@ -125,13 +290,38 @@ private static void Inject(FrameworkElement element, string identity) headerRow.Children.Add(chips); } - var star = BuildStarButton(identity, isFavourite, favouriteChip); - if (star != null) + headerPanel.Children.Insert(0, headerRow); + + // 更多按钮放到卡片模板最右侧动作列(col3),跨两行垂直居中 + var more = BuildMoreButton(identity, pageTag, hasRealKey, isFavourite, favouriteChip); + if (more != null) { - headerRow.Children.Add(star); + PlaceMoreButton(card, more); } + } - headerPanel.Children.Insert(0, headerRow); + /// + /// 将更多按钮放入 SettingsCard 模板根 Grid 的最右列(Grid.Column=3,PART_ActionIconPresenterHolder 同列)。 + /// 不占用库代码托管 ActionIcon 子级,避免其 OnActionIconChanged 回调把可见性改回 Collapsed。 + /// + private static void PlaceMoreButton(SettingsCard card, Button more) + { + try + { + if (!(card.Template?.FindName("PART_RootGrid", card) is Grid rootGrid)) return; + + // 左间距收窄:默认动作列 SettingsCardActionIconMargin 左边距 14 太宽 + more.Margin = new Thickness(4, 0, 0, 0); + more.VerticalAlignment = VerticalAlignment.Center; + more.HorizontalAlignment = HorizontalAlignment.Center; + Grid.SetColumn(more, 3); + Grid.SetRowSpan(more, 2); + rootGrid.Children.Add(more); + } + catch (Exception ex) + { + Debug.WriteLine($"放置设置项更多按钮出错: {ex.Message}"); + } } private static StackPanel BuildChips(SettingsTag tags, string identity, out Border favouriteChip) @@ -182,17 +372,17 @@ private static Border CreateChip(string text, Color color) }; } - private static ToggleButton BuildStarButton(string identity, bool isFavourite, Border favouriteChip) + private static Button BuildMoreButton(string identity, string pageTag, + bool hasRealKey, bool isFavourite, Border favouriteChip) { var icon = new iNKORE.UI.WPF.Modern.Controls.FontIcon { - Icon = isFavourite ? SegoeFluentIcons.FavoriteStarFill : SegoeFluentIcons.FavoriteStar, + Icon = FluentSystemIcons.MoreVertical_20_Regular, FontSize = 16, }; - var button = new ToggleButton + var button = new Button { - IsChecked = isFavourite, Content = icon, Width = 28, Height = 28, @@ -201,25 +391,85 @@ private static ToggleButton BuildStarButton(string identity, bool isFavourite, B VerticalAlignment = VerticalAlignment.Center, Background = Brushes.Transparent, BorderThickness = new Thickness(0), - ToolTip = isFavourite ? CommonStrings.SettingsTag_FavouriteRemove : CommonStrings.SettingsTag_FavouriteAdd, + ToolTip = CommonStrings.SettingsItemMore, }; - button.Checked += (s, e) => + var menu = new ContextMenu(); + var itemCopyKey = new MenuItem { - icon.Icon = SegoeFluentIcons.FavoriteStarFill; - button.ToolTip = CommonStrings.SettingsTag_FavouriteRemove; - ToggleFavourite(identity, true, favouriteChip); + Header = CommonStrings.SettingsItemCopyKey, + Visibility = hasRealKey ? Visibility.Visible : Visibility.Collapsed, }; - button.Unchecked += (s, e) => + var itemCopyUrl = new MenuItem { Header = CommonStrings.SettingsItemCopyUrl }; + var itemFavourite = new MenuItem { - icon.Icon = SegoeFluentIcons.FavoriteStar; - button.ToolTip = CommonStrings.SettingsTag_FavouriteAdd; - ToggleFavourite(identity, false, favouriteChip); + Header = isFavourite ? CommonStrings.SettingsItemRemoveFavourite : CommonStrings.SettingsItemAddFavourite, + }; + menu.Items.Add(itemCopyKey); + menu.Items.Add(itemCopyUrl); + menu.Items.Add(itemFavourite); + button.ContextMenu = menu; + + itemCopyKey.Click += (s, e) => + { + e.Handled = true; + SafeCopyText(identity); + }; + itemCopyUrl.Click += (s, e) => + { + e.Handled = true; + string url = "icc://settings/entry?page=" + Uri.EscapeDataString(pageTag) + + "&path=" + Uri.EscapeDataString(identity); + SafeCopyText(url); + }; + itemFavourite.Click += (s, e) => + { + e.Handled = true; + bool add = !SettingsTagResolver.IsFavourite(identity); + ToggleFavourite(identity, add, favouriteChip); + itemFavourite.Header = add + ? CommonStrings.SettingsItemRemoveFavourite + : CommonStrings.SettingsItemAddFavourite; + }; + + // 单击按钮即弹出菜单,且不触发卡片整卡 Click + button.Click += (s, e) => + { + e.Handled = true; + button.ContextMenu.PlacementTarget = button; + button.ContextMenu.IsOpen = true; }; return button; } + /// + /// 复制文本。WPF 的 OLE flush 会被剪贴板上一持有者阻塞数秒,导致 UI 冻结, + /// 故放到后台 STA 线程执行;被其它进程占用时可能抛 CLIPBRD_E_CANT_OPEN,重试后静默忽略, + /// 避免异常冒泡触发全局崩溃窗口。 + /// + private static void SafeCopyText(string text) + { + var thread = new System.Threading.Thread(() => + { + for (int i = 0; i < 5; i++) + { + try + { + Clipboard.SetText(text); + return; + } + catch (COMException) + { + System.Threading.Thread.Sleep(50); + } + } + }); + thread.SetApartmentState(System.Threading.ApartmentState.STA); + thread.IsBackground = true; + thread.Start(); + } + private static void ToggleFavourite(string identity, bool add, Border favouriteChip) { var settings = SettingsManager.Settings; @@ -285,5 +535,31 @@ private static T FindVisualChild(DependencyObject parent) where T : Dependenc } return null; } + + /// + /// 取 SettingsExpander 模板内"组顶行"SettingsCard(ExpanderHeader 折叠按钮内容中的那张卡, + /// 其上承载组开关/内容,如"启用启动动画"顶行)。Header 行始终显示,展开器物化后即可取到。 + /// + private static SettingsCard FindExpanderGroupHeaderCard(SettingsExpander expander) + { + if (expander == null) return null; + try { expander.ApplyTemplate(); } catch { } + var headerToggle = FindVisualDescendantByName(expander, "ExpanderHeader"); + if (headerToggle == null) return null; + return FindVisualChild(headerToggle); + } + + private static T FindVisualDescendantByName(DependencyObject parent, string name) where T : FrameworkElement + { + int count = VisualTreeHelper.GetChildrenCount(parent); + for (int i = 0; i < count; i++) + { + var child = VisualTreeHelper.GetChild(parent, i); + if (child is T typed && typed.Name == name) return typed; + var nested = FindVisualDescendantByName(child, name); + if (nested != null) return nested; + } + return null; + } } } diff --git a/Ink Canvas/Windows/SettingsViews/Pages/StartupPage.xaml b/Ink Canvas/Windows/SettingsViews/Pages/StartupPage.xaml index b1de96e5..edacf4b7 100644 --- a/Ink Canvas/Windows/SettingsViews/Pages/StartupPage.xaml +++ b/Ink Canvas/Windows/SettingsViews/Pages/StartupPage.xaml @@ -7,6 +7,7 @@ xmlns:ui="http://schemas.inkore.net/lib/ui/wpf/modern" xmlns:ikw="http://schemas.inkore.net/lib/ui/wpf" xmlns:sys="clr-namespace:System;assembly=mscorlib" + xmlns:helpers="clr-namespace:Ink_Canvas.Windows.SettingsViews.Helpers" mc:Ignorable="d" Title="{x:Static props:NavStrings.Nav_Startup}"> @@ -141,6 +142,7 @@ @@ -162,6 +164,7 @@ diff --git a/Ink Canvas/Windows/SettingsViews/Pages/UpdatePage.xaml b/Ink Canvas/Windows/SettingsViews/Pages/UpdatePage.xaml index c84eb188..b1157112 100644 --- a/Ink Canvas/Windows/SettingsViews/Pages/UpdatePage.xaml +++ b/Ink Canvas/Windows/SettingsViews/Pages/UpdatePage.xaml @@ -190,11 +190,10 @@ SwitchName="ToggleSwitchIsSmartUpdate" Toggled="ToggleSwitchIsSmartUpdate_Toggled" /> - + diff --git a/Ink Canvas/Windows/SettingsViews/Pages/UpdatePage.xaml.cs b/Ink Canvas/Windows/SettingsViews/Pages/UpdatePage.xaml.cs index f2b20494..b81ba2e5 100644 --- a/Ink Canvas/Windows/SettingsViews/Pages/UpdatePage.xaml.cs +++ b/Ink Canvas/Windows/SettingsViews/Pages/UpdatePage.xaml.cs @@ -10,6 +10,7 @@ using System.Windows; using System.Windows.Controls; using System.Windows.Input; +using System.Windows.Media; using System.Windows.Threading; using MessageBox = iNKORE.UI.WPF.Modern.Controls.MessageBox; @@ -51,6 +52,53 @@ public UpdatePage() { InitializeComponent(); Loaded += UpdatePage_Loaded; + ExpanderSilentUpdateTime.Loaded += ExpanderSilentUpdateTime_Loaded; + + // iNKORE.UI.WPF.Modern 0.10.2.1:内层 Expander 在 Load 时若无模板会走动画并空引用。 + // 提前应用模板,让库的 GetToAnimateControl 能取到 ExpanderContent。 + ExpanderSilentUpdateTime.ApplyTemplate(); + if (VisualTreeHelper.GetChildrenCount(ExpanderSilentUpdateTime) > 0 && + VisualTreeHelper.GetChild(ExpanderSilentUpdateTime, 0) is Expander innerExpander) + { + innerExpander.ApplyTemplate(); + } + } + + private int _silentExpanderRetryCount; + private const int MaxSilentExpanderRetries = 60; + + private void ExpanderSilentUpdateTime_Loaded(object sender, RoutedEventArgs e) + { + Dispatcher.BeginInvoke(new Action(SetSilentUpdateTimeExpanderState), DispatcherPriority.Background); + } + + private void SetSilentUpdateTimeExpanderState() + { + Expander innerExpander = null; + if (ExpanderSilentUpdateTime.Template != null && + VisualTreeHelper.GetChildrenCount(ExpanderSilentUpdateTime) > 0 && + VisualTreeHelper.GetChild(ExpanderSilentUpdateTime, 0) is Expander inner) + { + innerExpander = inner; + innerExpander.ApplyTemplate(); + } + + bool partReady = innerExpander?.Template?.FindName( + "ExpanderContent", innerExpander) is FrameworkElement; + + if (!partReady) + { + // 库在首次加载时可能还没生成可动画化的模板部件; + // 此时展开会触发库内部 NullReferenceException,推迟到部件就绪后再展开。 + if (_silentExpanderRetryCount++ < MaxSilentExpanderRetries) + { + Dispatcher.BeginInvoke(new Action(SetSilentUpdateTimeExpanderState), DispatcherPriority.ApplicationIdle); + } + return; + } + + _silentExpanderRetryCount = 0; + ExpanderSilentUpdateTime.IsExpanded = CardSilentUpdate.IsOn; } private async void UpdatePage_Loaded(object sender, RoutedEventArgs e) @@ -190,6 +238,7 @@ private void ToggleSwitchIsAutoUpdateWithSilence_Toggled(object sender, RoutedEv { SettingsManager.Settings.Startup.IsAutoUpdateWithSilence = CardSilentUpdate.IsOn; SettingsManager.SaveSettingsToFile(); + SetSilentUpdateTimeExpanderState(); } catch (Exception ex) { diff --git a/Ink Canvas/Windows/SettingsViews/SettingsWindow.xaml.cs b/Ink Canvas/Windows/SettingsViews/SettingsWindow.xaml.cs index 05674657..7992d69c 100644 --- a/Ink Canvas/Windows/SettingsViews/SettingsWindow.xaml.cs +++ b/Ink Canvas/Windows/SettingsViews/SettingsWindow.xaml.cs @@ -634,12 +634,14 @@ private void EnsureSearchIndexBuilt() private void CollectEntriesFromPage(DependencyObject root, string pageTag) { // 卡片身份与星标注入使用同一遍历,保证收藏匹配一致 + var entries = Ink_Canvas.Windows.SettingsViews.Helpers.SettingsTags.EnumerateCardIdentities(root, pageTag); var identities = new Dictionary(); - foreach (var pair in Ink_Canvas.Windows.SettingsViews.Helpers.SettingsTags.EnumerateCardIdentities(root, pageTag)) + foreach (var entry in entries) { - identities[pair.Key] = pair.Value; + identities[entry.Element] = entry.Identity; } + var covered = new HashSet(); foreach (var node in EnumerateLogicalDescendants(root)) { string header = null; @@ -660,6 +662,7 @@ private void CollectEntriesFromPage(DependencyObject root, string pageTag) if (!string.IsNullOrWhiteSpace(header) && target != null) { + covered.Add(target); string propertyPath = null; identities.TryGetValue(target, out propertyPath); @@ -672,6 +675,36 @@ private void CollectEntriesFromPage(DependencyObject root, string pageTag) }); } } + + // 枚举中含、但不在逻辑树中的卡(组顶行模板卡、折叠组内子项)也要进索引, + // 否则收藏/深链定位不到这些行。 + foreach (var entry in entries) + { + if (covered.Contains(entry.Element)) continue; + string header = null; + if (entry.Element is Ink_Canvas.Controls.LabeledSettingsCard lsc2) + { + header = lsc2.Header; + } + else if (entry.Element is iNKORE.UI.WPF.Modern.Controls.SettingsCard sc2) + { + header = sc2.Header?.ToString(); + } + else if (entry.Element is iNKORE.UI.WPF.Modern.Controls.SettingsExpander se2) + { + header = se2.Header?.ToString(); + } + if (string.IsNullOrWhiteSpace(header)) continue; + + covered.Add(entry.Element); + _searchIndex.Add(new SearchEntry + { + Text = header.Trim(), + PageTag = pageTag, + PropertyPath = entry.Identity, + Target = new WeakReference(entry.Element) + }); + } } private static IEnumerable EnumerateLogicalDescendants(DependencyObject root) @@ -733,12 +766,18 @@ public List GetFavouriteEntries() { EnsureSearchIndexBuilt(); var result = new List(); + var stale = new System.Collections.Generic.List(); foreach (var path in global::Ink_Canvas.Helpers.SettingsTagResolver.GetFavouritePaths()) { var entry = _searchIndex.FirstOrDefault(e => !string.IsNullOrEmpty(e.PropertyPath) && string.Equals(e.PropertyPath, path, StringComparison.OrdinalIgnoreCase)); - if (entry == null) continue; + if (entry == null) + { + // 身份规则演进后旧收藏可能已失配,静默清理,避免永久残留脏数据 + stale.Add(path); + continue; + } string pageTitle = FindNavigationViewItemByTag(entry.PageTag)?.Content?.ToString() ?? entry.PageTag; result.Add(new FavouriteEntry @@ -750,6 +789,16 @@ public List GetFavouriteEntries() Target = entry.Target, }); } + + if (stale.Count > 0) + { + var favs = Helpers.SettingsManager.Settings?.FavouriteSettings; + if (favs != null) + { + foreach (var p in stale) favs.RemoveAll(x => string.Equals(x, p, StringComparison.OrdinalIgnoreCase)); + Helpers.SettingsManager.SaveSettingsToFile(); + } + } return result; } @@ -766,6 +815,44 @@ public void NavigateToFavourite(string propertyPath) NavigateToSearchEntry(entry); } + /// + /// 深链跳转:定位到指定页面内某个设置项(PropertyPath 身份)并滚动到该卡片。 + /// + public void NavigateToEntry(string pageTag, string propertyPath) + { + if (string.IsNullOrEmpty(propertyPath)) return; + EnsureSearchIndexBuilt(); + var entry = _searchIndex.FirstOrDefault(e => + !string.IsNullOrEmpty(e.PageTag) && + !string.IsNullOrEmpty(e.PropertyPath) && + string.Equals(e.PageTag, pageTag, StringComparison.OrdinalIgnoreCase) && + string.Equals(e.PropertyPath, propertyPath, StringComparison.OrdinalIgnoreCase)); + if (entry == null) + { + entry = _searchIndex.FirstOrDefault(e => + !string.IsNullOrEmpty(e.PropertyPath) && + string.Equals(e.PropertyPath, propertyPath, StringComparison.OrdinalIgnoreCase)); + } + + if (entry != null) + { + NavigateToSearchEntry(entry); + return; + } + + // 索引未命中时兜底:至少打开目标页面 + if (!string.IsNullOrEmpty(pageTag) && _pageTypes.ContainsKey(pageTag)) + { + NavigateToPage(pageTag); + var navItem = FindNavigationViewItemByTag(pageTag); + if (navItem != null && NavigationViewControl.SelectedItem != navItem) + { + NavigationViewControl.SelectedItem = navItem; + NavigationViewControl.Header = navItem.Content; + } + } + } + private void OnControlsSearchBoxTextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) { if (args.Reason != AutoSuggestionBoxTextChangeReason.UserInput) return; diff --git a/rules/settings_pages.md b/rules/settings_pages.md index c26184ed..8f8349e9 100644 --- a/rules/settings_pages.md +++ b/rules/settings_pages.md @@ -212,14 +212,25 @@ public bool IsEnableForceFullScreen { get; set; } **所有** `SettingsCard` / `LabeledSettingsCard` 都会自动获得收藏星标(无需声明 `PropertyPath`),由页面级扫描注入(`SettingsTags.InjectStarsIntoPage`,挂 `SettingsWindow.NavigateToPage` 的页 Loaded,每页一次)。 +注入范围与"更多按钮"规则一致: +- 常规设置卡(内容含开关 / 下拉 / 滑杆等设置控件)恒注入。 +- `SettingsExpander` **组顶行**若本身就是开关组(内容含真开关,如「启用启动动画」「系统托盘」顶行),同样注入;纯分组 / 展示行不注入。 +- 显式声明 `helpers:SettingsTags.PropertyPath` 的卡片按真设置项处理(即使内容无开关 / 输入控件,也可收藏、可深链),身份即该 PropertyPath。 + +卡片呈现: +- 标题右侧:标签 chip(如 警告 / 新 / 实验性)。 +- 卡片**最右缘**(内容控件右侧动作列):`⋯` 更多按钮(菜单:复制设置键 / 复制深链 / 收藏)。 + 卡片身份(收藏存储的标识)优先级: 1. 显式 `PropertyPath`(真属性路径,即带 tag 的卡片) 2. 卡片 `x:Name` → `"{pageTag}:{xName}"` -3. 兜底 → `"{pageTag}:card{序号}"`(序号 = 页面逻辑树遍历序) +3. 兜底 → `"{pageTag}:card{序号}"(序号 = 页面遍历序) + +`SettingsExpander` **组顶行**的身份锚定在 expander 逻辑节点(内容开关键 → expander `x:Name` → 序号),**不依赖模板是否物化**,保证收藏时与索引构建时身份一致。 -⚠️ **注意**:无 `x:Name` / 无 `PropertyPath` 的卡片用序号兜底,XAML 中重排卡片会导致旧收藏指向错位。若要固化身份,给卡片补 `x:Name`(或显式 `PropertyPath`)。 +⚠️ **注意**:无 `x:Name` / 无 `PropertyPath` 的卡片用序号兜底,XAML 中重排卡片会导致旧收藏指向错位。若要固化身份,给卡片补 `x:Name`(或显式 `PropertyPath`)。失配的旧收藏会在打开收藏夹时被静默清理。 -身份推导与搜索索引共用同一遍历(`SettingsTags.EnumerateCardIdentities`),收藏匹配与跳转对所有卡片生效。 +身份推导与搜索索引共用同一遍历(`SettingsTags.EnumerateCardIdentities`)。索引会额外收录枚举含但不在页面逻辑树中的行(组顶行模板卡、折叠组内子项),收藏匹配与跳转对所有卡片生效。 ### 新增 tag 类型