Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: allow link to section mark #7744

Open
wants to merge 32 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
5b6f5b2
feat: parse and show ^id
linonetwo Sep 15, 2023
4c407c2
refactor: use blockid for shorter name
linonetwo Sep 15, 2023
18236b5
feat: allow add id for code block
linonetwo Sep 15, 2023
d5e9d2a
feat: allow wiki pretty link to have id
linonetwo Sep 15, 2023
b956e72
fix: properly match blockId and pass it to ast
linonetwo Sep 15, 2023
3d8ade3
feat: redirect tm-focus-selector event to check parent or sibling
linonetwo Sep 15, 2023
6b61243
fix: param maybe null
linonetwo Sep 15, 2023
d8bdd09
fix: ensure hightlight is visible
linonetwo Sep 15, 2023
0863916
fix: wait until animation finish and dom show
linonetwo Sep 15, 2023
e6445b7
docs: why add hook
linonetwo Sep 16, 2023
db83401
docs: about usage
linonetwo Sep 16, 2023
7200f73
refactor: use th-navigated to simplify the code
linonetwo Sep 16, 2023
c0b6b79
fix: element not exist
linonetwo Sep 16, 2023
3bcd822
fix: scroll too slow if tiddler already appear
linonetwo Sep 16, 2023
07130c2
fix: code style and types
linonetwo Sep 16, 2023
a5c2f85
feat: allow different tiddler have same block id in the text, and onl…
linonetwo Sep 17, 2023
cff0240
feat: allow using any char in id
linonetwo Sep 17, 2023
fef444c
fix: when id not exist, still navigate to the tiddler
linonetwo Sep 17, 2023
0d18b25
Update blockid.js
linonetwo Sep 18, 2023
dfa0600
docs: about why history change will reflect on storyview
linonetwo Sep 22, 2023
436343c
refactor: use history mechanism for block level navigation
linonetwo Sep 22, 2023
ebf84b5
docs: about HistoryMechanism in dev doc
linonetwo Sep 22, 2023
507d004
feat: adapt for other story views
linonetwo Sep 22, 2023
48d2eff
fix: no need for setTimeout
linonetwo Sep 22, 2023
13f6bd8
refactor: remove unusned hook
linonetwo Sep 22, 2023
13d0c5c
docs: about toAnchor added in 5.3.2
linonetwo Sep 22, 2023
6d16c98
Merge remote-tracking branch 'upstream/master' into feat/section-mark
linonetwo Jun 9, 2024
9c2c6d7
fix: link end position should add ^id 's length
linonetwo Jun 9, 2024
7e9cadf
Revert "fix: link end position should add ^id 's length"
linonetwo Jun 9, 2024
92ca17a
fix: correct anchor start end
linonetwo Jun 9, 2024
cc12af5
docs: fix BlockIdWidget
linonetwo Jun 9, 2024
cb2d4bb
Merge branch 'master' into feat/section-mark
linonetwo Sep 13, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions core/modules/parsers/wikiparser/rules/anchor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*\
title: $:/core/modules/parsers/wikiparser/rules/anchor.js
type: application/javascript
module-type: wikirule

Use hash as a tag for paragraph, we call it anchor.

1. Hash won't change, it can be written by hand or be generated, and it is a ` \^\S+$` string after line: `text ^cb9d485` or `text ^1`, so it can be human readable (while without space), here are the parse rule for this.
2. When creating widgets for rendering, omit this hash, so it's invisible in view mode. But this widget will create an anchor to jump to.

\*/
exports.name = "anchor";
exports.types = {inline: true};

/*
Instantiate parse rule
*/
exports.init = function(parser) {
this.parser = parser;
// Regexp to match the anchor.
// 1. inlineId: located on the end of the line, with a space before it, means it's the id of the current block.
// 2. blockId: located at start of the line, no space, means it's the id of the previous block. Because some block can't have id suffix, otherwise id break the block mode parser like codeblock.
this.matchRegExp = /[ ]\^(\S+)$|^\^(\S+)$/mg;
};

