Intertexti - 复活 HyperCard





5.00/5 (8投票s)
一个用于文件、URL交叉引用和索引的原型应用程序。
目录
- 引言
- 初始功能
- 导航概念可视化
- 初始用户界面
- Notecards 作为浏览器控件
- 标签式浏览
- 持久性 - 模型
- 链接 Notecards
- 利用现有资源
- 编辑 Notecard 内容
- 结论
- 附录 A - 定义和加载 Schema"
- 附录 B - 模型持久性方法"
- 附录 C:Notecard 记录实现"
- 附录 D:捕获应用程序范围的鼠标事件
- 参考文献
重要提示:运行应用程序...
从此处下载 WebKit.NET 二进制文件并将其复制到 bin\x86\Debug 文件夹中!
引言
Intertexti 是我正在实施的一项举措,旨在实现类似于 Apple HyperCard 应用程序的功能。 这个名字来源于拉丁语“intertwined”(相互交织),因为系统中的记事卡可以以任何方式相互链接。
您将遇到什么
在此应用程序中,我利用了以下第三方组件
以及- Linq 扩展联接
- 模型-视图-控制器 (MVC) 架构
- 声明式编程实践
我试图实现什么?
我试图解决的问题是:在我所做的几乎所有事情中,无论是工作还是个人项目,我都会发现大量有用的信息分散在各个网页上。 我需要以比仅仅书签更好的方式组织这些页面——我希望能够添加标签,从这些标签创建索引,查看我想将哪些页面与其他页面关联(很多时候页面本身不会直接链接到),等等。
此外,我希望能够关联我自己的笔记、待办事项列表、评论等,并引用相关页面。 再次强调,这种记笔记和交叉引用不是浏览器支持的功能。 此外,特别是关于我自己的笔记,我希望能够以关系型的方式组织它们,这通常可以以目录的形式直观地表达出来——它显示了我自己的笔记的结构和组织,并提供了指向来源、进一步阅读等的链接。
对我来说,Word 文档过于线性——它是一维的,就像垂直滚动条一样。 第二维,即文档的交叉引用,是 Intertexti 至少在原型形式上所实现的。
我的设计目标
像往常一样,目标是设计出灵活、几乎立即可用且代码量最少的东西。 您会注意到我大量依赖于我使用 XML 进行所有声明式操作的标准操作实践。 您还会注意到,由于 MVC 架构,方法非常小——有些只有一两行。 最后,底层应用程序组织应该支持可扩展性——例如,如果您不想使用 WebKit 作为浏览器引擎,您应该能够以最少的麻烦将其替换为其他东西。
关于本文
我喜欢边编码边写文章,所以您在这里会遇到的是一个开发过程的日志。 最终的源代码与这里展示的代码略有不同——例如,NotecardRecord 类的最终版本使用工厂模式而不是公开的构造函数。 但其思想是让读者了解应用程序是如何开发的以及我遇到的问题(例如处理 WebKit 浏览器上的右键单击鼠标事件)。
初始功能
我最初的功能并不太雄心勃勃
- 使用魏芬罗的 Dock Panel Suite 实现可停靠窗口。
- 用于目录的侧面板
- 用于索引的侧面板
- 一个侧面板,用于链接到其他记事卡——“参考文献”。 我不断遇到的一个可用性问题是链接通常是嵌入的——文本的某些部分、图像上的区域等。 对于文本,这意味着必须扫描文本以查找链接。 我想要的是将所有可能的链接显示在一个单独的部分中,并附带描述链接的注释。
- 一个侧面板,用于链接到此记事卡的记事卡——“被引用”。 通常,我想了解更广泛的主题是什么。
- 一个基于 HTML 的记事卡,允许任何 HTML 内容和未来可能的脚本。
正如上面对引用的描述所推测的那样,我将引用视为单向的,本质上是向下钻取(或至少是横向的),而“被引用”的链接则是弹出树。
导航概念可视化
有四种导航形式,但这些对用户来说应该是直观的
- 目录:每个记事卡都有一个标题,目录由标题信息生成,子部分由记事卡上指定为“子部分”或类似内容的引用确定。 如果引用指向目录中已有的记事卡,则忽略它。
- 索引:这列出了使用特定文本标签的所有记事卡。
- 正向引用:并非所有引用都需要进入目录,但指向另一个记事卡的链接仍然可能有用。 这些(除了目录引用)显示在引用导航中。
- 反向引用:这显示了引用当前记事卡的记事卡列表。
例如,给定 5 个记事卡
四个可导航的组件是
初始用户界面
UI 脚手架
上述组件可以快速组合成一个脚手架——没有内容,只有视图的布局
命令式代码
使这个脚手架运行起来大约需要 200 行代码,这得益于魏芬罗的 DockPanelSuite 和我最近关于 内容与容器解耦 的文章。 使用了一个基本的 MVC 模型。
Program.cs
这里我们只是实例化了应用程序的主窗体
static class Program { [STAThread] public static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Form form = Instantiate<Form>("mainform.xml", null); Application.Run(form); } public static T Instantiate<T>(string filename, Action<MycroParser> AddInstances) { MycroParser mp = new MycroParser(); if (AddInstances != null) { AddInstances(mp); } XmlDocument doc = new XmlDocument(); doc.Load(filename); mp.Load(doc, "Form", null); T obj = (T)mp.Process(); return obj; } }
ApplicationFormView.cs
应用程序的视图只是 DockPanel 实例的占位符,因为控制器会引用它来响应菜单事件
public class ApplicationFormView : Form { public DockPanel DockPanel { get; protected set; } public ApplicationFormView() { } }
ApplicationFormController.cs
控制器处理所有应用程序主窗体事件。 大部分是使用 DockPanelSuite 的样板代码。 这里唯一值得注意的是,控制器派生自抽象类 ViewController,它要求定义控制器关联的具体视图类型。 这使得控制器更容易处理特定的视图属性——我们可以避免否则所需的所有类型转换。
public class ApplicationFormController : ViewController<ApplicationFormView> { public ApplicationFormController() { } protected void Exit(object sender, EventArgs args) { View.Close(); } protected void Closing(object sender, CancelEventArgs args) { SaveLayout(); } protected void RestoreLayout(object sender, EventArgs args) { CloseAllDockContent(); LoadTheLayout("defaultLayout.xml"); } protected void LoadLayout(object sender, EventArgs args) { if (File.Exists("layout.xml")) { LoadTheLayout("layout.xml"); } else { RestoreLayout(sender, args); } } protected void LoadTheLayout(string layoutFilename) { View.DockPanel.LoadFromXml(layoutFilename, ((string persistString)=> { string typeName = persistString.LeftOf(',').Trim(); string contentMetadata = persistString.RightOf(',').Trim(); IDockContent container = InstantiateContainer(typeName, contentMetadata); InstantiateContent(container, contentMetadata); return container; })); } protected void SaveLayout() { View.DockPanel.SaveAsXml("layout.xml"); } protected IDockContent InstantiateContainer(string typeName, string metadata) { IDockContent container = null; if (typeName == typeof(GenericPane).ToString()) { container = new GenericPane(metadata); } else if (typeName == typeof(GenericDocument).ToString()) { container = new GenericDocument(metadata); } return container; } protected void InstantiateContent(object container, string filename) { Program.Instantiate<object>(filename, ((MycroParser mp) => { mp.AddInstance("Container", container); })); } protected void NewDocument(string filename) { GenericDocument doc = new GenericDocument(filename); InstantiateContent(doc, filename); doc.Show(View.DockPanel); } protected void NewPane(string filename) { GenericPane pane = new GenericPane(filename); InstantiateContent(pane, filename); pane.Show(View.DockPanel); } protected void CloseAllDockContent() { if (View.DockPanel.DocumentStyle == DocumentStyle.SystemMdi) { foreach (Form form in View.MdiChildren) { form.Close(); } } else { for (int index = View.DockPanel.Contents.Count - 1; index >= 0; index--) { if (View.DockPanel.Contents[index] is IDockContent) { IDockContent content = (IDockContent)View.DockPanel.Contents[index]; content.DockHandler.Close(); } } } } }
ViewController.cs
与视图关联的所有控制器都派生自 ViewController,它只是提供底层视图的类型化实例,可在派生控制器类中访问
public abstract class ViewController<T> : ISupportInitialize { public T View { get; set; } public ViewController() { } public virtual void BeginInit() { } public virtual void EndInit() { } }
我们稍后会看到 EndInit() 虚方法的使用,但现在,请记住,实例化引擎在对象完全实例化后会调用此方法,我们可以利用它在控制器中进行一些特定于应用程序的初始化。
声明式代码
布局和初始设置的定义由声明性代码处理,使用 MycroXaml(经过一些修改)进行实例化。
mainform.xml
这定义了应用程序的布局。 请注意,此处是如何引入各种程序集,以及视图和控制器是如何实例化的,最后完成属性和事件的连接。
<MycroXaml Name="Form" xmlns:wf="System.Windows.Forms, System.Windows.Forms, Version=1.0.5000.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" xmlns:ixc="Intertexti.Controllers, Intertexti" xmlns:ixv="Intertexti.Views, Intertexti" xmlns:wfui="WeifenLuo.WinFormsUI.Docking, WeifenLuo.WinFormsUI.Docking" xmlns:def="def" xmlns:ref="ref"> <ixv:ApplicationFormView def:Name="applicationFormView" Text="Intertexti" Size="800, 600" IsMdiContainer="true"> <ixc:ApplicationFormController def:Name="controller" View="{applicationFormView}"/> <ixv:Controls> <wfui:DockPanel def:Name="dockPanel" Dock="Fill"/> <wf:MenuStrip> <wf:Items> <wf:ToolStripMenuItem Text="&File"> <wf:DropDownItems> <wf:ToolStripMenuItem Text="E&xit" Click="{controller.Exit}"/> </wf:DropDownItems> </wf:ToolStripMenuItem> <wf:ToolStripMenuItem Text="&Window"> <wf:DropDownItems> <wf:ToolStripMenuItem Text="Restore &Layout" Click="{controller.RestoreLayout}"/> </wf:DropDownItems> </wf:ToolStripMenuItem> </wf:Items> </wf:MenuStrip> </ixv:Controls> <!-- Forward references --> <!-- Form events requiring the controller must be wired after the controller and form have been instantiated. --> <ixv:ApplicationFormView ref:Name="applicationFormView" DockPanel="{dockPanel}" Load="{controller.LoadLayout}" Closing="{controller.Closing}"/> </ixv:ApplicationFormView> </MycroXaml>
四个窗格
中定义了四个初始窗格
- indexPane.xml
- linksToPane.xml
- referencedByPane.xml
- tableOfContentsPane.xml
它们都非常相似,所以我只展示其中一个的标记,即 indexPane.xml
<?xml version="1.0" encoding="utf-8" ?> <MycroXaml Name="Form" xmlns:wf="System.Windows.Forms, System.Windows.Forms, Version=1.0.5000.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" xmlns:ix="Intertexti, Intertexti" xmlns:ref="ref"> <ix:GenericPane ref:Name="Container" TabText="Index" ClientSize="400, 190" BackColor="White" ShowHint="DockLeft"> <ix:Controls> <wf:TreeView Dock="Fill"> <wf:Nodes> <wf:TreeNode Text="Index"> </wf:TreeNode> </wf:Nodes> </wf:TreeView> </ix:Controls> </ix:GenericPane> </MycroXaml>
目前,窗格之间唯一的区别是文本和 ShowHint 属性值的赋值。
Notecards 作为浏览器控件
利用网络浏览器技术作为渲染记事卡的方式似乎是最合理的。 这也为未来嵌入脚本或其他控制逻辑以创建更复杂和动态的记事卡提供了可能性。 我没有使用 .NET 中嵌入的浏览器,而是决定使用 WebKit,特别是 WebKit.NET 实现。 但是,您应该知道这个实现看起来并没有得到积极支持(上次更新是 2010 年 8 月),它是 WebKit 的包装器, 读者应该考虑改用 open-webkit-sharp。 目前,停滞的 WebKit.NET 项目对于本文来说已经足够,原因有两个:我正在使用 VS2008,而 open-webkit-sharp 的二进制文件是用 VS2010/12 构建的,而且我无法让一个简单的浏览器应用程序工作,我不想现在花时间纠缠它。
命令式代码
命令式代码只是获取一些基本功能的存根。
NotecardController.cs
没有实现
public class NotecardController :ViewController<NotecardView> { }
NotecardView.cs
这里唯一的实现是将一些东西加载到浏览器窗口中
public class NotecardView : UserControl { protected WebKitBrowser browser; public WebKitBrowser Browser { get { return browser; } set { browser = value; browser.Navigate("https://codeproject.org.cn"); } } public NotecardView() { } }
ApplicationFormController.cs
已添加一个方法来处理添加新记事卡的菜单事件
protected void NewNotecard(object sender, EventArgs args) { NewDocument("notecard.xml"); }
声明式代码
上面截图中显示的记事卡由 notecard.xml 实例化。
notecard.xml
<?xml version="1.0" encoding="utf-8" ?> <MycroXaml Name="Form" xmlns:ixc="Intertexti.Controllers, Intertexti" xmlns:ixv="Intertexti.Views, Intertexti" xmlns:ix="Intertexti, Intertexti" xmlns:wk="WebKit, WebKitBrowser" xmlns:def="def" xmlns:ref="ref"> <ix:GenericDocument ref:Name="Container" Text="Notecard"> <ix:Controls> <ixv:NotecardView def:Name="notecardView" Dock="Fill"> <ixv:Controls> <wk:WebKitBrowser def:Name="browser" Dock="Fill"/> </ixv:Controls> </ixv:NotecardView> </ix:Controls> <ixv:NotecardView ref:Name="notecardView" Browser="{browser}"/> <ixc:NotecardController def:Name="notecardController" View="{notecardView}"/> </ix:GenericDocument> </MycroXaml>
mainform.xml
添加了一个新菜单项
<wf:ToolStripMenuItem Text="&Notecard"> <wf:DropDownItems> <wf:ToolStripMenuItem Text="&New" Click="{controller.NewNotecard}"/> </wf:DropDownItems> </wf:ToolStripMenuItem>
标签式浏览
我想要组织的很多东西实际上是 URL,所以我会坚持导航和链接 URL 的基本概念。 其中一些最终会有些敷衍,因为我将实际编辑记事卡推迟到本文的后面,但我们可以通过处理 URL 来获得关于链接的整个应用程序行为。 是的,这最终会创建一个带有组织和关联网页功能的标签式浏览器应用程序。 有趣,不是吗?
我们首先需要能够将四件事与记事卡关联起来
- 所需的 URL
- 目录标签
- 关键字,将用于生成索引信息
- 链接,允许我们描述一个记事卡与另一个记事卡相关联
UI 更改
上述前三项(URL、TOC 和关键字)是每个记事卡的标准组成部分。 问题是,这些信息应该与每个记事卡关联,还是位于特定于所选记事卡上下文的位置? 我选择了第二种方案,因为这允许我们消除一些在简单导航数据时不需要查看的杂乱信息。 理想情况下,这应该是一个另一个 DockPanel 窗格,让用户可以灵活地将其移动到他们想要的位置
<?xml version="1.0" encoding="utf-8" ?> <MycroXaml Name="Form" xmlns:ix="Intertexti, Intertexti" xmlns:ixctrl="Intertexti.Controls, Intertexti" xmlns:ixc="Intertexti.Controllers, Intertexti" xmlns:def="def" xmlns:ref="ref"> <ix:GenericPane ref:Name="Container" TabText="Notecard Info" ClientSize="400, 190" BackColor="White" ShowHint="DockTop"> <ixc:MetadataController def:Name="controller" AppController="{ApplicationFormController}"/> <ix:Controls> <ixctrl:LabeledTextBox LabelText="URL:" Location="5, 5" TextDataChanged="controller.NavigateToURL"/> <ixctrl:LabeledTextBox LabelText="TOC:" Location="5, 30"/> <ixctrl:LabeledTextBox LabelText="Tags:" Location="5, 55"/> </ix:Controls> </ix:GenericPane> </MycroXaml>
跟踪活动 Notecard
在记事卡 XML 中,我添加了这一行
<ixa:RegisterDocumentController App="{ApplicationFormController}" Container="{Container}" Controller="{controller}"/>
请注意标记元素 RegisterDocumentController。 这是我的一项严重的“作弊”,它通过实例的实例化和后初始化提供了添加“动作”的功能。 下图说明了我们为什么需要它
问题是,我们如何让 MetadataController 告诉 NotecardController 导航到特定的 URL? 此外,可以同时显示多个记事卡,因此我们需要一种方法来跟踪活动记事卡。 DockPanelSuite 提供了一个用于跟踪活动记事卡的事件,我们将其连接到 mainForm 标记中,因为这是 DockPanel 提供的事件
<wfui:DockPanel def:Name="dockPanel" Dock="Fill" ActiveDocumentChanged="{controller.ActiveDocumentChanged}"/>
在 ApplicationFormController 中实现为
public IDocumentController ActiveDocumentController { get; protected set; } protected void ActiveDocumentChanged(object sender, EventArgs args) { DockPanel dockPanel = (DockPanel)sender; IDockContent content = dockPanel.ActiveDocument; ActiveDocumentController = documentControllerMap[content]; }
但是,我们仍然需要 *注册* 与停靠内容关联的控制器,这就是“动作”RegisterDocumentController 所做的
public class RegisterDocumentController : DeclarativeAction { public ApplicationFormController App { get; protected set; } public IDockContent Container { get; protected set; } public IDocumentController Controller { get; protected set; } public override void EndInit() { App.RegisterDocumentController(Container, Controller); } }
在应用程序控制器中
public void RegisterDocumentController(IDockContent content, IDocumentController controller) { documentControllerMap[content] = controller; }
现在,元数据控制器可以从应用程序控制器获取活动的记事卡控制器——涉及三个控制器!
public class MetadataController { public ApplicationFormController AppController { get; set; } public void NavigateToURL(string url) { ((INotecardController)AppController.ActiveDocumentController).NavigateToURL(url); } }
由于我们只有一种文档,实现为 INotecardController,因此我们可以安全地转换控制器。 上图现在看起来像这样
当然,我们还需要能够从内容-控制器映射中删除条目。 这是通过连接 DockPanel 事件来完成的
<wfui:DockPanel def:Name="dockPanel" Dock="Fill" ActiveDocumentChanged="{controller.ActiveDocumentChanged}" ContentRemoved="{controller.ContentRemoved}"/>
并在应用程序控制器中提供实现
protected void ContentRemoved(object sender, DockContentEventArgs e) { documentControllerMap.Remove(e.Content); }
最后还有一个小细节——通过捕获浏览器控件的 DocumentTitledChanged 事件
<wk:WebKitBrowser def:Name="browser" Dock="Fill" DocumentTitleChanged="{controller.DocumentTitleChanged}"/>
我们可以设置标签的标题
protected void DocumentTitleChanged(object sender, EventArgs args) { ((GenericDocument)View.Parent).Text = View.Browser.DocumentTitle; }
我们现在有了一个不持久的多标签浏览器
持久性 - 模型
我们应该创建并连接模型。 正如您可能知道的,我不是一个模型驱动的开发人员——模型往往在 UI 开发过程中发生很多变化,所以我喜欢在构建模型之前先确定一些 UI 方面。 不过,这种方法在只有粗略纸面设计的情况下即时实现时效果很好。 如果我正在开发一个完全有故事板的应用程序,那么是的,模型可能是一个很好的起点,但仍然,它的即时满足感较差。
在另一种激进的方法中,我不会用第三方数据库实现持久性;.NET DataSet 完全足以胜任手头的工作——可持久化且关系型。 此外,请记住 UI 的布局(本身就是一个模型)完全由 DockPanelSuite 处理,所以我们不需要担心这一点。
我们目前需要的模型看起来像这样
有关模式如何声明和实例化的信息,请参见附录 A。
有关模型持久性方法,请参见附录 B。
有关 NotecardRecord 的实现,请参见附录 C。
附录 A-C 只是样板模式和数据管理代码。 更有趣的模型代码在应用程序特定功能中。 除了维护 DataSet 实例外,模型还提供了一个属性,允许控制器获取或设置活动的记事卡记录
public NotecardRecord ActiveNotecardRecord { get; set; }
活动记录在新记事卡创建时初始化
public NotecardRecord NewNotecard() { DataRow row = NewRow("Notecards"); ActiveNotecardRecord = new NotecardRecord(row); return ActiveNotecardRecord; } protected DataRow NewRow(string tableName) { DataRow row = dataSet.Tables["Notecards"].NewRow(); dataSet.Tables["Notecards"].Rows.Add(row); return row; }
这发生在应用程序的控制器中,当发出新的记事卡请求(来自菜单)时
protected void NewNotecard(object sender, EventArgs args) { NewDocument("notecard.xml"); NotecardRecord notecard = ApplicationModel.NewNotecard(); notecard.IsOpen = true; ((NotecardController)ActiveDocumentController).SetNotecardRecord(notecard); }
此外,每个控制器都维护与其关联的记事卡记录实例。 因此,当选择记事卡时,控制器可以更新元数据面板控件以及活动记录
public void IsActive() { // We may not have a record associated with the document! // This happens because DockPanelSuite opens documents as persisted in the layout. // TODO: Fix this, so documents are not persisted in the layout! We should always open with no documents! if (notecardRecord != null) { ApplicationModel.ActiveNotecardRecord = notecardRecord; ApplicationController.MetadataController.UpdateURL(notecardRecord.URL); ApplicationController.MetadataController.UpdateTOC(notecardRecord.TableOfContents); ApplicationController.MetadataController.UpdateTags(notecardRecord.Tags); } }
当元数据更新时,元数据控制器可以更新活动记事卡记录(记录的 URL 实际上是在记事卡控制器中更新的)
public void SetTableOfContents(string toc) { ApplicationModel.ActiveNotecardRecord.TableOfContents = toc; } public void SetTags(string tags) { ApplicationModel.ActiveNotecardRecord.Tags = tags; }
最后,加载 DataSet 时,会执行一个查询,查找与 DataSet 关联的会话中所有被指定为“打开”的记事卡
public class ApplicationModel { ... public List<NotecardRecord> GetOpenNotecards() { List<NotecardRecord> openNotecards = new List<NotecardRecord>(); dataSet.Tables["Notecards"].AsEnumerable(). Where(t => t.Field<bool>("IsOpen")). ForEach(t => openNotecards.Add(new NotecardRecord(t))); return openNotecards; } ... }
上次保存 DataSet 时打开的记事卡会打开并定向到相应的 URL
public class ApplicationFormController { ... protected void OpenNotecardDocuments(List<NotecardRecord> notecards) { notecards.ForEach(t => { NewDocument("notecard.xml"); ((NotecardController)ActiveDocumentController).SetNotecardRecord(t); ((NotecardController)ActiveDocumentController).NavigateToURL(t.URL); }); } ... }
再次,因为我们只有一种文档控制器,我们可以安全地将活动文档控制器转换为 NotecardController 类型。
所有这些事件和交互都可以通过下图来说明
链接 Notecards
对于这个原型,我只会在当前打开的记事卡之间实现链接(目前处理数据集中的数千张记事卡并不完全可行)。 我想通过右键单击记事卡来实现在这一点,我发现 WebKit(我正在使用的版本,显然这在 SharpWebKit 中已修复)不允许我设置 WebKitBrowser 对象的 ContextMenuStrip,因此我需要实现 附录 D:捕获应用程序范围的鼠标事件 中描述的解决方法。
现在我们已经使右键单击功能正常工作,我们可以根据打开的记事卡动态创建上下文菜单(当前文本设置为 URL,我们稍后会修复)
public class NotecardView : UserControl { ... protected void CreateDynamicReferences() { ReferencesMenu.DropDownItems.Clear(); ReferencedByMenu.DropDownItems.Clear(); List<NotecardController> activeNotecardControllers = ApplicationController.ActiveNotecardControllers; activeNotecardControllers.ForEach(t => { ToolStripMenuItem item1 = new ToolStripMenuItem(t.NotecardRecord.URL); item1.Tag = t; item1.Click += Controller.LinkReferences; ReferencesMenu.DropDownItems.Add(item1); ToolStripMenuItem item2 = new ToolStripMenuItem(t.NotecardRecord.URL); item2.Tag = t; item2.Click += Controller.LinkReferencedFrom; ReferencedByMenu.DropDownItems.Add(item2); }); } ... }
并且与视图关联的控制器根据链接的方向处理对模型的调用
public class NotecardController : ViewController<NotecardView>, IDocumentController, INotecardController { ... public void LinkReferences(object sender, EventArgs e) { ToolStripMenuItem item = (ToolStripMenuItem)sender; NotecardController refController = (NotecardController)item.Tag; // Create an association between this controller, as the parent, and the refController, as the child. ApplicationModel.Associate(NotecardRecord, refController.NotecardRecord); } public void LinkReferencedFrom(object sender, EventArgs e) { ToolStripMenuItem item = (ToolStripMenuItem)sender; NotecardController refController = (NotecardController)item.Tag; // Create an association between this controller, as the child, and the refController, as the parent. ApplicationModel.Associate(refController.NotecardRecord, NotecardRecord); } ... }
最后,模型处理 DataSet 的实际操作
public class ApplicationModel { ... public void Associate(NotecardRecord parent, NotecardRecord child) { DataRow row = dataSet.Tables["NotecardReferences"].NewRow(); row["NotecardParentID"] = parent.ID;
row["NotecardChildID"] = child.ID; dataSet.Tables["NotecardReferences"].Rows.Add(row); } ... }
我们还需要查询“引用”和“被引用”的记事卡,这也在模型中实现。 其中一些代码依赖于 Juan Francisco Morales Larios 关于 Linq 扩展连接 的出色文章。
public List<NotecardRecord> GetReferences() { List<NotecardRecord> references = this["Notecards"].Join(this["NotecardReferences"].Where(t => t.Field<int>("NotecardParentID") == ActiveNotecardRecord.ID), pk => pk.Field<int>("ID"), fk => fk.Field<int>("NotecardChildID"), (pk, fk) => new NotecardRecord(pk)).ToList(); return references; } public List<NotecardRecord> GetReferencedFrom() { List<NotecardRecord> references = this["Notecards"].Join(this["NotecardReferences"].Where(t => t.Field<int>("NotecardChildID") == ActiveNotecardRecord.ID), pk => pk.Field<int>("ID"), fk => fk.Field<int>("NotecardParentID"), (pk, fk) => new NotecardRecord(pk)).ToList(); return references; }
对于目录,我们还需要获取根记事卡(那些未被其他记事卡引用的记事卡)的能力
public List<NotecardRecord> GetRootNotecards() { List<NotecardRecord> rootRecs = this["Notecards"].LeftExcludingJoin( this["NotecardReferences"], pk => pk.Field<int>("ID"), fk => fk.Field<int>("NotecardChildID"), (pk, fk) => pk).Select(t => new NotecardRecord(t)).ToList(); return rootRecs; }
参考文献视图
我们现在拥有所有组件来填充 TOC、索引、参考文献和被引用面板中的数据。 请注意,前面所示的一些标记已更改——我现在为每个窗格实现了控制器和视图类。
“链接到”(即引用)和“被引用”(即从何处引用)的实现非常简单。 这两个视图都派生自
public class ReferenceView : UserControl { public ApplicationModel Model { get; protected set; } public TreeView TreeView { get; protected set; } public void UpdateTree(List<NotecardRecord> refs) { TreeView.Nodes.Clear(); refs.ForEach(r => { TreeNode node = new TreeNode(r.URL); node.Tag = r; TreeView.Nodes.Add(node); }); } }
其中 ReferencesView 获取活动记录的引用
public class ReferencesView : ReferenceView { public void UpdateView() { List<NotecardRecord> refs = Model.GetReferences(); UpdateTree(refs); } }
与 ReferencesFromView 相比,它获取从其他记事卡到活动记录的“引用”
public class ReferencedFromView : ReferenceView { public void UpdateView() { List<NotecardRecord> refs = Model.GetReferencedFrom(); UpdateTree(refs); } }
索引视图
此视图累积并索引每个记事卡的标签,并且是所有视图中代码量最大的一个
public class IndexView : UserControl { public ApplicationModel Model { get; protected set; } public TreeView TreeView { get; protected set; } public void RefreshView() { Dictionary<string, List<NotecardRecord>> tagRecordMap; TreeView.Nodes.Clear(); tagRecordMap = BuildTagRecordMap(); // Sort the list by tag value. var orderedIndexList = tagRecordMap.OrderBy((item)=>item.Key); BuildTree(orderedIndexList); } protected Dictionary<string, List<NotecardRecord>> BuildTagRecordMap() { Dictionary<string, List<NotecardRecord>> tagRecordMap = new Dictionary<string, List<NotecardRecord>>(); // Build the view model, which is a list of references for tag item. Model.ForEachNotecard(rec => { Model.GetTags(rec).Where(t=>!String.IsNullOrEmpty(t)).ForEach(t => { List<NotecardRecord> records; if (!tagRecordMap.TryGetValue(t, out records)) { records = new List<NotecardRecord>(); tagRecordMap[t] = records; } records.Add(rec); }); }); return tagRecordMap; } protected void BuildTree(IOrderedEnumerable<KeyValuePair<string, List<NotecardRecord>>> orderedIndexList) { orderedIndexList.ForEach(item => { TreeNode tn = new TreeNode(item.Key); TreeView.Nodes.Add(tn); if (item.Value.Count == 1) { // Only one notecard for this index item, so set the node's tag to the notecard record. tn.Tag = item.Value[0]; } else if (item.Value.Count > 1) { // Multiple notecards for this index item, so create child nodes and set the node's tag to the associated notecard record. item.Value.ForEach(rec => { TreeNode tn2 = new TreeNode(rec.URL); tn2.Tag = rec; tn.Nodes.Add(tn2); }); } }); } }
目录视图
目录是从根记事卡(那些没有被任何地方引用的记事卡)构建的,并且排除了任何没有 TOC 条目的引用,并确保如果存在循环引用,我们不会陷入无限递归状态
public class TableOfContentsView : UserControl { public ApplicationModel Model { get; protected set; } public TreeView TreeView { get; protected set; } protected List<int> encounteredRecords; public void RefreshView() { encounteredRecords = new List<int>(); List<NotecardRecord> rootRecs = Model.GetRootNotecards(); TreeView.Nodes.Clear(); PopulateTree(rootRecs); } protected void PopulateTree(List<NotecardRecord> rootRecs) { rootRecs.Where(r=>!String.IsNullOrEmpty(r.TableOfContents)).ForEach(r => { encounteredRecords.Add(r.ID); TreeNode tn = new TreeNode(r.TableOfContents); tn.Tag = r; TreeView.Nodes.Add(tn); PopulateChildren(tn, r); }); } protected void PopulateChildren(TreeNode node, NotecardRecord rec) { List<NotecardRecord> childRecs = Model.GetReferences(rec); childRecs.Where(r=>(!String.IsNullOrEmpty(r.TableOfContents)) && (!encounteredRecords.Contains(r.ID))).ForEach(r => { encounteredRecords.Add(r.ID); TreeNode tn = new TreeNode(r.TableOfContents); tn.Tag = r; node.Nodes.Add(tn); // Recurse into grandchildren, etc. PopulateChildren(tn, r); }); } }
利用现有资源
此时,让我们稍作休息,只使用 URL(来自网络和本地文件),将一些食谱记事卡整理出来。 我想按早餐、午餐和晚餐来组织我的食谱,并且我希望标签是配料,这样如果我对包含西兰花的食谱感兴趣,我就可以找到所有这些食谱。
首先,我手工创建了一些基本文件(我们稍后会实现一个内容编辑器),它们都看起来与此类似
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN"> <html> <head> <title>Recipes</title> </head> <body> <p>Recipes</p> </body> </html>
这些将作为我目录的占位符。 我为每个占位符添加新的记事卡,生成目录,然后我们得到一个扁平的目录
这不是我们想要的——我们希望食谱引用早餐、午餐和晚餐,因此我们选择食谱记事卡,然后针对每个用餐时间,右键单击并创建关联
我们现在得到了一个组织良好的目录,并且还注意到“链接到”窗格显示了“食谱”记事卡引用的记事卡
现在我们可以从互联网上添加一些食谱,并在将食谱与用餐时间进行适当关联,并填充标签后,我们就拥有了一本食谱书的雏形
这里要注意的是
- 目录是一个多级树,包含所有具有 TOC 值的记事卡。
- “链接到”窗格显示当前记事卡(“晚餐”)引用的记事卡名称。
- “被引用”窗格显示引用当前记事卡(“晚餐”)的记事卡名称。
- “索引”窗格显示所有标签(我们所有食谱的配料),如果记事卡不止一个,则显示记事卡选项作为子节点项。
处理 TreeView 点击事件
当然,我们希望能够点击 TOC、索引或引用节点并让它打开 URL。 事件已在标记中连接,例如
<wf:TreeView def:Name="treeView" Dock="Fill" NodeMouseClick="{ApplicationFormController.OpenNotecard}">
并路由到应用程序控制器
protected void OpenNotecard(object sender, TreeNodeMouseClickEventArgs args) { NotecardRecord rec = args.Node.Tag as NotecardRecord; if (rec != null) { if (!rec.IsOpen) { NewDocument("notecard.xml"); ((NotecardController)ActiveDocumentController).SetNotecardRecord(rec); ((NotecardController)ActiveDocumentController).NavigateToURL(rec.URL); ((NotecardController)ActiveDocumentController).IsActive(); rec.IsOpen = true; } else { // Don't open a new document, select the one that is already open. IDockContent content = documentControllerMap.Single(t=>((NotecardController)t.Value).NotecardRecord==rec).Key; content.DockHandler.Show(); } } }
编辑 Notecard 内容
最后(至少对于此原型实现),我们希望用户能够添加实际内容。 由于记事卡基于网络浏览器,因此使用 HTML 编辑器肯定有意义,我在这里找到了一个不错的编辑器。 请注意,这不是为了编辑现有网页——这是为了创建您自己的 *简单* 内容(您 *不* 应该使用它来编辑来自互联网的网页,除其他外,所有样式表信息都会丢失)。
HTML 编辑器作为隐藏控件添加到记事本视图标记中
<editor:HtmlEditorControl def:Name="htmlEditor" Dock="Fill" Visible="false"/> <wk:WebKitBrowser def:Name="browser" Dock="Fill" .../>
并添加了一个右键上下文菜单选项
<wf:ToolStripMenuItem def:Name="editHtml" Text="&Edit Document" Click="{controller.EditHtml}"/>
记事本控制器处理它(现在有点笨拙)
protected void EditHtml(object sender, EventArgs args) { if (!editing) { editing = true; View.BeginHtmlEditing(); } else { editing = false; View.EndHtmlEditing(); // Must get the new text from the HtmlEditor, as the WebKit Browser control // won't have updated the return value of the Browser.DocumentText property! NotecardRecord.HTML = View.HtmlEditor.InnerHtml; } }
视图完成其余部分
public void BeginHtmlEditing() { HtmlEditor.InnerHtml = Browser.DocumentText; Browser.Visible = false; HtmlEditor.Visible = true; EditHtml.Text = "&Save Html"; } public void EndHtmlEditing() { Browser.DocumentText = HtmlEditor.InnerHtml; HtmlEditor.Visible = false; Browser.Visible = true; EditHtml.Text = "&Edit Html"; }
现在,当我们打开一条记录时,自定义内容只需要正确处理,这意味着,我们不再调用 NavigateToURL,而是添加 ShowDocument 方法,并让控制器确定是使用 URL 还是自定义 HTML
public void ShowDocument() { if (!(String.IsNullOrEmpty(NotecardRecord.HTML))) { View.Browser.DocumentText = NotecardRecord.HTML; } else { NavigateToURL(NotecardRecord.URL); } }
所以,现在通过右键单击“编辑记事卡”
我可以使用一个时髦的 HTML 编辑器创建自己的记事卡
现在我可以像使用 HTML 文件或 URL 一样交叉引用我的自定义记事卡了
结论
最终(或本例中是这一周,因为将其全部组合起来大约花了一周时间),我们得到了一个可用原型,实现了我复活 Apple HyperCard 概念的愿景。也许我会因此被起诉! 无论如何,尽管此时这是一个足够可用的应用程序,但仍有很多粗糙之处——需要解决的可用性问题(例如,您必须告诉程序从“视图/刷新”菜单重新生成 TOC 和索引)以及可能存在的许多奇怪的错误。 但这项工作将留待他日再做。 此外,还缺少一些有用的功能,例如删除记事卡! 您还会注意到这是一个 x86 应用程序,因为 WebKit 只能在 32 位模式下运行。
像往常一样,如果您有兴趣为这个项目做出贡献,请告诉我。 就我个人而言,我希望将其发展成为一个可行的商业产品,但这里提供的代码可供社区使用。
附录 A - 定义和加载 Schema
模式表示为实例化 DataSet 的对象图
<?xml version="1.0" encoding="utf-8" ?> <MycroXaml Name="Schema" xmlns:d="System.Data, System.Data, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" xmlns:def="def" xmlns:ref="ref"> <d:DataSet Name="Dataset"> <d:Tables> <d:DataTable Name="Notecards" TableName="Notecards"> <d:Columns> <d:DataColumn Name="NotecardID" ColumnName="ID" AllowDBNull="false" AutoIncrement="true" DataType="System.Int32" /> <d:DataColumn ColumnName="TableOfContents" AllowDBNull="true" DataType="System.String"/> <d:DataColumn ColumnName="URL" AllowDBNull="true" DataType="System.String"/> <d:DataColumn ColumnName="Title" AllowDBNull="true" DataType="System.String"/> <d:DataColumn ColumnName="HTML" AllowDBNull="true" DataType="System.String"/> <d:DataColumn ColumnName="IsOpen" AllowDBNull="true" DataType="System.Boolean"/> </d:Columns> </d:DataTable> <d:DataTable Name="NotecardReferences" TableName="NotecardReferences"> <d:Columns> <d:DataColumn Name="NotecardReferenceID" ColumnName="ID" AllowDBNull="false" AutoIncrement="true" DataType="System.Int32"/> <d:DataColumn Name="NotecardParentID" ColumnName="NotecardParentID" AllowDBNull="false" DataType="System.Int32"/> <d:DataColumn Name="NotecardChildID" ColumnName="NotecardChildID" AllowDBNull="false" DataType="System.Int32"/> </d:Columns> </d:DataTable> <d:DataTable Name="Metadata" TableName="Metadata"> <d:Columns> <d:DataColumn Name="MetadataID" ColumnName="ID" AllowDBNull="false" AutoIncrement="true" DataType="System.Int32"/> <d:DataColumn Name="Metadata_NotecardID" ColumnName="NotecardID" AllowDBNull="false" DataType="System.Int32"/> <d:DataColumn ColumnName="Tag" AllowDBNull="false" DataType="System.String"/> </d:Columns> </d:DataTable> </d:Tables> <d:Relations> <d:DataRelation Name="FK_Metadata_Notecard" ChildColumn="{Metadata_NotecardID}" ParentColumn="{NotecardID}"/> <d:DataRelation Name="FK_NotecardRef_Notecard1" ChildColumn="{NotecardParentID}" ParentColumn="{NotecardID}"/> <d:DataRelation Name="FK_NotecardRef_Notecard2" ChildColumn="{NotecardChildID}" ParentColumn="{NotecardID}"/> </d:Relations> <d:DataTable ref:Name="Notecards" PrimaryKey="{NotecardID}"/> <d:DataTable ref:Name="NotecardReferences" PrimaryKey="{NotecardReferenceID}"/> <d:DataTable ref:Name="Metadata" PrimaryKey="{MetadataID}"/> </d:DataSet> </MycroXaml>
不幸的是,某些属性(DataType)和类(DataRelation)对声明式实例化并不是特别友好,需要一些“帮助”
public static class SchemaHelper { public static DataSet CreateSchema() { MycroParser mp = new MycroParser(); // Instantiation of schemas using .NET classes needs some help. mp.CustomAssignProperty += new CustomAssignPropertyDlgt(CustomAssignProperty); mp.InstantiateClass += new InstantiateClassDlgt(InstantiateClass); mp.UnknownProperty += new UnknownPropertyDlgt(UnknownProperty); XmlDocument doc = new XmlDocument(); doc.Load("schema.xml"); mp.Load(doc, "Schema", null); DataSet dataSet = (DataSet)mp.Process(); return dataSet; } public static void CustomAssignProperty(object sender, CustomPropertyEventArgs pea) { if (pea.PropertyInfo.Name == "DataType") { Type t = Type.GetType(pea.Value.ToString()); pea.PropertyInfo.SetValue(pea.Source, t, null); pea.Handled = true; } else if (pea.PropertyInfo.Name == "PrimaryKey") { pea.PropertyInfo.SetValue(pea.Source, new DataColumn[] { (DataColumn)pea.Value }, null); pea.Handled = true; } } public static void InstantiateClass(object sender, ClassEventArgs cea) { MycroParser mp = (MycroParser)sender; if (cea.Type.Name == "DataRelation") { string name = cea.Node.Attributes["Name"].Value; string childColumnRef = cea.Node.Attributes["ChildColumn"].Value; string parentColumnRef = cea.Node.Attributes["ParentColumn"].Value; DataColumn dcChild = (DataColumn)mp.GetInstance(childColumnRef.Between('{', '}')); DataColumn dcParent = (DataColumn)mp.GetInstance(parentColumnRef.Between('{', '}')); cea.Result = new DataRelation(name, dcParent, dcChild); cea.Handled = true; } } public static void UnknownProperty(object sender, UnknownPropertyEventArgs pea) { // Ignore these attributes. // TODO: add the element name into the args, so we can also test the element for which we want to ignore certain properties. if ((pea.PropertyName == "ChildColumn") || (pea.PropertyName == "ParentColumn")) { pea.Handled = true; } } }
附录 B - 模型持久性方法
这处理 DataSet 到 XML 文件的保存和加载
public class ApplicationModel { protected DataSet dataSet; protected string filename; public ApplicationModel() { dataSet = SchemaHelper.CreateSchema(); } public void NewModel() { dataSet = SchemaHelper.CreateSchema(); filename = String.Empty; } public void LoadModel(string filename) { this.filename = filename; dataSet = SchemaHelper.CreateSchema(); dataSet.ReadXml(filename, XmlReadMode.IgnoreSchema); } public void SaveModel() { dataSet.WriteXml(filename, XmlWriteMode.WriteSchema); } public void SaveModelAs(string filename) { this.filename = filename; dataSet.WriteXml(filename, XmlWriteMode.WriteSchema); } }
附录 C:NotecardRecord 实现
这是底层与记事卡记录关联的 DataRow 的一个薄包装器
public class NotecardRecord { public string TableOfContents { get { return row.Field<string>("TableOfContents"); } set { row["TableOfContents"] = value; } } public string URL { get { return row.Field<string>("URL"); } set { row["URL"] = value; } } public string HTML { get { return row.Field<string>("HTML"); } set { row["HTML"] = value; } } public string Tags { get { return JoinTags(); } set { ParseTags(value); } } public bool IsOpen { get { return row.Field<bool>("IsOpen"); } set { row["IsOpen"] = value; } } protected DataRow row; public NotecardRecord(DataRow row) { this.row = row; } protected string JoinTags() { return String.Empty; } protected void ParseTags(string tags) { } }
附录 D:捕获应用程序范围的鼠标事件
这是一个复杂的解决方法,需要首先拦截应用程序范围的右键单击消息,然后发布(而不是发送)自定义消息到应用程序以处理事件,这反过来又需要确保右键单击确实发生在记事卡上。 让我们开始
在启动时,我们注册一个自定义窗口消息和我们的自定义消息过滤器
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] static extern uint RegisterWindowMessage(string lpString); public static void Main() { RightClickWindowMessage = RegisterWindowMessage("IntertextiRightClick"); IMessageFilter myFilter = new MyMessageFilter(); Application.AddMessageFilter(myFilter); ...
自定义消息过滤器查找右键单击事件并发布一条消息来处理该事件。 右键单击未过滤,允许应用程序正常处理。 我们发布消息的原因是,我们不想立即处理它——我们希望给 Windows 和应用程序机会做它所做的事情,在我们的情况下,它是将焦点设置到鼠标单击的控件(这在某个地方为我们完成)。 发布消息将消息添加到 Windows 消息队列的末尾,这与 SendMessage 相反,后者在两个窗口具有相同线程时立即处理消息。 消息过滤器
public class MyMessageFilter : IMessageFilter { [return: MarshalAs(UnmanagedType.Bool)] [DllImport("user32.dll", SetLastError = true)] static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); public bool PreFilterMessage(ref Message m) { if (m.Msg == 0x204) //WM_RBUTTONDOWN { PostMessage(Program.MainForm.Handle, Program.RightClickWindowMessage, m.WParam, m.LParam); } return false; // do not filter } }
接下来,我们在应用程序主窗体中查找我们的自定义右键单击消息并触发一个事件
public delegate void RightClickDlgt(int x, int y); public class ApplicationFormView : Form { public event RightClickDlgt RightClick; ... protected override void WndProc(ref Message m) { if (m.Msg == Program.RightClickWindowMessage) { // client area (x,y) int x = (int)(((ulong)m.LParam) & 0xFFFF); int y = (int)(((ulong)m.LParam) >> 16); if (RightClick != null) { RightClick(x, y); } } else { base.WndProc(ref m); } } }
事件已在标记中连接
<ixv:ApplicationFormView ref:Name="applicationFormView" DockPanel="{dockPanel}" Load="{controller.LoadLayout}" Closing="{controller.Closing}" RightClick="{controller.RightClick}"/>
并由应用程序控制器处理,该控制器只请求活动文档控制器显示上下文菜单。
protected void RightClick(int x, int y) { ActiveDocumentController.ShowContextMenu(x, y); }
这个请求被传递给控制器的视图(我没有将 View 属性暴露给其他类,所以我们总是必须经过这一步,因为我不想让控制器与其他控制器的视图通信)
public class NotecardController ... { ... public void ShowContextMenu(int x, int y) { View.ShowContextMenu(new Point(x, y)); } ... }
在视图中,坐标被测试。 这需要将应用程序活动控件的客户端坐标转换为屏幕坐标,然后将屏幕坐标与记事卡窗口的屏幕坐标进行比较,这目前相当笨拙(Parent.Parent.Parent 的情况)
public void ShowContextMenu(Point p) { // Determine whether the mouse click occurred on the this control: // The point is relative to the control that currently has focus. ApplicationFormView app = (ApplicationFormView)Parent.Parent.Parent; Control activeCtrl = (Control)((ApplicationFormView)Parent.Parent.Parent).DockPanel.ActiveContent; // app.ActiveControl; // Convert to a screen point relative to the active control where the right-click occurred. Point screenPoint = activeCtrl.PointToScreen(p); // Get the screen location for this view. Point viewUpperLeft = PointToScreen(new Point(0, 0)); Rectangle viewRect = new Rectangle(viewUpperLeft, Size); if (viewRect.Contains(screenPoint)) { BrowserContextMenu.Show(PointToScreen(p)); } }
为了获得所需行为,这需要相当多的工作,如果我使用不同的浏览器控件,可能可以全部删除,并且由于对 UI 对象图的硬编码依赖,需要进行清理。 但是,它有效,这才是目前最重要的。