Contents
Important: To Run The Application...
Download the WebKit.NET binaries from
here and copy
them into the bin\x86\Debug folder!
Intertexti is an initiative I'm undertaking to implement something similar to
Apple's HyperCard
application. The name is based on the Latin word for "intertwined", since
the notecards in the system can be linked any-which-way to other cards.
What You Will Encounter
In this application, I take advantage of the following third party components:
as well as:
What Am I Trying To Accomplish ?
The problem I'm trying to solve is this - in most everything that I do either for work or personal projects, I find a lot of useful information spread across web pages. I need to organize those pages in better ways than just bookmarking -- I want to be able to add tags, create an index from those tags, see what pages I want to associate with other pages (which often enough the page itself doesn't link to directly), and so forth.
Furthermore, I want to be able to associate my own notes, todo lists, commentary, etc., and reference back to relevant pages. Again, this kind of note taking and cross-referencing is not something a browser supports. Furthermore, and especially with regards to my own notes, I want to be able to organize them relationally, which can often be expressed visually as a table of contents -- something that shows the structure and organization of my own notes as well as providing links to sources, further reading, and so forth.
A Word document is simply to linear for me -- it's one dimensional, like the vertical scrollbar. The second dimension, the cross-referencing of documents, is what Intertexti achieves, at least in prototype form.
My Design Goals
As usual, the goal is to design something that is flexible, almost
immediately useable, and is implemented with minimal amount of code. You will note my heavy reliance on my standard operating practice of using XML for everything declarative. You will also note that, as a result of the MVC architecture, methods are very small -- some only a line or two. Lastly, the underlying application organization should be support the idea of extensibility -- for example, if you don't want to use WebKit is the browser engine, you should be able to replace it with something else with a minimal amount of fuss.
About This Article
I like to write the article as I'm doing the coding, so what you will encounter here is a log, if you will, of the development process. The final source code has slight differences from the code presented here--for example, the final version of the NotecardRecord class uses a factory pattern rather than a publicly exposed constructor. But the idea is that the reader will get a sense of how the application was developed and the problems I encountered (like handling right-click mouse events on the WebKit browser).
My initial features are not too ambitious:
- Dockable windows using Weifen Luo's Dock Panel Suite.
- A side panel for the table of contents
- A side panel for the index
- A side panel for links to other notecards--"references". One of
the usability issues I keep encountering is that links are usually embedded
- portions of text, areas on an image, etc. For text, this means
having to scan the text for links. What I want instead is for all the
possible links to be displayed in a separate section with annotation
describing the link.
- A side panel for notecards that link to this notecard--"referenced by".
Often enough, I want to see what the broader theme is.
- An HTML-based notecard, allowing any HTML content and possible future
scripting.
As might be surmised by the description above of references, I view
references as being unidirectional, essentially drilling down (or at least
horizontally) whereas the links for "referenced by" are popping up the tree.
There are four forms of navigation, but these should be intuitive to the
user:
- Table of Contents: Each notecard has a title and the table of contents
is generated from title information, and sub-sections are determined by
references on the notecard designated as "subsection" or something similar.
If a reference points to a notecard already in the table of contents, it is
ignored.
- Index: This lists all the notecards where a specific text tag is used.
- Forward references: Not every reference needs to go into the table of
contents, but a link to another notecard might still be useful. These
(in addition to the table of contents references) are displayed in the
references navigation.
- Reverse references: This displays the list of notecards referencing the
current notecard.
For example, given 5 notecards:
The four navigable components are:
The above pieces can be quickly put together in a scaffold--no content, just
the layout of the views:
Getting this scaffolding up and running takes about 200 lines of code,
leveraging Weifen Luo's
DockPanelSuite and my recent article on
Decoupling Content from Container, A basic MVC model is used.
Here we simply instantiate the application's main form:
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;
}
}
The application's view is simply a placeholder for the DockPanel instance, as
this is referenced by the controller to respond to menu events:
public class ApplicationFormView : Form
{
public DockPanel DockPanel { get; protected set; }
public ApplicationFormView()
{
}
}
The controller handles all of the application's main form events. Most
of this is boilerplate for working with DockPanelSuite. The only
noteworthy thing here is to observe that the controller is derived from the
abstract class ViewController, which requires defining the concrete view type to
which the controller is associated. This makes it easier in the controller
to work with the specific view properties -- we can avoid all the casting that
would otherwise be required.
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();
}
}
}
}
}
All controllers associated with views are derived from ViewController, which
simply provides a typed instance of the underlying view, accessible in the
derived controller class:
public abstract class ViewController<T> : ISupportInitialize
{
public T View { get; set; }
public ViewController()
{
}
public virtual void BeginInit()
{
}
public virtual void EndInit()
{
}
}
We will see later where the EndInit() virtual method is used, but for now,
just keep in mind that the instantiation engine calls this method when the
object has been completely instantiated, which we can take advantage of to do
some application-specific initialization in the controller.
The definition of the layout and initial settings is handled by the
declarative code, instantiated using
MycroXaml (with
some modifcations).
This defines the layout of the application. Note here how various
assemblies are being pulled in and the view and controller is being instantiated
with final property and event wire-up done last.
<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>
<!---->
<!---->
<ixv:ApplicationFormView ref:Name="applicationFormView" DockPanel="{dockPanel}" Load="{controller.LoadLayout}" Closing="{controller.Closing}"/>
</ixv:ApplicationFormView>
</MycroXaml>
There are four initial panes defined in:
- indexPane.xml
- linksToPane.xml
- referencedByPane.xml
- tableOfContentsPane.xml
and they all are very similar, so I'll show the markup for only one of them,
indexPane.xml:
="1.0" ="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>
The only thing at the moment that differs between the panes is the text and
the ShowHint property value assignment.
It seemed most reasonable to leverage web browser technology as a means for
rendering notecards. This also has the future possibility of embedding
scripts or other control logic to create more sophisticated and dynamic
notecards. Rather than use the browser that is embedded in .NET, I decided
to use WebKit, specifically the
WebKit.NET
implementation. You should be aware however that this implementation does
not look like it's actively supported (the last update was August 2010) and is a
wrapper for WebKit, Readers should
consider looking at open-webkit-sharp
instead. For the moment, the stalled WebKit.NET project is sufficient for
this article for two reasons: I'm using VS2008 and open-webkit-sharp's binaries
are built with VS2010/12, and I also can't get the a simple browser application
to work and I don't want to spend the time fussing with it right now.
The imperative code is simply stubs to get something basic working.
There is no implementation:
public class NotecardController :ViewController<NotecardView>
{
}
The only implementation here is to load something into the browser window:
public class NotecardView : UserControl
{
protected WebKitBrowser browser;
public WebKitBrowser Browser
{
get { return browser; }
set { browser = value; browser.Navigate("http://www.codeproject.com"); }
}
public NotecardView()
{
}
}
A method has been added to handle the menu event to add a new notecard:
protected void NewNotecard(object sender, EventArgs args)
{
NewDocument("notecard.xml");
}
The notecard shown in the screenshot above is instantiated by notecard.xml.
="1.0" ="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>
A new menu item has been added:
<wf:ToolStripMenuItem Text="&Notecard">
<wf:DropDownItems>
<wf:ToolStripMenuItem Text="&New" Click="{controller.NewNotecard}"/>
</wf:DropDownItems>
</wf:ToolStripMenuItem>
A lot of things that I want to organize are actually URL's, so I'm going to
stick with the basic concept of navigating and linking together URL's.
Some of this will end up being a bit hokey because I'm putting off actually
editing notecards until later in this article, but we can get the entire application behavior regarding linkages just by working
with URL's. And yes, this will end up creating a tabbed browser
application with the ability to organize and associate web pages. Amusing,
isn't it?
What we need first is the
ability to associate four things with a notecard:
- The desired URL
- A table of contents label
- Keywords, which will be used to generate the index information
- Linkage, allowing us to describe that a notecard is associated with
another notecard
The first three items above (URL, TOC, and Keywords) are a standard part of
each notecard. The question becomes, should this information be associated
with each notecard or in a location that is context-specific to the selected
notecard? I've opted for the second option, as this allows us to remove
some of the clutter that we don't need to be looking at when simply navigating
the data. Ideally, this should be another DockPanel pane, giving the user
the flexibility to move it around where they want:
="1.0" ="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>
In the notecard XML, I've added the line:
<ixa:RegisterDocumentController
App="{ApplicationFormController}"
Container="{Container}"
Controller="{controller}"/>
Note the markup element RegisterDocumentController. This is a serious
"cheat" on my part, providing the capability to add "actions" through the
instantiation and post-initialization of instances. The following diagram
illustrates why we need this:
The question is, how do we get the MetadataController to tell the
NotecardController to navigate to a particular URL? Furthermore, there can
be multiple notecards displayed at the same time, so we need a way to track the
active notecard. DockPanelSuite provides an event for keeping track of the
active notecard, which we wire up in the mainForm markup, as this is an event
provided by DockPanel:
<wfui:DockPanel def:Name="dockPanel" Dock="Fill" ActiveDocumentChanged="{controller.ActiveDocumentChanged}"/>
Implemented in the ApplicationFormController as:
public IDocumentController ActiveDocumentController { get; protected set; }
protected void ActiveDocumentChanged(object sender, EventArgs args)
{
DockPanel dockPanel = (DockPanel)sender;
IDockContent content = dockPanel.ActiveDocument;
ActiveDocumentController = documentControllerMap[content];
}
However, we still need to register the controller associated with the
dock content, which is what the "action" RegisterDocumentController does:
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);
}
}
And in the application controller:
public void RegisterDocumentController(IDockContent content, IDocumentController controller)
{
documentControllerMap[content] = controller;
}
Now, the metadata controller can get the active notecard controller from the
application controller -- three controllers are involved!
public class MetadataController
{
public ApplicationFormController AppController { get; set; }
public void NavigateToURL(string url)
{
((INotecardController)AppController.ActiveDocumentController).NavigateToURL(url);
}
}
Since we only have one kind of document, implemented as an
INotecardController, we can safely cast the controller. The above diagram
now looks like this:
Of course, we also need the ability to remove the entries in the
content-controller map. This is done by wiring up the DockPanel event:
<wfui:DockPanel
def:Name="dockPanel"
Dock="Fill"
ActiveDocumentChanged="{controller.ActiveDocumentChanged}"
ContentRemoved="{controller.ContentRemoved}"/>
and providing the implementation in the application controller:
protected void ContentRemoved(object sender, DockContentEventArgs e)
{
documentControllerMap.Remove(e.Content);
}
One last little nuance -- by capturing the DocumentTitledChanged event of the
Browser control:
<wk:WebKitBrowser
def:Name="browser"
Dock="Fill"
DocumentTitleChanged="{controller.DocumentTitleChanged}"/>
We can set the title of the tab:
protected void DocumentTitleChanged(object sender, EventArgs args)
{
((GenericDocument)View.Parent).Text = View.Browser.DocumentTitle;
}
We now have a non-persisting multi-tab browser:
It would behoove us to create and wire-up the model. As you probably
tell, I am not a model-driven-developer -- models tend to change a lot as the UI
is being developed, so I like to get some of the UI aspects in place before
constructing the model. Then again, this approach works well when
implementing on the fly with only a thin paper design. If I were working
with a fully storyboarded application, then yes, probably the model would be a
good place to start, but still, it's less instant-gratifying.
In another radical approach, I'm not going to implement persistence with a
third party database; a .NET DataSet is perfectly adequate for the job at hand
-- persistable and relational. Also, keep in mind that the layout of the
UI (a model in its own right) is being handled completely by DockPanelSuite, so
we don't need to worry about that.
The model that we need at this point looks like this:
See Appendix A
for how the schema is declared and instantiated.
See Appendix B for the model persistence methods.
See Appendix C for the NotecardRecord implementation.
Appendices A-C are just boilerplate schema and data management code.
The more interesting model code is in the application specific features. The model, in addition to maintaining the DataSet instance, also provides a
property allowing a controller to get or set the active notecard record:
public NotecardRecord ActiveNotecardRecord { get; set; }
The active record is initialized when a new notecard is created:
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;
}
This occurs in the application's controller when a new notecard request
(from the menu) is made:
protected void NewNotecard(object sender, EventArgs args)
{
NewDocument("notecard.xml");
NotecardRecord notecard = ApplicationModel.NewNotecard();
notecard.IsOpen = true;
((NotecardController)ActiveDocumentController).SetNotecardRecord(notecard);
}
Furthermore, each controller maintains the notecard record instance to which
it is associated. Therefore, when the notecard in selected, the controller
can update the metadata panel controls as well as the active record:
public void IsActive()
{
if (notecardRecord != null)
{
ApplicationModel.ActiveNotecardRecord = notecardRecord;
ApplicationController.MetadataController.UpdateURL(notecardRecord.URL);
ApplicationController.MetadataController.UpdateTOC(notecardRecord.TableOfContents);
ApplicationController.MetadataController.UpdateTags(notecardRecord.Tags);
}
}
When the metadata is updated, the metadata controller can update the active notecard
record (the record's URL is updated actually in the notecard controller):
public void SetTableOfContents(string toc)
{
ApplicationModel.ActiveNotecardRecord.TableOfContents = toc;
}
public void SetTags(string tags)
{
ApplicationModel.ActiveNotecardRecord.Tags = tags;
}
Lastly, when a DataSet is loaded, a query is executed for all the notecards
that were designated as "open" in the session associated with the 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;
}
...
}
The notecards that were open in the last session when the DataSet was saved are opened and directed to the appropriate URL's:
public class ApplicationFormController
{
...
protected void OpenNotecardDocuments(List<NotecardRecord> notecards)
{
notecards.ForEach(t =>
{
NewDocument("notecard.xml");
((NotecardController)ActiveDocumentController).SetNotecardRecord(t);
((NotecardController)ActiveDocumentController).NavigateToURL(t.URL);
});
}
...
}
Again, because we only have one kind of document controller, we can safely
cast the active document controller to the NotecardController type.
All of these events and interactions can be illustrated by the following
diagram:
For this prototype, I'm only going to implement linkages between notecards
that are currently open (handling potentially thousands of notecards in the
dataset is not exactly feasible at the moment.) Wanting to implement this
is a right-click operation on a notecard, I discovered that WebKit (the version
I'm using, apparently this is fixed in SharpWebKit) doesn't allow me to set the
ContextMenuStrip of the WebKitBrowser object, therefore I needed to implement
the workaround described in
Appendix D:
Capturing Application-Wide Mouse Events.
Now that we have the right-click feature working correctly, we can create the
context menu dynamically based on the open notecards (currently the text is set
to the URL, we'll fix that later):
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);
});
}
...
}
and the controller associated with the view handles the call to the model
depending on the direction of the link:
public class NotecardController : ViewController<NotecardView>, IDocumentController, INotecardController
{
...
public void LinkReferences(object sender, EventArgs e)
{
ToolStripMenuItem item = (ToolStripMenuItem)sender;
NotecardController refController = (NotecardController)item.Tag;
ApplicationModel.Associate(NotecardRecord, refController.NotecardRecord);
}
public void LinkReferencedFrom(object sender, EventArgs e)
{
ToolStripMenuItem item = (ToolStripMenuItem)sender;
NotecardController refController = (NotecardController)item.Tag;
ApplicationModel.Associate(refController.NotecardRecord, NotecardRecord);
}
...
}
and finally the model handles the actual manipulation of the 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);
}
...
}
We also need to query the "references" and "referenced from" notecards,
also implemented in the model. Some of this code relies on Juan Francisco
Morales Larios' excellent article on
Linq
Extended Joins.
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;
}
For the table of contents, we also need the ability to get root notecards
(those that aren't referenced by other notecards):
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;
}
We now have all the pieces to fill in the data in the TOC, indices,
references, and referenced by panels. Note that some of the markup
illustrated earlier has changed -- I have now implemented controller and view
classes for each of the panes.
The "links to" (aka references) and "referenced by" (aka referenced from)
implementations are quite trivial. Both views derive from:
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);
});
}
}
where ReferencesView gets the references of the active record:
public class ReferencesView : ReferenceView
{
public void UpdateView()
{
List<NotecardRecord> refs = Model.GetReferences();
UpdateTree(refs);
}
}
as compared to ReferencesFromView, which gets the "references from" other
notecards to the active record:
public class ReferencedFromView : ReferenceView
{
public void UpdateView()
{
List<NotecardRecord> refs = Model.GetReferencedFrom();
UpdateTree(refs);
}
}
This view accumulates and indexes the tags for each notecard and has the most
code of any of the views:
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();
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>>();
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)
{
tn.Tag = item.Value[0];
}
else if (item.Value.Count > 1)
{
item.Value.ForEach(rec =>
{
TreeNode tn2 = new TreeNode(rec.URL);
tn2.Tag = rec;
tn.Nodes.Add(tn2);
});
}
});
}
}
The table of contents is built from root notecards (those that aren't
referenced anywhere) and excludes any references that do not have a TOC entry,
as well as ensuring we don't get into an infinite recursion state if there are
circular references:
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);
PopulateChildren(tn, r);
});
}
}
At this point, let's take a small breather and, using just URL's (both from
the web and local files), put together some recipe notecards. I want to
organize my recipes by breakfast, lunch, and dinner, and I want the tags to be
the ingredients so if I'm interested in a recipe that contains broccoli, I can
find all those recipes.
First off, I created by hand some basic files (we'll implement a content
editor later), that all look similar to this:
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
<html>
<head>
<title>Recipes</title>
</head>
<body>
<p>Recipes</p>
</body>
</html>
These are going to be placeholders for my table of contents. I add new
notecards for each of my placeholders, generate the TOC, and we get a flat TOC:
This isn't what we want - we want Recipes to references Breakfast, Lunch, and
Dinner, so we select the Recipes notecard, and for each mealtime, right click
and create the association:
We now get a properly organized TOC and also note that the "Links To" pane
shows what notecards the Recipes notecard references:
Now we can add some recipes from the Internet, and after making the
appropriate associations of a recipe to a mealtime, as well as populating the
tags, we have the beginnings of a recipe book:
The things to note here are:
- the table of contents is a multi-level tree of all notecards that have a
TOC value.
- the "Links To" pane displays the notecard names that the current notecard
("Dinner") references.
- the "Referenced By" pane displays the notecard names that reference the
current nodecard ("Dinner").
- the "Index" pane displays all the tags (our ingredients for all recipes) and
if there is more than one notecard, shows the notecard options as sub-node
items.
Of course, we want to be able to click on a TOC, index, or reference node and
have it open the URL. The event is wired up in the markup, for example:
<wf:TreeView def:Name="treeView" Dock="Fill" NodeMouseClick="{ApplicationFormController.OpenNotecard}">
and is routed to the application controller:
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
{
IDockContent content = documentControllerMap.Single(t=>((NotecardController)t.Value).NotecardRecord==rec).Key;
content.DockHandler.Show();
}
}
}
Lastly (at least for this prototype implementation) we want the user to be
able to add actual content.
Since the notecard is based on a web browser, it would certainly make sense to
use an HTML editor, and I found a decent one
here. Note that this is not intended to edit existing web pages - this
for creating your own simple content (you do not want to use this
for editing web pages from the Internet, among other things, all of the
stylesheet information is lost.)
The HTML editor is added as a hidden control in the notepad view markup:
<editor:HtmlEditorControl def:Name="htmlEditor" Dock="Fill" Visible="false"/>
<wk:WebKitBrowser def:Name="browser" Dock="Fill" .../>
and a right-click context menu option is added:
<wf:ToolStripMenuItem def:Name="editHtml" Text="&Edit Document" Click="{controller.EditHtml}"/>
which the notepad controller handles (this is a bit kludgy right now):
protected void EditHtml(object sender, EventArgs args)
{
if (!editing)
{
editing = true;
View.BeginHtmlEditing();
}
else
{
editing = false;
View.EndHtmlEditing();
NotecardRecord.HTML = View.HtmlEditor.InnerHtml;
}
}
and the view does the rest:
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";
}
Now the custom content simply needs to be handled correctly when we open a
record, which means, instead of calling NavigateToURL, we're going to add the
method ShowDocument and let the controller determine whether to use a URL or the
custom HTML:
public void ShowDocument()
{
if (!(String.IsNullOrEmpty(NotecardRecord.HTML)))
{
View.Browser.DocumentText = NotecardRecord.HTML;
}
else
{
NavigateToURL(NotecardRecord.URL);
}
}
So, now by right-clicking on Edit Notecard:
I can create my own notecards using a snazzy HTML editor:
and I can now cross reference my custom notecards in the same why as with
HTML files or URL's:
At the end of the day (or the week, in this case, as it took about a week to
put this all together) we end up with a usable prototype of my vision of
resurrecting Apple's HyperCard
concept. Maybe I'll be sued! In any case, while this is a sufficiently
usable application at this point, there are still a lot of rough
edges--usability issues that need to be addressed (for example, you have to tell
the program to regenerate the TOC and index from the View / Refresh menu) and
probably lots of strange bugs. But that work will be left for another day.
Also, there's some useful functionality missing, like being able to delete
notecards! You will also note that this is an x86 application because
WebKit runs only in 32 bit mode.
As usual, if you're interested in contributing to this project, please let me
know. Personally, I'm interested in developing this into a viable
commercial product, but the code presented here is available to the community.
The schema is represented as an object graph that instantiates a DataSet:
="1.0" ="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>
Unfortunately, certain properties (DataType) and classes (DataRelation) are
not particularly friendly to declarative instantiation and need some "help":
public static class SchemaHelper
{
public static DataSet CreateSchema()
{
MycroParser mp = new MycroParser();
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)
{
if ((pea.PropertyName == "ChildColumn") || (pea.PropertyName == "ParentColumn"))
{
pea.Handled = true;
}
}
}
This handles the saving and loading of the DataSet to an XML file:
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);
}
}
This is a thin wrapper for the underlying DataRow associated with a notecard
record:
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)
{
}
}
This is a complicated workaround that requires first intercepting the
application-wide right-click message and then posting (not sending) a custom
message to the application to process the event, which in turn needs to make
sure that the right-click is actually occurring on a noteacard. Let's
begin:
At startup, we register a custom window message and our custom message
filter:
[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);
...
The custom message filter looks for right-click events and posts a message to
process the event. The right-click is not filtered, allowing the
application to handle it normally. The reason we post the message is that
we don't want to process it immediately -- we want to give Windows and the
application the opportunity to do whatever it does, which, in our case, it to
set focus to the control where the mouse was clicked (this is done for us
somewhere). Posting a message adds the message at the end of the Windows
message queue, as opposed to SendMessage, which processes the message
immediately if the two windows have the same thread. The message filter:
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) {
PostMessage(Program.MainForm.Handle, Program.RightClickWindowMessage, m.WParam, m.LParam);
}
return false; }
}
Next, we look for our custom right-click message in the application's main
form and fire an event:
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)
{
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);
}
}
}
The event is wired up in the markup:
<ixv:ApplicationFormView
ref:Name="applicationFormView"
DockPanel="{dockPanel}"
Load="{controller.LoadLayout}"
Closing="{controller.Closing}"
RightClick="{controller.RightClick}"/>
and is handled by the application controller, which does nothing more than
request that the active document controller show the context menu.
protected void RightClick(int x, int y)
{
ActiveDocumentController.ShowContextMenu(x, y);
}
This request is passed to the controller's view (I don't expose the View
property to other classes, so we always have to go through this step, because I
don't want controllers to talk to views of other controllers):
public class NotecardController ...
{
...
public void ShowContextMenu(int x, int y)
{
View.ShowContextMenu(new Point(x, y));
}
...
}
And in the view, the coordinate is tested. This requires converting the
client coordinate of the application's active control to a screen coordinate,
then comparing the screen coordinate with the screen coordinate of the notecard
window, which for the moment is rather kludgy (the Parent.Parent.Parent thing):
public void ShowContextMenu(Point p)
{
ApplicationFormView app = (ApplicationFormView)Parent.Parent.Parent;
Control activeCtrl = (Control)((ApplicationFormView)Parent.Parent.Parent).DockPanel.ActiveContent;
Point screenPoint = activeCtrl.PointToScreen(p);
Point viewUpperLeft = PointToScreen(new Point(0, 0));
Rectangle viewRect = new Rectangle(viewUpperLeft, Size);
if (viewRect.Contains(screenPoint))
{
BrowserContextMenu.Show(PointToScreen(p));
}
}
This is quite a bit of work to get the desired behavior, can probably be all
ripped out if I were to use a different browser control, and needs cleanup
because of the hard-coded dependency on the UI object graph. However, it
works, and that's what matters at the moment.
MycroXaml
DockPanelSuite
DockPanelSuite - Decoupling Content From Container
Linq
Extended Joins
WinForms HTML Editor
WebKit.NET
open-webkit-sharp