/*
Parse the most recent match
*/
exports.parse = function() {
// Move past the match
this.parser.pos = this.matchRegExp.lastIndex;
// will be one of following case, another will be undefined
var inlineId = this.match[1];
var blockId = this.match[2];
var id = inlineId || blockId || '';
var anchorStart = this.parser.pos;
var anchorEnd = anchorStart + id.length;
// Parse tree nodes to return
return [{
type: "anchor",
attributes: {
id: {type: "string", value: id, start: anchorStart, end: anchorEnd},
// `yes` means the block that this anchor pointing to, is before this node, both anchor and the block, is in a same parent node's children list.
// empty means the block is this node's direct parent node.
previousSibling: {type: "string", value: Boolean(blockId) ? "yes" : ""},
},
children: []
}];
};
14 changes: 11 additions & 3 deletions core/modules/parsers/wikiparser/rules/prettylink.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ exports.types = {inline: true};

exports.init = function(parser) {
this.parser = parser;
// Regexp to match
this.matchRegExp = /\[\[(.*?)(?:\|(.*?))?\]\]/mg;
// Regexp to match `[[Alias|Title^blockId]]`, the `Alias|` and `^blockId` are optional.
this.matchRegExp = /\[\[(.*?)(?:\|(.*?)?)?(?:\^([^|\s^]+)?)?\]\]/mg;
};

