diff --git a/OneMore/AddInCommands.cs b/OneMore/AddInCommands.cs index 9055a9d950..d352f9f6bc 100644 --- a/OneMore/AddInCommands.cs +++ b/OneMore/AddInCommands.cs @@ -90,6 +90,9 @@ public async Task EnableSpellCheckCmd(IRibbonControl control) public async Task ExpandContentCmd(IRibbonControl control) => await factory.Run(Expando.Expand); + public async Task FinishBiLinkCmd(IRibbonControl control) + => await factory.Run("link"); + public async Task GetImagesCmd(IRibbonControl control) => await factory.Run(true); @@ -288,6 +291,9 @@ public async Task SplitCmd(IRibbonControl control) public async Task SplitTableCmd(IRibbonControl control) => await factory.Run(); + public async Task StartBiLinkCmd(IRibbonControl control) + => await factory.Run("mark"); + public async Task StrikeoutCmd(IRibbonControl control) => await factory.Run(); diff --git a/OneMore/Commands/Clean/ClearBackgroundCommand.cs b/OneMore/Commands/Clean/ClearBackgroundCommand.cs index 56341201f6..5462079d07 100644 --- a/OneMore/Commands/Clean/ClearBackgroundCommand.cs +++ b/OneMore/Commands/Clean/ClearBackgroundCommand.cs @@ -68,16 +68,16 @@ private bool ClearTextBackground(IEnumerable runs, bool deep = false) var darkText = !style.Color.Equals(Style.Automatic) && ColorTranslator.FromHtml(style.Color).GetBrightness() < 0.5; - // if dark-on-dkar or light-on-light + // if dark-on-dark or light-on-light if (darkText == darkPage) { style.Color = darkText ? White : Black; - - var stylizer = new Stylizer(style); - stylizer.ApplyStyle(run); - updated = true; } + var stylizer = new Stylizer(style); + stylizer.ApplyStyle(run); + updated = true; + // deep prevents runs from being processed multiple times // NOTE sometimes OneNote will incorrectly set the collapsed attribute, diff --git a/OneMore/Commands/References/BiLinkCommand.cs b/OneMore/Commands/References/BiLinkCommand.cs new file mode 100644 index 0000000000..07577bd037 --- /dev/null +++ b/OneMore/Commands/References/BiLinkCommand.cs @@ -0,0 +1,307 @@ +//************************************************************************************************ +// Copyright © 2021 Steven M Cohn. All rights reserved. +//************************************************************************************************ + +namespace River.OneMoreAddIn +{ + using River.OneMoreAddIn.Models; + using System.Linq; + using System.Threading.Tasks; + using System.Xml.Linq; + using Resx = River.OneMoreAddIn.Properties.Resources; + + + /// + /// Create a bi-directional link between two selected words or phrases, either across pages + /// or on the same page. This is invoked as two commands, the first to mark the first word + /// or phrase and the second to select and create the link with the second words or phrase. + /// + internal class BiLinkCommand : Command + { + // TODO: consider moving these to a global state cache that can be pruned + // rather than holding on to them indefinitely as statics.... + + private static string anchorPageId; + private static string anchorId; + private static SelectionRange anchor; + + private string anchorText; + private string error; + + + public BiLinkCommand() + { + } + + + public override async Task Execute(params object[] args) + { + using (var one = new OneNote()) + { + if ((args[0] is string cmd) && (cmd == "mark")) + { + if (!MarkAnchor(one)) + { + UIHelper.ShowInfo(one.Window, Resx.BiLinkCommand_BadAnchor); + IsCancelled = true; + return; + } + + if (anchorText.Length > 20) { anchorText = $"{anchorText.Substring(0,20)}..."; } + UIHelper.ShowInfo(one.Window, string.Format(Resx.BiLinkCommand_Marked, anchorText)); + } + else + { + if (string.IsNullOrEmpty(anchorPageId)) + { + UIHelper.ShowInfo(one.Window, Resx.BiLinkCommand_NoAnchor); + IsCancelled = true; + return; + } + + if (!await CreateLinks(one)) + { + UIHelper.ShowInfo(one.Window, string.Format(Resx.BiLinkCommand_BadTarget, error)); + IsCancelled = true; + return; + } + + // reset + anchorPageId = null; + anchorId = null; + anchor = null; + } + } + + await Task.Yield(); + } + + + private bool MarkAnchor(OneNote one) + { + var page = one.GetPage(); + var range = new SelectionRange(page.Root); + + // get selected runs but preserve cursor if there is one so we can edit from it later + var run = range.GetSelection(); + if (run == null) + { + logger.WriteLine("no selected content"); + return false; + } + + // anchor is the surrounding OE + anchor = new SelectionRange(run.Parent); + + anchorId = anchor.ObjectId; + if (string.IsNullOrEmpty(anchorId)) + { + logger.WriteLine("missing objectID"); + anchor = null; + return false; + } + + anchorPageId = one.CurrentPageId; + anchorText = page.GetSelectedText(); + return true; + } + + + private async Task CreateLinks(OneNote one) + { + // - - - - anchor... + + var anchorPage = one.GetPage(anchorPageId); + if (anchorPage == null) + { + logger.WriteLine($"lost anchor page {anchorPageId}"); + error = Resx.BiLinkCommand_LostAnchor; + return false; + } + + var candidate = anchorPage.Root.Descendants() + .FirstOrDefault(e => e.Attributes("objectID").Any(a => a.Value == anchorId)); + + if (candidate == null) + { + logger.WriteLine($"lost anchor paragraph {anchorId}"); + error = Resx.BiLinkCommand_LostAnchor; + return false; + } + + // ensure anchor selection hasn't changed and is still selected! + if (AnchorModified(candidate, anchor.Root)) + { + logger.WriteLine($"anchor paragraph may have changed"); + error = Resx.BiLinkCommand_LostAnchor; + return false; + } + + // - - - - target... + + Page targetPage = anchorPage; + var targetPageId = anchorPageId; + if (one.CurrentPageId != anchorPageId) + { + targetPage = one.GetPage(); + targetPageId = targetPage.PageId; + } + + var range = new SelectionRange(targetPage.Root); + var targetRun = range.GetSelection(); + if (targetRun == null) + { + logger.WriteLine("no selected target content"); + error = Resx.BiLinkCommand_NoTarget; + return false; + } + + var target = new SelectionRange(targetRun.Parent); + var targetId = target.ObjectId; + if (anchorId == targetId) + { + logger.WriteLine("cannot link a phrase to itself"); + error = Resx.BiLinkCommand_Circular; + return false; + } + + // - - - - action... + + // anchorPageId -> anchorPage -> anchorId -> anchor + // targetPageId -> targetPage -> targetId -> target + + var anchorLink = one.GetHyperlink(anchorPageId, anchorId); + var targetLink = one.GetHyperlink(targetPageId, targetId); + + ApplyHyperlink(anchorPage, anchor, targetLink); + ApplyHyperlink(targetPage, target, anchorLink); + + candidate.ReplaceAttributes(anchor.Root.Attributes()); + candidate.ReplaceNodes(anchor.Root.Nodes()); + + if (targetPageId == anchorPageId) + { + // avoid invalid selection by leaving only partials without an all + candidate.DescendantsAndSelf().Attributes("selected").Remove(); + } + + //logger.WriteLine(); + //logger.WriteLine("LINKING"); + //logger.WriteLine($" anchorPageId = {anchorPageId}"); + //logger.WriteLine($" anchorId = {anchorId}"); + //logger.WriteLine($" anchorLink = {anchorLink}"); + //logger.WriteLine($" candidate = '{candidate}'"); + //logger.WriteLine($" targetPageId = {targetPageId}"); + //logger.WriteLine($" targetId = {targetId}"); + //logger.WriteLine($" targetLink = {targetLink}"); + //logger.WriteLine($" target = '{target}'"); + //logger.WriteLine(); + //logger.WriteLine("---------------------------------------------"); + //logger.WriteLine(targetPage.Root); + + await one.Update(targetPage); + + if (targetPageId != anchorPageId) + { + // avoid invalid selection by leaving only partials without an all + anchorPage.Root.DescendantsAndSelf().Attributes("selected").Remove(); + await one.Update(anchorPage); + } + + return true; + } + + + private bool AnchorModified(XElement candidate, XElement anchor) + { + // special deep comparison, excluding the selected attributes to handle + // case where anchor is on the same page as the target element + + var oldcopy = new SelectionRange(anchor.Clone()); + oldcopy.Deselect(); + + var newcopy = new SelectionRange(candidate.Clone()); + newcopy.Deselect(); + + var oldxml = oldcopy.ToString(); + var newxml = newcopy.ToString(); + + //if (oldxml != newxml) + //{ + // logger.WriteLine("differences found in candidate/anchor"); + // logger.WriteLine("oldxml/candidate"); + // logger.WriteLine(oldxml); + // logger.WriteLine("newxml/anchor"); + // logger.WriteLine(newxml); + //} + + return oldxml != newxml; + } + + + private void ApplyHyperlink(Page page, SelectionRange range, string link) + { + var count = 0; + + var selection = range.GetSelection(); + if (range.SelectionScope == SelectionScope.Empty) + { + page.EditNode(selection, (s) => + { + count++; + return new XElement("a", new XAttribute("href", link), s); + }); + } + else + { + page.EditSelected(range.Root, (s) => + { + count++; + return new XElement("a", new XAttribute("href", link), s); + }); + } + + // combine doubled-up ... + // WARN: this could loose styling + + if (count > 0 && range.SelectionScope == SelectionScope.Empty) + { + var cdata = selection.GetCData(); + + if (selection.PreviousNode is XElement prev) + { + var cprev = prev.GetCData(); + var wrapper = cprev.GetWrapper(); + if (wrapper.LastNode is XElement node) + { + cdata.Value = $"{node.ToString(SaveOptions.DisableFormatting)}{cdata.Value}"; + node.Remove(); + cprev.Value = wrapper.GetInnerXml(); + } + + if (cprev.Value.Length == 0) + { + prev.Remove(); + } + } + + if (selection.NextNode is XElement next) + { + var cnext = next.GetCData(); + var wrapper = cnext.GetWrapper(); + if (wrapper.FirstNode is XElement node) + { + cdata.Value = $"{cdata.Value}{node.ToString(SaveOptions.DisableFormatting)}"; + node.Remove(); + cnext.Value = wrapper.GetInnerXml(); + } + + if (cnext.Value.Length == 0) + { + next.Remove(); + } + } + } + } + } +} diff --git a/OneMore/Helpers/Extensions/StringExtensions.cs b/OneMore/Helpers/Extensions/StringExtensions.cs index f1dbe6b2c4..f716daac01 100644 --- a/OneMore/Helpers/Extensions/StringExtensions.cs +++ b/OneMore/Helpers/Extensions/StringExtensions.cs @@ -68,14 +68,15 @@ public static bool StartsWithICIC(this string s, string value) /// - /// Extract the first word delimeted by a non-word boundary from the given - /// string and returns the word and updated string. + /// Splits a string into its first word and the remaining characters as delimited by + /// the first non-word boundary. /// /// A string with one or more words /// - /// A two-part ValueTuple with the word and the updated string. + /// A two-part ValueTuple with the first word and the remaining string. If the given + /// string does not contain a word boundary then this returns (null,s) /// - public static (string, string) ExtractFirstWord(this string s) + public static (string, string) SplitAtFirstWord(this string s) { if (!string.IsNullOrEmpty(s)) { @@ -96,14 +97,15 @@ public static (string, string) ExtractFirstWord(this string s) /// - /// Extract the last word delimeted by a non-word boundary from the given - /// string and returns the word and updated string. + /// Splits a string into its last word and the remaining characters as delimited by + /// the last non-word boundary. /// /// A string with one or more words /// - /// A two-part ValueTuple with the word and the updated string. + /// A two-part ValueTuple with the last word and the remaining string. If the given + /// string does not contain a word boundary then this returns (null,s) /// - public static (string, string) ExtractLastWord(this string s) + public static (string, string) SplitAtLastWord(this string s) { if (!string.IsNullOrEmpty(s)) { @@ -143,7 +145,7 @@ public static XElement ToXmlWrapper(this string s) // quote unquote language attribute, e.g., lang=yo to lang="yo" (or two part en-US) value = Regex.Replace(value, @"(\s)lang=([\w\-]+)([\s/>])", "$1lang=\"$2\"$3"); - return XElement.Parse("" + value + ""); + return XElement.Parse($"{value}"); } diff --git a/OneMore/Helpers/Extensions/XElementExtensions.cs b/OneMore/Helpers/Extensions/XElementExtensions.cs index c5f848ee37..ee92968067 100644 --- a/OneMore/Helpers/Extensions/XElementExtensions.cs +++ b/OneMore/Helpers/Extensions/XElementExtensions.cs @@ -4,7 +4,6 @@ namespace River.OneMoreAddIn { - using System; using System.Collections.Generic; using System.ComponentModel; using System.Globalization; @@ -17,6 +16,41 @@ namespace River.OneMoreAddIn internal static class XElementExtensions { + /// + /// Properly deep clones the given element, possibly restoring the "one:" namespace + /// prefix if it is not present, e.g. from a snipet of a larger document. + /// + /// + /// + public static XElement Clone(this XElement element) + { + var ns = element.GetNamespaceOfPrefix(OneNote.Prefix); + if (ns == null) + { + ns = element.GetDefaultNamespace(); + } + + if (ns == string.Empty) + { + // no namespace, might be a CDATA Wrapper + return new XElement( + element.Name.LocalName, + element.Attributes(), + element.Nodes() + ); + } + + // reconstruct the "one" namespace by removing all namespace declaration attributes + // and adding a new one + return new XElement( + ns + element.Name.LocalName, + element.Attributes().Where(a => !a.IsNamespaceDeclaration), + new XAttribute(XNamespace.Xmlns + "one" /*OneNote.Prefix*/, ns), + element.Nodes() + ); + } + + /// /// Extract the style properties of this element by looking at its "style" attribute /// if one exists and possibly its immediate CDATA child and any span/style attributes. @@ -42,25 +76,6 @@ public static Dictionary CollectStyleProperties( var sprops = span.CollectStyleProperties(); sprops.ToList().ForEach(c => props.Add(c.Key, c.Value)); } - - /* - var wrapper = cdata.GetWrapper(); - - // collect inner span@style properties only if CDATA is entirely wrapped - // by a span element with style attributes - - if (wrapper.Nodes().Count() == 1 && - wrapper.FirstNode.NodeType == XmlNodeType.Element && - ((XElement)wrapper.FirstNode).Name.LocalName == "span") - { - var span = (XElement)wrapper.FirstNode; - if (span.Attributes("style").Any()) - { - var sprops = span.CollectStyleProperties(); - sprops.ToList().ForEach(c => props.Add(c.Key, c.Value)); - } - } - */ } } @@ -144,8 +159,9 @@ public static string GetInnerXml(this XElement element) /// Remove and return the first textual word from the element content /// /// The element to modify + /// Return the word preserving its styled span parent /// A string that can be appended to a CData's raw content - public static string ExtractFirstWord(this XElement element) + public static string ExtractFirstWord(this XElement element, bool styled = false) { if (element.FirstNode == null) { @@ -154,7 +170,7 @@ public static string ExtractFirstWord(this XElement element) if (element.FirstNode.NodeType == XmlNodeType.Text) { - var pair = element.Value.ExtractFirstWord(); + var pair = element.Value.SplitAtFirstWord(); element.Value = pair.Item2; return pair.Item1; } @@ -189,24 +205,42 @@ public static string ExtractFirstWord(this XElement element) string word = null; - if (node.NodeType == XmlNodeType.Text) + if (node is XText text) { // extract first word from raw text in CData - var result = (node as XText).Value.ExtractFirstWord(); - word = result.Item1; - (node as XText).Value = result.Item2; + var pair = text.Value.SplitAtFirstWord(); + word = pair.Item1; + text.Value = pair.Item2; } - else + else if (node is XElement span) { - // extract first word from text in span - var result = (node as XElement).Value.ExtractFirstWord(); - word = result.Item1; - (node as XElement).Value = result.Item2; + if (styled) + { + // clone the retaining its style= attribute + var clone = span.Clone(); + // edit the span, removing its first word + var result = span.ExtractFirstWord(); + + // reset our clone to just the first word + clone.Value = result; + word = clone.ToString(SaveOptions.DisableFormatting); - if (result.Item2.Length == 0) + if (span.Value.Length == 0) + { + // resultant span is empty so remove it + span.Remove(); + } + } + else { - // left the span empty so remove it - node.Remove(); + // extract first word from text in span + word = span.ExtractFirstWord(); + + if (span.Value.Length == 0) + { + // left the span empty so remove it + span.Remove(); + } } } @@ -226,8 +260,9 @@ public static string ExtractFirstWord(this XElement element) /// Remove and return the last textual word from the element content /// /// The element to modify + /// Return the word preserving its styled span parent /// A string that can be appended to a CData's raw content - public static string ExtractLastWord(this XElement element) + public static string ExtractLastWord(this XElement element, bool styled = false) { if (element.FirstNode == null) { @@ -236,7 +271,7 @@ public static string ExtractLastWord(this XElement element) if (element.FirstNode.NodeType == XmlNodeType.Text) { - var pair = element.Value.ExtractLastWord(); + var pair = element.Value.SplitAtLastWord(); element.Value = pair.Item2; return pair.Item1; } @@ -272,24 +307,42 @@ public static string ExtractLastWord(this XElement element) string word = null; - if (node.NodeType == XmlNodeType.Text) + if (node is XText text) { // extract last word from raw text in CData - var result = (node as XText).Value.ExtractLastWord(); - word = result.Item1; - (node as XText).Value = result.Item2; + var pair = text.Value.SplitAtLastWord(); + word = pair.Item1; + text.Value = pair.Item2; } - else + else if (node is XElement span) { - // extract last word from text in span - var result = (node as XElement).Value.ExtractLastWord(); - word = result.Item1; - (node as XElement).Value = result.Item2; + if (styled) + { + // clone the retaining its style= attribute + var clone = span.Clone(); + // edit the span, removing its last word + var result = span.ExtractLastWord(); + + // reset our clone to just the last word + clone.Value = result; + word = clone.ToString(SaveOptions.DisableFormatting); - if (result.Item2.Length == 0) + if (span.Value.Length == 0) + { + // resultant span is empty so remove it + span.Remove(); + } + } + else { - // left the span empty so remove it - node.Remove(); + // extract last word from text in span + word = span.ExtractLastWord(); + + if (span.Value.Length == 0) + { + // left the span empty so remove it + span.Remove(); + } } } diff --git a/OneMore/Models/Page.cs b/OneMore/Models/Page.cs index a416511d6b..76d2e45c85 100644 --- a/OneMore/Models/Page.cs +++ b/OneMore/Models/Page.cs @@ -3,6 +3,7 @@ //************************************************************************************************ #pragma warning disable S1155 // "Any()" should be used to test for emptiness +#pragma warning disable S4136 // Method overloads should be grouped together namespace River.OneMoreAddIn.Models { @@ -37,8 +38,6 @@ internal partial class Page // Page meta to specify page tag list public static readonly string TaggingMetaName = "omTaggingLabels"; - private bool reverseScanning; - /// /// Initialize a new instance with the given page XML root @@ -73,6 +72,12 @@ public Page(XElement root) public string PageId { get; private set; } + /// + /// Used as a signal to GetSelectedText that editor scanning is in reverse + /// + public bool ReverseScanning { get; set; } + + /// /// Gets the root element of the page /// @@ -318,192 +323,204 @@ public bool ConfirmImageSelected(bool feedback = false) /// public bool EditSelected(Func edit) { - var updated = false; var cursor = GetTextCursor(); - if (cursor != null) { - // T elements can only be a child of an OE but can also have other T siblings... - // Each T will have one CDATA with one or more XText and SPAN XElements. - // OneNote handles nested spans by normalizing them into consecutive spans + return EditNode(cursor, edit); + } - // Just FYI, because we use XElement.Parse to build the DOM, XText nodes will be - // singular but may be surrounded by SPAN elements; i.e., there shouldn't be two - // consecutive XText nodes next to each other + return EditSelected(Root, edit); + } - // indicate to GetSelected() that we're scanning in reverse - reverseScanning = true; - // is there a preceding T? - if ((cursor.PreviousNode is XElement prev) && !prev.GetCData().EndsWithWhitespace()) - { - var cdata = prev.GetCData(); - var wrapper = cdata.GetWrapper(); - var nodes = wrapper.Nodes().ToList(); + public bool EditNode(XElement cursor, Func edit) + { + var updated = false; - // reverse, extracting text and stopping when matching word delimiter - for (var i = nodes.Count - 1; i >= 0; i--) - { - if (nodes[i] is XText text) - { - // ends with delimiter so can't be part of current word - if (text.Value.EndsWithWhitespace()) - break; + // T elements can only be a child of an OE but can also have other T siblings... + // Each T will have one CDATA with one or more XText and SPAN XElements. + // OneNote handles nested spans by normalizing them into consecutive spans - // extract last word and pump through the editor - var pair = text.Value.ExtractLastWord(); - if (pair.Item1 == null) - { - // entire content of this XText - edit(text); - } - else - { - // last word of this XText - text.Value = pair.Item2; - text.AddAfterSelf(edit(new XText(pair.Item1))); - } + // Just FYI, because we use XElement.Parse to build the DOM, XText nodes will be + // singular but may be surrounded by SPAN elements; i.e., there shouldn't be two + // consecutive XText nodes next to each other + + // indicate to GetSelectedText() that we're scanning in reverse + ReverseScanning = true; + + // is there a preceding T? + if ((cursor.PreviousNode is XElement prev) && !prev.GetCData().EndsWithWhitespace()) + { + var cdata = prev.GetCData(); + var wrapper = cdata.GetWrapper(); + var nodes = wrapper.Nodes().ToList(); + + // reverse, extracting text and stopping when matching word delimiter + for (var i = nodes.Count - 1; i >= 0; i--) + { + if (nodes[i] is XText text) + { + // ends with delimiter so can't be part of current word + if (text.Value.EndsWithWhitespace()) + break; - // remaining text has a word delimiter - if (text.Value.StartsWithWhitespace()) - break; + // extract last word and pump through the editor + var pair = text.Value.SplitAtLastWord(); + if (pair.Item1 == null) + { + // entire content of this XText + edit(text); } - else if (nodes[i] is XElement span) + else { - // ends with delimiter so can't be part of current word - if (span.Value.EndsWithWhitespace()) - break; + // last word of this XText + text.Value = pair.Item2; + text.AddAfterSelf(edit(new XText(pair.Item1))); + } - // extract last word and pump through editor - var word = span.ExtractLastWord(); - if (word == null) - { - // edit entire contents of SPAN - edit(span); - } - else - { - // last word of this SPAN - var spawn = new XElement(span.Name, span.Attributes(), word); - edit(spawn); - span.AddAfterSelf(spawn); - } + // remaining text has a word delimiter + if (text.Value.StartsWithWhitespace()) + break; + } + else if (nodes[i] is XElement span) + { + // ends with delimiter so can't be part of current word + if (span.Value.EndsWithWhitespace()) + break; - // remaining text has a word delimiter - if (span.Value.StartsWithWhitespace()) - break; + // extract last word and pump through editor + var word = span.ExtractLastWord(); + if (word == null) + { + // edit entire contents of SPAN + edit(span); + } + else + { + // last word of this SPAN + var spawn = new XElement(span.Name, span.Attributes(), word); + edit(spawn); + span.AddAfterSelf(spawn); } - } - // rebuild CDATA with edited content - cdata.Value = wrapper.GetInnerXml(); - updated = true; + // remaining text has a word delimiter + if (span.Value.StartsWithWhitespace()) + break; + } } - // indicate to GetSelected() that we're scanning forward - reverseScanning = false; + // rebuild CDATA with edited content + cdata.Value = wrapper.GetInnerXml(); + updated = true; + } - // is there a following T? - if ((cursor.NextNode is XElement next) && !next.GetCData().StartsWithWhitespace()) - { - var cdata = next.GetCData(); - var wrapper = cdata.GetWrapper(); - var nodes = wrapper.Nodes().ToList(); + // indicate to GetSelectedText() that we're scanning forward + ReverseScanning = false; - // extract text and stop when matching word delimiter - for (var i = 0; i < nodes.Count; i++) - { - if (nodes[i] is XText text) - { - // starts with delimiter so can't be part of current word - if (text.Value.StartsWithWhitespace()) - break; + // is there a following T? + if ((cursor.NextNode is XElement next) && !next.GetCData().StartsWithWhitespace()) + { + var cdata = next.GetCData(); + var wrapper = cdata.GetWrapper(); + var nodes = wrapper.Nodes().ToList(); - // extract first word and pump through editor - var pair = text.Value.ExtractFirstWord(); - if (pair.Item1 == null) - { - // entire content of this XText - edit(text); - } - else - { - // first word of this XText - text.Value = pair.Item2; - text.AddBeforeSelf(edit(new XText(pair.Item1))); - } + // extract text and stop when matching word delimiter + for (var i = 0; i < nodes.Count; i++) + { + if (nodes[i] is XText text) + { + // starts with delimiter so can't be part of current word + if (text.Value.StartsWithWhitespace()) + break; - // remaining text has a word delimiter - if (text.Value.EndsWithWhitespace()) - break; + // extract first word and pump through editor + var pair = text.Value.SplitAtFirstWord(); + if (pair.Item1 == null) + { + // entire content of this XText + edit(text); } - else if (nodes[i] is XElement span) + else { - // ends with delimiter so can't be part of current word - if (span.Value.StartsWithWhitespace()) - break; + // first word of this XText + text.Value = pair.Item2; + text.AddBeforeSelf(edit(new XText(pair.Item1))); + } - // extract first word and pump through editor - var word = span.ExtractFirstWord(); - if (word == null) - { - // eidt entire contents of SPAN - edit(span); - } - else - { - // first word of this SPAN - var spawn = new XElement(span.Name, span.Attributes(), word); - edit(spawn); - span.AddBeforeSelf(spawn); - } + // remaining text has a word delimiter + if (text.Value.EndsWithWhitespace()) + break; + } + else if (nodes[i] is XElement span) + { + // ends with delimiter so can't be part of current word + if (span.Value.StartsWithWhitespace()) + break; - // remaining text has a word delimiter - if (span.Value.EndsWithWhitespace()) - break; + // extract first word and pump through editor + var word = span.ExtractFirstWord(); + if (word == null) + { + // eidt entire contents of SPAN + edit(span); + } + else + { + // first word of this SPAN + var spawn = new XElement(span.Name, span.Attributes(), word); + edit(spawn); + span.AddBeforeSelf(spawn); } - } - // rebuild CDATA with edited content - cdata.Value = wrapper.GetInnerXml(); - updated = true; + // remaining text has a word delimiter + if (span.Value.EndsWithWhitespace()) + break; + } } + + // rebuild CDATA with edited content + cdata.Value = wrapper.GetInnerXml(); + updated = true; } - else - { - // detect all selected text (cdata within T runs) - var cdatas = Root.Descendants(Namespace + "T") - .Where(e => e.Attributes("selected").Any(a => a.Value == "all") - && e.FirstNode?.NodeType == XmlNodeType.CDATA) - .Select(e => e.FirstNode as XCData); - if (cdatas?.Any() == true) - { - foreach (var cdata in cdatas) - { - // edit every XText and SPAN in the T wrapper - var wrapper = cdata.GetWrapper(); + return updated; + } - // use ToList, otherwise enumeration will stop after first FeplaceWith - foreach (var node in wrapper.Nodes().ToList()) - { - node.ReplaceWith(edit(node)); - } - var text = wrapper.GetInnerXml(); + public bool EditSelected(XElement root, Func edit) + { + // detect all selected text (cdata within T runs) + var cdatas = root.Descendants(Namespace + "T") + .Where(e => e.Attributes("selected").Any(a => a.Value == "all") + && e.FirstNode?.NodeType == XmlNodeType.CDATA) + .Select(e => e.FirstNode as XCData); - // special case for
+ EOL - text = text.Replace("
", "
\n"); + if (cdatas?.Any() != true) + { + return false; + } - // build CDATA with editing content - cdata.Value = text; - } + foreach (var cdata in cdatas) + { + // edit every XText and SPAN in the T wrapper + var wrapper = cdata.GetWrapper(); - updated = true; + // use ToList, otherwise enumeration will stop after first ReplaceWith + foreach (var node in wrapper.Nodes().ToList()) + { + node.ReplaceWith(edit(node)); } + + var text = wrapper.GetInnerXml(); + + // special case for
+ EOL + text = text.Replace("
", "
\n"); + + // build CDATA with editing content + cdata.Value = text; } - return updated; + return true; } @@ -621,7 +638,7 @@ public IEnumerable GetSelectedElements(bool all = true) if (selected == null || selected.Count() == 0) { - SelectionScope = SelectionScope.Empty; + SelectionScope = SelectionScope.Unknown; return all ? Root.Elements(Namespace + "Outline").Descendants(Namespace + "T") @@ -676,14 +693,14 @@ public string GetSelectedText() { if (s is XText text) { - if (reverseScanning) + if (ReverseScanning) builder.Insert(0, text.Value); else builder.Append(text.Value); } else if (!(s is XComment)) { - if (reverseScanning) + if (ReverseScanning) builder.Insert(0, ((XElement)s).Value); else builder.Append(((XElement)s).Value); @@ -700,6 +717,10 @@ public string GetSelectedText() /// Gets the T element of a zero-width selection. Visually, this appears as the current /// cursor insertion point and can be used to infer the current word or phrase in text. ///
+ /// + /// If true then merge the runs around the empty cursor and return that merged element + /// otherwise return the empty cursor + /// /// /// The one:T XElement or null if there is a selected range greater than zero /// @@ -725,14 +746,20 @@ public XElement GetTextCursor(bool merge = false) Regex.IsMatch(cdata.Value, @"
") || Regex.IsMatch(cdata.Value, @"")) { + XElement merged = null; if (merge && cursor.GetCData().Value == string.Empty) { // only merge if empty [], note cursor could be a hyperlink .. - MergeRuns(cursor); + merged = MergeRuns(cursor); } SelectionScope = SelectionScope.Empty; + return merge ? merged : cursor; + } + + if (merge) + { return cursor; } } @@ -746,8 +773,10 @@ public XElement GetTextCursor(bool merge = false) // remove the empty CDATA[] cursor, combining the previous and next runs into one - private void MergeRuns(XElement cursor) + private XElement MergeRuns(XElement cursor) { + XElement merged = null; + if (cursor.PreviousNode is XElement prev) { if (cursor.NextNode is XElement next) @@ -756,10 +785,12 @@ private void MergeRuns(XElement cursor) var cnext = next.GetCData(); cprev.Value = $"{cprev.Value}{cnext.Value}"; next.Remove(); + merged = prev; } } cursor.Remove(); + return merged; } diff --git a/OneMore/Models/SelectionRange.cs b/OneMore/Models/SelectionRange.cs new file mode 100644 index 0000000000..7635162111 --- /dev/null +++ b/OneMore/Models/SelectionRange.cs @@ -0,0 +1,282 @@ +//************************************************************************************************ +// Copyright © 2021 Steven M Cohn. All rights reserved. +//************************************************************************************************ + +namespace River.OneMoreAddIn.Models +{ + using System.Collections.Generic; + using System.Linq; + using System.Text.RegularExpressions; + using System.Xml.Linq; + + + /// + /// Inspects and manages the selection ranges within an element + /// + /// + /// Typically instantiated with a page Outline or an OE on a page. Doesn't specifically + /// check the root element name so can be instantiated with any element with T descendants. + /// + internal class SelectionRange + { + private readonly XElement root; + private readonly XNamespace ns; + + + public SelectionRange(XElement element) + { + root = element; + ns = element.GetNamespaceOfPrefix(OneNote.Prefix); + } + + + public string ObjectId + { + get + { + root.GetAttributeValue("objectID", out var objectId); + return objectId; + } + } + + + /// + /// Get the root XElement of the outline element + /// + public XElement Root => root; + + + /// + /// Get the selection scope based on the results of GetSelection or MergeFromCursor + /// + public SelectionScope SelectionScope { get; private set; } + + + /// + /// Removes the selection run from a sequence of runs, merging content with the previous + /// and next sibling runs. + /// + /// + /// The merged run containing next, run, and previous content + /// + /// + /// The selection run may be either empty or contain content. An empty selection + /// represents the insertion text cursor with no range; content represents a selection + /// range of a word or phrase. + /// + /// The SelectionScope is set based on the range type: Empty, Run, Special, or Region + /// if there are multiple selections in context, e.g. the Root is higher than an OE. + /// + public XElement Deselect() + { + SelectionScope = SelectionScope.Unknown; + + var selections = GetSelections(); + var count = selections.Count(); + + if (count == 0) + { + return null; + } + + if (count > 1) + { + // empty cursor run should be the only selected run; + // this can only happen if the given root is not an OE + SelectionScope = SelectionScope.Region; + return null; + } + + var cursor = selections.First(); + if (!(cursor.FirstNode is XCData cdata)) + { + // shouldn't happen? + return null; + } + + // A zero length insertion cursor (CDATA[]) is easy to recognize. But OneNote doesn't + // provide enough information when the cursor is positioned on a partially or fully + // selected hyperlink or XML comment so we can't tell the difference between these + // three cases without looking at the CDATA value. Note that XML comments are used + // to wrap mathML equations. + + if (cdata.Value.Length == 0) + { + cursor = JoinCursorContext(cursor); + NormalizeRuns(); + SelectionScope = SelectionScope.Empty; + } + else if (Regex.IsMatch(cdata.Value, @"") || + Regex.IsMatch(cdata.Value, @"")) + { + SelectionScope = SelectionScope.Special; + } + else + { + // the entire current non-empty run is selected + NormalizeRuns(); + SelectionScope = SelectionScope.Run; + } + + root.DescendantsAndSelf().Attributes("selected").Remove(); + + return cursor; + } + + + // Remove an empty CDATA[] cursor or a selected=all T run, combining it with the previous + // and next runs into a single run + private XElement JoinCursorContext(XElement run) + { + var cdata = run.GetCData(); + + if (run.PreviousNode is XElement prev) + { + var word = prev.ExtractLastWord(true); + cdata.Value = $"{word}{cdata.Value}"; + + if (prev.GetCData().Value.Length == 0) + { + prev.Remove(); + } + } + + if (run.NextNode is XElement next) + { + var word = next.ExtractFirstWord(true); + cdata.Value = $"{cdata.Value}{word}"; + + if (next.GetCData().Value.Length == 0) + { + next.Remove(); + } + } + + return run; + } + + + // Merge consecutive runs with equal styling. OneNote does this after navigating away + // from a selection range within a T by combining similar Ts back into one + private void NormalizeRuns() + { + var runs = root.Elements(ns + "T").ToList(); + + for (int i = 0, j = 1; j < runs.Count; j++) + { + var si = new Style(runs[i].CollectStyleProperties()); + var sj = new Style(runs[j].CollectStyleProperties()); + + if (si.Equals(sj)) + { + var ci = runs[i].GetCData(); + ci.Value = $"{ci.Value}{runs[j].GetCData().Value}"; + runs[j].Remove(); + } + else + { + i = j; + } + } + } + + + /// + /// Gets the singly selected text run. + /// + /// + /// The one outline element or null if there are multiple runs selected or the selected + /// region is unknonwn. This also sets the SelectionScope property + /// + /// + /// If there is exactly one selected text run and its width is zero then this visually + /// appears as the current cursor insertion point and can be used to infer the current + /// word or phrase in context. + /// + /// If there is exactly one selected text run and its width is greater than zero then + /// this visually appears as a selected region within one paragraph (outline element) + /// + public XElement GetSelection() + { + SelectionScope = SelectionScope.Unknown; + + var selections = GetSelections(); + var count = selections.Count(); + + if (count == 0) + { + return null; + } + + if (count > 1) + { + SelectionScope = SelectionScope.Region; + return null; + } + + var cursor = selections.First(); + if (!(cursor.FirstNode is XCData cdata)) + { + // shouldn't happen? + return null; + } + + // empty or link or xml-comment because we can't tell the difference between + // a zero-selection zero-selection link and a partial or fully selected link. + // Note that XML comments are used to wrap mathML equations + if (cdata.Value.Length == 0) + { + SelectionScope = SelectionScope.Empty; + } + else if (Regex.IsMatch(cdata.Value, @"") || + Regex.IsMatch(cdata.Value, @"")) + { + SelectionScope = SelectionScope.Special; + } + else + { + // the entire current non-empty run is selected + SelectionScope = SelectionScope.Run; + } + + return cursor; + } + + + /// + /// Return a collection of all selected text runs + /// + /// An IEnumerable of XElements + /// + /// Sets SelectionScope by making a basic assumption that if all selectioned runs are + /// under the same parent then it must be a Run, otherwise it must be a Region. + /// + public IEnumerable GetSelections() + { + var selections = root.Descendants(ns + "T") + .Where(e => e.Attribute("selected")?.Value == "all"); + + if (selections.Any()) + { + var count = selections.GroupBy(e => e.Parent).Count(); + SelectionScope = count == 1 ? SelectionScope.Run : SelectionScope.Region; + } + else + { + SelectionScope = SelectionScope.Unknown; + } + + return selections; + } + + + /// + /// Return a string representation of the XML of the outline element + /// + /// An XML string + public override string ToString() + { + return root.ToString(SaveOptions.DisableFormatting); + } + } +} diff --git a/OneMore/Models/SelectionScope.cs b/OneMore/Models/SelectionScope.cs index 11f7c91345..7b22044fa0 100644 --- a/OneMore/Models/SelectionScope.cs +++ b/OneMore/Models/SelectionScope.cs @@ -4,10 +4,35 @@ namespace River.OneMoreAddIn { + + /// + /// Describes the selection state on the page or within an outline element + /// internal enum SelectionScope { + /// + /// Can't decipher scope + /// Unknown, + + /// + /// Insertion cursor, zero-width selection + /// Empty, - Region + + /// + /// A region with more than one run is selected + /// + Region, + + /// + /// Exactly one non-empty run is selected + /// + Run, + + /// + /// CDATA contains an anchor link or an XML comment + /// + Special } } diff --git a/OneMore/OneMore.csproj b/OneMore/OneMore.csproj index 4260e32246..16e956bcbc 100644 --- a/OneMore/OneMore.csproj +++ b/OneMore/OneMore.csproj @@ -150,6 +150,7 @@ ImportWebDialog.cs + Form @@ -280,6 +281,7 @@ + Component diff --git a/OneMore/Properties/AssemblyInfo.cs b/OneMore/Properties/AssemblyInfo.cs index d6bf9438e2..6ebd5ba6f3 100644 --- a/OneMore/Properties/AssemblyInfo.cs +++ b/OneMore/Properties/AssemblyInfo.cs @@ -36,7 +36,7 @@ internal static class AssemblyInfo * NOTE - also update the version in the Setup project * by clicking on the Setup project node in VS and update its properties */ - public const string Version = "3.27"; + public const string Version = "3.28"; public const string Product = "OneMore"; diff --git a/OneMore/Properties/Resources.Designer.cs b/OneMore/Properties/Resources.Designer.cs index ef1dafbab3..b0e3f640bf 100644 --- a/OneMore/Properties/Resources.Designer.cs +++ b/OneMore/Properties/Resources.Designer.cs @@ -323,6 +323,69 @@ internal static System.Drawing.Bitmap Automobile { } } + /// + /// Looks up a localized string similar to Could not mark anchor point. Select a word or phrase from one paragraph. See log file for details.. + /// + internal static string BiLinkCommand_BadAnchor { + get { + return ResourceManager.GetString("BiLinkCommand_BadAnchor", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not create bi-directional link. {0}. + /// + internal static string BiLinkCommand_BadTarget { + get { + return ResourceManager.GetString("BiLinkCommand_BadTarget", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cannot link a phrase to itself. + /// + internal static string BiLinkCommand_Circular { + get { + return ResourceManager.GetString("BiLinkCommand_Circular", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Anchor cannot be found. Page or text has changed.. + /// + internal static string BiLinkCommand_LostAnchor { + get { + return ResourceManager.GetString("BiLinkCommand_LostAnchor", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Marked "{0}" as the anchor. Now select the target text and finish the bi-directional link. + /// + internal static string BiLinkCommand_Marked { + get { + return ResourceManager.GetString("BiLinkCommand_Marked", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Starting anchor not yet marked. Select the start of the bi-directional link.. + /// + internal static string BiLinkCommand_NoAnchor { + get { + return ResourceManager.GetString("BiLinkCommand_NoAnchor", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Select a word or phrase from one paragraph. + /// + internal static string BiLinkCommand_NoTarget { + get { + return ResourceManager.GetString("BiLinkCommand_NoTarget", resourceCulture); + } + } + /// /// Looks up a localized resource of type System.Drawing.Bitmap. /// @@ -4321,6 +4384,15 @@ internal static string ribFavoritesMenu_Label { } } + /// + /// Looks up a localized string similar to Finish Bidirectional Link. + /// + internal static string ribFinishBiLinkButton_Label { + get { + return ResourceManager.GetString("ribFinishBiLinkButton_Label", resourceCulture); + } + } + /// /// Looks up a localized string similar to Add Icon to Page Title. /// @@ -5131,6 +5203,15 @@ internal static string ribSplitTableButton_Label { } } + /// + /// Looks up a localized string similar to Start Bidirectional Link. + /// + internal static string ribStartBiLinkButton_Label { + get { + return ResourceManager.GetString("ribStartBiLinkButton_Label", resourceCulture); + } + } + /// /// Looks up a localized string similar to Strikethrough Completed To Do Tags. /// diff --git a/OneMore/Properties/Resources.ar-SA.resx b/OneMore/Properties/Resources.ar-SA.resx index c4db7101d7..2c198f3360 100644 --- a/OneMore/Properties/Resources.ar-SA.resx +++ b/OneMore/Properties/Resources.ar-SA.resx @@ -3147,4 +3147,40 @@ ISO-code then comma then language name حدد خلية جدول حيث يجب أن تبدأ الخلايا الملصقة في التراكب message box + + لا يمكن وضع علامة على نقطة الارتساء. + message box + + + تعذر إنشاء ارتباط ثنائي الاتجاه. + message box + + + تم وضع علامة "{0}" على أنها نقطة الارتساء. + message box EDIT + + + مرساة البدء لم يتم وضع علامة عليها بعد. + message box + + + لا يمكن ربط عبارة بنفسها + message box + + + لا يمكن العثور على المرساة. + message box + + + حدد كلمة أو عبارة من فقرة واحدة + message box + + + قم بإنهاء الارتباط ثنائي الاتجاه + ribbon item + + + ابدأ رابط ثنائي الاتجاه + ribbon item + \ No newline at end of file diff --git a/OneMore/Properties/Resources.de-DE.resx b/OneMore/Properties/Resources.de-DE.resx index fe544d5cc4..3cbca0c467 100644 --- a/OneMore/Properties/Resources.de-DE.resx +++ b/OneMore/Properties/Resources.de-DE.resx @@ -3133,4 +3133,40 @@ ISO-code then comma then language name Wählen Sie eine Tabellenzelle aus, in der eingefügte Zellen überlagert werden sollen message box + + Ankerpunkt konnte nicht markiert werden. + message box + + + Bidirektionaler Link konnte nicht erstellt werden. + message box + + + Als Anker markiert "{0}". + message box EDIT + + + Startanker noch nicht markiert. + message box + + + Eine Phrase kann nicht mit sich selbst verknüpft werden + message box + + + Anker kann nicht gefunden werden. + message box + + + Wählen Sie ein Wort oder einen Satz aus einem Absatz + message box + + + Bidirektionale Verbindung beenden + ribbon item + + + Bidirektionale Verbindung starten + ribbon item + \ No newline at end of file diff --git a/OneMore/Properties/Resources.es-ES.resx b/OneMore/Properties/Resources.es-ES.resx index fc08d0b697..6eaf65c2d5 100644 --- a/OneMore/Properties/Resources.es-ES.resx +++ b/OneMore/Properties/Resources.es-ES.resx @@ -3147,4 +3147,40 @@ ISO-code then comma then language name Seleccione una celda de la tabla donde las celdas pegadas deberían comenzar a superponerse message box + + No se pudo marcar el punto de anclaje. + message box + + + No se pudo crear el enlace bidireccional. + message box + + + Marcado "{0}" como ancla. + message box EDIT + + + Ancla de salida aún no marcada. + message box + + + No se puede vincular una frase a sí misma + message box + + + No se puede encontrar el ancla. + message box + + + Seleccione una palabra o frase de un párrafo + message box + + + Finalizar enlace bidireccional + ribbon item + + + Iniciar enlace bidireccional + ribbon item + \ No newline at end of file diff --git a/OneMore/Properties/Resources.fr-FR.resx b/OneMore/Properties/Resources.fr-FR.resx index a3d4ce94a8..e4d25e8520 100644 --- a/OneMore/Properties/Resources.fr-FR.resx +++ b/OneMore/Properties/Resources.fr-FR.resx @@ -3142,4 +3142,40 @@ ISO-code then comma then language name Sélectionnez une cellule de tableau où les cellules collées doivent commencer à se superposer message box + + Impossible de marquer le point d'ancrage. + message box + + + Impossible de créer un lien bidirectionnel. + message box + + + Marqué "{0}" comme ancre. + message box EDIT + + + Ancre de départ pas encore marquée. + message box + + + Impossible de lier une phrase à elle-même + message box + + + L'ancre est introuvable. + message box + + + Sélectionnez un mot ou une phrase dans un paragraphe + message box + + + Terminer le lien bidirectionnel + ribbon item + + + Démarrer le lien bidirectionnel + ribbon item + \ No newline at end of file diff --git a/OneMore/Properties/Resources.nl-NL.resx b/OneMore/Properties/Resources.nl-NL.resx index 0ae8c6a3a4..cb610a1a9c 100644 --- a/OneMore/Properties/Resources.nl-NL.resx +++ b/OneMore/Properties/Resources.nl-NL.resx @@ -3148,4 +3148,40 @@ ISO-code then comma then language name Selecteer een tabelcel waar geplakte cellen moeten beginnen over elkaar heen te leggen message box + + Kan ankerpunt niet markeren. + message box + + + Kan geen bidirectionele link maken. + message box + + + Gemarkeerd met "{0}" als het anker. + message box EDIT + + + Startanker nog niet gemarkeerd. + message box + + + Kan een zin niet aan zichzelf koppelen + message box + + + Anker kan niet worden gevonden. + message box + + + Selecteer een woord of zin uit één alinea + message box + + + Bidirectionele link voltooien + ribbon item + + + Bidirectionele koppeling starten + ribbon item + \ No newline at end of file diff --git a/OneMore/Properties/Resources.pt-BR.resx b/OneMore/Properties/Resources.pt-BR.resx index 95a54007e7..d02deec76e 100644 --- a/OneMore/Properties/Resources.pt-BR.resx +++ b/OneMore/Properties/Resources.pt-BR.resx @@ -3148,4 +3148,40 @@ ISO-code then comma then language name Selecione uma célula da tabela onde as células coladas devem começar a se sobrepor message box + + Não foi possível marcar o ponto de ancoragem. + message box + + + Não foi possível criar um link bidirecional. + message box + + + Marcado "{0}" como âncora. + message box EDIT + + + Âncora inicial ainda não marcada. + message box + + + Não é possível vincular uma frase a ela mesma + message box + + + A âncora não pode ser encontrada. + message box + + + Selecione uma palavra ou frase de um parágrafo + message box + + + Concluir link bidirecional + ribbon item + + + Iniciar link bidirecional + ribbon item + \ No newline at end of file diff --git a/OneMore/Properties/Resources.resx b/OneMore/Properties/Resources.resx index a87f9c17cb..792a46779b 100644 --- a/OneMore/Properties/Resources.resx +++ b/OneMore/Properties/Resources.resx @@ -3162,4 +3162,40 @@ In this section Select a table cell where pasted cells should start overlaying message box + + Could not mark anchor point. Select a word or phrase from one paragraph. See log file for details. + message box + + + Could not create bi-directional link. {0} + message box + + + Marked "{0}" as the anchor. Now select the target text and finish the bi-directional link + message box + + + Starting anchor not yet marked. Select the start of the bi-directional link. + message box + + + Cannot link a phrase to itself + message box + + + Anchor cannot be found. Page or text has changed. + message box + + + Select a word or phrase from one paragraph + message box + + + Finish Bidirectional Link + ribbon item + + + Start Bidirectional Link + ribbon item + \ No newline at end of file diff --git a/OneMore/Properties/Resources.zh-CN.resx b/OneMore/Properties/Resources.zh-CN.resx index f76d26eaae..78d3c84c8a 100644 --- a/OneMore/Properties/Resources.zh-CN.resx +++ b/OneMore/Properties/Resources.zh-CN.resx @@ -3139,4 +3139,40 @@ ISO-code then comma then language name 选择一个表格单元格,粘贴的单元格应该开始覆盖 message box + + 无法标记锚点。 + message box + + + 无法创建双向链接。 + message box + + + 将“{0}”标记为锚点。 + message box EDIT + + + 起始锚点尚未标记。 + message box + + + 无法将短语链接到自身 + message box + + + 找不到锚点。 + message box + + + 从一个段落中选择一个单词或短语 + message box + + + 完成双向链接 + ribbon item + + + 启动双向链接 + ribbon item + \ No newline at end of file diff --git a/OneMore/Properties/Ribbon.xml b/OneMore/Properties/Ribbon.xml index c5a6feb99d..debf6ed26c 100644 --- a/OneMore/Properties/Ribbon.xml +++ b/OneMore/Properties/Ribbon.xml @@ -268,6 +268,18 @@ getScreentip="GetRibbonScreentip" getEnabled="GetBodyContext" onAction="RefreshFootnotesCmd"/> +