exports.parse = function() {
Expand All @@ -34,13 +34,18 @@ exports.parse = function() {
// Process the link
var text = this.match[1],
link = this.match[2] || text,
anchor = this.match[3] || "",
textEndPos = this.parser.source.indexOf("|", start);
if (textEndPos < 0 || textEndPos > this.matchRegExp.lastIndex) {
textEndPos = this.matchRegExp.lastIndex - 2;
}
var linkStart = this.match[2] ? (start + this.match[1].length + 1) : start;
var linkEnd = linkStart + link.length;
if($tw.utils.isLinkExternal(link)) {
// add back the part after `^` to the ext link, if it happen to has one. Here is is not an anchor, but a part of the external URL.
if(anchor) {
link = link + "^" + anchor;
}
return [{
type: "element",
tag: "a",
Expand All @@ -55,10 +60,13 @@ exports.parse = function() {
}]
}];
} else {
var anchorStart = anchor ? (linkEnd + 1) : linkEnd;
var anchorEnd = anchorStart + anchor.length;
return [{
type: "link",
attributes: {
to: {type: "string", value: link, start: linkStart, end: linkEnd}
to: {type: "string", value: link, start: linkStart, end: linkEnd},
toAnchor: {type: "string", value: anchor, start: anchorStart, end: anchorEnd},
},
children: [{
type: "text", text: text, start: start, end: textEndPos
Expand Down
4 changes: 2 additions & 2 deletions core/modules/story.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,12 @@ Story.prototype.saveStoryList = function(storyList) {
));
};

Story.prototype.addToHistory = function(navigateTo,navigateFromClientRect) {
Story.prototype.addToHistory = function(navigateTo,fromPageRect,anchor) {
var titles = $tw.utils.isArray(navigateTo) ? navigateTo : [navigateTo];
// Add a new record to the top of the history stack
var historyList = this.wiki.getTiddlerData(this.historyTitle,[]);
$tw.utils.each(titles,function(title) {
historyList.push({title: title, fromPageRect: navigateFromClientRect});
historyList.push({title: title, fromPageRect: fromPageRect, anchor: anchor});
});
this.wiki.setTiddlerData(this.historyTitle,historyList,{"current-tiddler": titles[titles.length-1]});
};
Expand Down
13 changes: 12 additions & 1 deletion core/modules/storyviews/classic.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,23 @@ ClassicStoryView.prototype.navigateTo = function(historyInfo) {
}
var listItemWidget = this.listWidget.children[listElementIndex],
targetElement = listItemWidget.findFirstDomNode();
// If anchor is provided, find the element the anchor pointing to
var foundAnchor = false;
if(listItemWidget && historyInfo.anchor) {
var anchorWidget = $tw.utils.findChildNodeInTree(listItemWidget, function(widget) {
return widget.anchorId === historyInfo.anchor;
});
if(anchorWidget) {
targetElement = anchorWidget.findAnchorTargetDomNode()
foundAnchor = true;
}
}
// Abandon if the list entry isn't a DOM element (it might be a text node)
if(!targetElement || targetElement.nodeType === Node.TEXT_NODE) {
return;
}
// Scroll the node into view
this.listWidget.dispatchEvent({type: "tm-scroll", target: targetElement});
this.listWidget.dispatchEvent({type: "tm-scroll", target: targetElement, highlight: foundAnchor});
};

ClassicStoryView.prototype.insert = function(widget) {
Expand Down
13 changes: 12 additions & 1 deletion core/modules/storyviews/pop.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,23 @@ PopStoryView.prototype.navigateTo = function(historyInfo) {
}
var listItemWidget = this.listWidget.children[listElementIndex],
targetElement = listItemWidget.findFirstDomNode();
// If anchor is provided, find the element the anchor pointing to
var foundAnchor = false;
if(listItemWidget && historyInfo.anchor) {
var anchorWidget = $tw.utils.findChildNodeInTree(listItemWidget, function(widget) {
return widget.anchorId === historyInfo.anchor;
});
if(anchorWidget) {
targetElement = anchorWidget.findAnchorTargetDomNode()
foundAnchor = true;
}
}
// Abandon if the list entry isn't a DOM element (it might be a text node)
if(!targetElement || targetElement.nodeType === Node.TEXT_NODE) {
return;
}
// Scroll the node into view
this.listWidget.dispatchEvent({type: "tm-scroll", target: targetElement});
this.listWidget.dispatchEvent({type: "tm-scroll", target: targetElement, highlight: foundAnchor});
};

PopStoryView.prototype.insert = function(widget) {
Expand Down
15 changes: 14 additions & 1 deletion core/modules/storyviews/zoomin.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ ZoominListView.prototype.navigateTo = function(historyInfo) {
}
var listItemWidget = this.listWidget.children[listElementIndex],
targetElement = listItemWidget.findFirstDomNode();
// If anchor is provided, find the element the anchor pointing to
var anchorElement = null;
if(listItemWidget && historyInfo.anchor) {
var anchorWidget = $tw.utils.findChildNodeInTree(listItemWidget, function(widget) {
return widget.anchorId === historyInfo.anchor;
});
if(anchorWidget) {
anchorElement = anchorWidget.findAnchorTargetDomNode()
}
}
// Abandon if the list entry isn't a DOM element (it might be a text node)
if(!targetElement) {
return;
Expand Down Expand Up @@ -119,7 +129,10 @@ ZoominListView.prototype.navigateTo = function(historyInfo) {
},duration);
}
// Scroll the target into view
// $tw.pageScroller.scrollIntoView(targetElement);
if(anchorElement) {
this.listWidget.dispatchEvent({type: "tm-scroll", target: anchorElement, highlight: true});
}
// $tw.pageScroller.scrollIntoView(targetElement);
};

/*
Expand Down
17 changes: 16 additions & 1 deletion core/modules/utils/dom/scroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ Handle an event
*/
PageScroller.prototype.handleEvent = function(event) {
if(event.type === "tm-scroll") {
var options = {};
var options = {
highlight: event.highlight,
};
if($tw.utils.hop(event.paramObject,"animationDuration")) {
options.animationDuration = event.paramObject.animationDuration;
}
Expand All @@ -65,14 +67,21 @@ PageScroller.prototype.handleEvent = function(event) {

/*
Handle a scroll event hitting the page document

options:
- animationDuration: total time of scroll animation
- highlight: highlight the element after scrolling, to make it evident. Usually to focus an anchor in the middle of the tiddler.
*/
PageScroller.prototype.scrollIntoView = function(element,callback,options) {
var self = this,
duration = $tw.utils.hop(options,"animationDuration") ? parseInt(options.animationDuration) : $tw.utils.getAnimationDuration(),
highlight = options.highlight || false,
srcWindow = element ? element.ownerDocument.defaultView : window;
// Now get ready to scroll the body
this.cancelScroll(srcWindow);
this.startTime = Date.now();
// toggle class to allow trigger the highlight animation
$tw.utils.removeClass(element,"tc-focus-highlight");
// Get the height of any position:fixed toolbars
var toolbar = srcWindow.document.querySelector(".tc-adjust-top-of-scroll"),
offset = 0;
Expand Down Expand Up @@ -121,6 +130,12 @@ PageScroller.prototype.scrollIntoView = function(element,callback,options) {
srcWindow.scrollTo(scrollPosition.x + (endX - scrollPosition.x) * t,scrollPosition.y + (endY - scrollPosition.y) * t);
if(t < 1) {
self.idRequestFrame = self.requestAnimationFrame.call(srcWindow,drawFrame);
} else {
// the animation is end.
if(highlight) {
element.focus({ focusVisible: true });
$tw.utils.addClass(element,"tc-focus-highlight");
}
}
};
drawFrame();
Expand Down
15 changes: 15 additions & 0 deletions core/modules/utils/parsetree.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,21 @@ exports.findParseTreeNode = function(nodeArray,search) {
return undefined;
};

exports.findChildNodeInTree = function(root,searchFn) {
if(searchFn(root)) {
return root;
}
if(root.children && root.children.length > 0) {
for(var i=0; i<root.children.length; i++) {
var result = exports.findChildNodeInTree(root.children[i], searchFn);
if(result) {
return result;
}
}
}
return undefined;
};

/*
Helper to get the text of a parse tree node or array of nodes
*/
Expand Down
76 changes: 76 additions & 0 deletions core/modules/widgets/anchor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*\
title: $:/core/modules/widgets/anchor.js
type: application/javascript
module-type: widget

An invisible element with anchor id metadata.
\*/
var Widget = require("$:/core/modules/widgets/widget.js").widget;
var AnchorWidget = function(parseTreeNode,options) {
this.initialise(parseTreeNode,options);
// only this widget knows target info (if the block is before this node or not), so we need to hook the focus event, and process it here, instead of in the root widget.
};
AnchorWidget.prototype = new Widget();

AnchorWidget.prototype.render = function(parent,nextSibling) {
// Save the parent dom node
this.parentDomNode = parent;
// Compute our attributes
this.computeAttributes();
// Execute our logic
this.execute();
// Create an invisible DOM element with data that can be accessed from JS or CSS
this.idNode = this.document.createElement("span");
this.idNode.setAttribute("data-anchor-id",this.anchorId);
this.idNode.setAttribute("data-anchor-title",this.tiddlerTitle);
// if the actual block is before this node, we need to add a flag to the node
if(this.previousSibling) {
this.idNode.setAttribute("data-anchor-previous-sibling","true");
}
this.idNode.className = "tc-anchor";
parent.insertBefore(this.idNode,nextSibling);
this.domNodes.push(this.idNode);
};

/*
Compute the internal state of the widget
*/
AnchorWidget.prototype.execute = function() {
// Get the id from the parse tree node or manually assigned attributes
this.anchorId = this.getAttribute("id");
this.tiddlerTitle = this.getVariable("currentTiddler");
this.previousSibling = this.getAttribute("previousSibling") === "yes";
// Make the child widgets
this.makeChildWidgets();
};

/*
Find the DOM node pointed by this anchor
*/
Widget.prototype.findAnchorTargetDomNode = function() {
if(!this.idNode) {
return null;
}
// the actual block is always at the parent level
targetElement = this.idNode.parentNode;
// need to check if the block is before this node
if(this.previousSibling) {
targetElement = targetElement.previousSibling;
}
return targetElement;
};

/*
Selectively refreshes the widget if needed. Returns true if the widget or any of its children needed re-rendering
*/
AnchorWidget.prototype.refresh = function(changedTiddlers) {
var changedAttributes = this.computeAttributes();
if(($tw.utils.count(changedAttributes) > 0)) {
this.refreshSelf();
return true;
} else {
return this.refreshChildren(changedTiddlers);
}
};

exports.anchor = AnchorWidget;
2 changes: 2 additions & 0 deletions core/modules/widgets/link.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ LinkWidget.prototype.handleClickEvent = function(event) {
this.dispatchEvent({
type: "tm-navigate",
navigateTo: this.to,
toAnchor: this.toAnchor,
navigateFromTitle: this.getVariable("storyTiddler"),
navigateFromNode: this,
navigateFromClientRect: { top: bounds.top, left: bounds.left, width: bounds.width, right: bounds.right, bottom: bounds.bottom, height: bounds.height
Expand Down Expand Up @@ -190,6 +191,7 @@ Compute the internal state of the widget
LinkWidget.prototype.execute = function() {
// Pick up our attributes
this.to = this.getAttribute("to",this.getVariable("currentTiddler"));
this.toAnchor = this.getAttribute("toAnchor");
this.tooltip = this.getAttribute("tooltip");
this["aria-label"] = this.getAttribute("aria-label");
this.linkClasses = this.getAttribute("class");
Expand Down
7 changes: 4 additions & 3 deletions core/modules/widgets/navigator.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,10 @@ NavigatorWidget.prototype.addToStory = function(title,fromTitle) {
Add a new record to the top of the history stack
title: a title string or an array of title strings
fromPageRect: page coordinates of the origin of the navigation
anchor:optional anchor id in this tiddler
*/
NavigatorWidget.prototype.addToHistory = function(title,fromPageRect) {
this.story.addToHistory(title,fromPageRect,this.historyTitle);
NavigatorWidget.prototype.addToHistory = function(title,fromPageRect,anchor) {
this.story.addToHistory(title,fromPageRect,anchor);
};

/*
Expand All @@ -151,7 +152,7 @@ NavigatorWidget.prototype.handleNavigateEvent = function(event) {
if(event.navigateTo) {
this.addToStory(event.navigateTo,event.navigateFromTitle);
if(!event.navigateSuppressNavigation) {
this.addToHistory(event.navigateTo,event.navigateFromClientRect);
this.addToHistory(event.navigateTo,event.navigateFromClientRect,event.toAnchor);
}
}
return false;
Expand Down
2 changes: 1 addition & 1 deletion core/ui/EditTemplate/title.tid
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ tags: $:/tags/EditTemplate
\whitespace trim
<$edit-text field="draft.title" class="tc-titlebar tc-edit-texteditor" focus={{{ [{$:/config/AutoFocus}match[title]then[true]] ~[[false]] }}} tabindex={{$:/config/EditTabIndex}} cancelPopups="yes"/>

<$vars pattern="""[\|\[\]{}]""" bad-chars="""`| [ ] { }`""">
<$vars pattern="""[\^\|\[\]{}]""" bad-chars="""`| ^ [ ] { }`""">

<$list filter="[all[current]regexp:draft.title<pattern>]" variable="listItem">

Expand Down
8 changes: 8 additions & 0 deletions editions/dev/tiddlers/mechanism/HistoryMechanism.tid
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
created: 20230922141042399
modified: 20230922141423022
tags:
title: HistoryMechanism

The story view is created by [[$:/core/ui/PageTemplate/story]] core page template, which uses list widget to render tiddlers. In this way, page template will reflect to history's change.

List widget has a `history="$:/HistoryList"` parameter, that will be used in list widget's `handleHistoryChanges` method, and pass to the `this.storyview.navigateTo`, you can read [[storyview module]] for how storyview use the changed history.
Loading
Loading