Index: src/fossil.bootstrap.js
==================================================================
--- src/fossil.bootstrap.js
+++ src/fossil.bootstrap.js
@@ -280,19 +280,21 @@
}
return this;
};
/**
- Sets the innerText of the page's TITLE tag to
- the given text and returns this object.
+ Sets the innerText of the page's TITLE tag to the given text and
+ returns this object. If passed a falsy value then the title is
+ reverted to its page-load-time value.
*/
- F.page.setPageTitle = function(title){
+ F.page.setPageTitle = function f(title){
const t = document.querySelector('title');
- if(t) t.innerText = title;
+ if(t) t.innerText = title || f.$orig;
return this;
};
-
+ F.onPageLoad(()=>F.page.setPageTitle.$orig
+ = document.querySelector('title').innerText);
/**
Returns a function, that, as long as it continues to be invoked,
will not be triggered. The function will be called after it stops
being called for N milliseconds. If `immediate` is passed, call
the callback immediately and hinder future invocations until at
Index: src/fossil.page.chat.js
==================================================================
--- src/fossil.page.chat.js
+++ src/fossil.page.chat.js
@@ -86,15 +86,14 @@
While we're here, we also use this to cap the max-height
of the input field so that pasting huge text does not scroll
the upper area of the input widget off-screen. */
const elemsToCount = GetFramingElements();
const contentArea = E1('div.content');
- const bcl = document.body.classList;
const resized = function f(){
if(f.$disabled) return;
const wh = window.innerHeight,
- com = bcl.contains('chat-only-mode');
+ com = document.body.classList.contains('chat-only-mode');
var ht;
var extra = 0;
if(com){
ht = wh;
}else{
@@ -151,11 +150,12 @@
viewSearch: E1('#chat-search'),
searchContent: E1('#chat-search-content'),
btnPreview: E1('#chat-button-preview'),
views: document.querySelectorAll('.chat-view'),
activeUserListWrapper: E1('#chat-user-list-wrapper'),
- activeUserList: E1('#chat-user-list')
+ activeUserList: E1('#chat-user-list'),
+ btnClearFilter: E1('#chat-clear-filter')
},
me: F.user.name,
mxMsg: F.config.chat.initSize ? -F.config.chat.initSize : -50,
mnMsg: undefined/*lowest message ID we've seen so far (for history loading)*/,
pageIsActive: 'visible'===document.visibilityState,
@@ -170,15 +170,31 @@
(JS Date object). Only messages received by the chat client
are considered. */
/* Reminder: to convert a Julian time J to JS:
new Date((J - 2440587.5) * 86400000) */
},
- filterState:{
- activeUser: undefined,
- match: function(uname){
- return this.activeUser===uname || !this.activeUser;
- }
+ filter: {
+ user:{
+ activeTag: undefined,
+ match: function(uname){
+ return !this.activeTag || this.activeTag===uname;
+ },
+ matchElem: function(e){
+ return !this.activeTag || this.activeTag===e.dataset.xfrom;
+ }
+ },
+ hashtag:{
+ activeTag: undefined,
+ match: function(tag){
+ return !this.activeTag || tag===this.activeTag;
+ },
+ matchElem: function(e){
+ return !this.activeTag
+ || !!e.querySelector('[data-hashtag="'+this.activeTag+'"]');
+ }
+ },
+ current: undefined/*gets set to current active filter*/
},
/**
Gets (no args) or sets (1 arg) the current input text field
value, taking into account single- vs multi-line input. The
getter returns a trim()'d string and the setter returns this
@@ -270,11 +286,12 @@
the list. */
injectMessageElem: function f(e, atEnd){
const mip = atEnd ? this.e.loadOlderToolbar : this.e.messageInjectPoint,
holder = this.e.viewMessages,
prevMessage = this.e.newestMessage;
- if(!this.filterState.match(e.dataset.xfrom)){
+ if(this.filter.current
+ && !this.filter.current.matchElem(e)){
e.classList.add('hidden');
}
if(atEnd){
const fe = mip.nextElementSibling;
if(fe) mip.parentNode.insertBefore(e, fe);
@@ -492,11 +509,10 @@
}
this.e.views.forEach(function(E){
if(e!==E) D.addClass(E,'hidden');
});
this.e.currentView = e;
- if(this.e.currentView.$beforeShow) this.e.currentView.$beforeShow();
D.removeClass(e,'hidden');
this.animate(this.e.currentView, 'anim-fade-in-fast');
return this.e.currentView;
},
/**
@@ -521,11 +537,11 @@
else return 0;
};
callee.addUserElem = function(u){
const uSpan = D.addClass(D.span(), 'chat-user');
const uDate = self.usersLastSeen[u];
- if(self.filterState.activeUser===u){
+ if(self.filter.user.activeTag===u){
uSpan.classList.add('selected');
}
uSpan.dataset.uname = u;
D.append(uSpan, u, "\n",
D.append(
@@ -543,11 +559,82 @@
Object.keys(this.usersLastSeen).sort(
callee.sortUsersSeen
).forEach(callee.addUserElem);
return this;
},
- /** Show or hide the active user list. Returns this object. */
+ /**
+ For each Chat.MessageWidget element (X.message-widget) for
+ which predicate(elem) returns true, the 'hidden' class is
+ removed from that message. For all others, 'hidden' is
+ added. If predicate is falsy, 'hidden' is removed from all
+ elements. After filtering, it will try to scroll the last
+ not-filtered-out message into view, but exactly where it
+ scrolls into view (top, middle, button) is
+ unpredictable. Returns this object.
+
+ The argument may optionally be an object from this.filter,
+ in which case its matchElem() method becomes the predicate.
+
+ Note that this does not encapsulate certain filter-specific
+ logic which applies changes to elements other than the
+ main message list or this.e.btnClearFilter.
+ */
+ applyMessageFilter: function(predicate){
+ const self = this;
+ let eLast;
+ console.debug("applyMessageFilter(",predicate,")");
+ if(!predicate){
+ D.removeClass(this.e.viewMessages.querySelectorAll('.message-widget.hidden'),
+ 'hidden');
+ D.addClass(this.e.btnClearFilter, 'hidden');
+ }else if('function'!==typeof predicate
+ && predicate.matchElem){
+ /* assume Chat.filter object */
+ const p = predicate;
+ predicate = (e)=>p.matchElem(e);
+ }
+ if(predicate){
+ this.e.viewMessages.querySelectorAll('.message-widget').forEach(function(e){
+ if(predicate(e)){
+ e.classList.remove('hidden');
+ eLast = e;
+ }else{
+ e.classList.add('hidden');
+ }
+ });
+ D.removeClass(this.e.btnClearFilter, 'hidden');
+ }
+ this.setCurrentView(this.e.viewMessages);
+ if(eLast) eLast.scrollIntoView(false);
+ else this.scrollMessagesTo(1);
+ return this;
+ },
+ /**
+ Clears the current message filter, if any, and clears the
+ activeTag property of all members of this.filter. Returns
+ this object. This also unfortunately performs some
+ filter-type-specific logic which we have not yet managed to
+ encapsulate more cleanly.
+ */
+ clearFilters: function(){
+ if(!this.filter.current) return this;
+ this.filter.current = undefined;
+ this.applyMessageFilter(false);
+ const self = this;
+ Object.keys(this.filter).forEach(function(k){
+ const f = self.filter[k];
+ if(f) f.activeTag = undefined;
+ });
+ this.e.activeUserList.querySelectorAll('.chat-user').forEach(
+ /*Unfortante filter-specific logic*/
+ (e)=>e.classList.remove('selected')
+ );
+ return this;
+ },
+ /**
+ Show or hide the active user list. Returns this object.
+ */
showActiveUserList: function(yes){
if(0===arguments.length) yes = true;
this.e.activeUserListWrapper.classList[
yes ? 'remove' : 'add'
]('hidden');
@@ -573,32 +660,38 @@
/**
Applies user name filter to all current messages, or clears
the filter if uname is falsy.
*/
setUserFilter: function(uname){
- this.filterState.activeUser = uname;
- const mw = this.e.viewMessages.querySelectorAll('.message-widget');
+ if(!uname || (this.filter.current
+ && this.filter.current!==this.filter.user)){
+ this.clearFilters();
+ }
+ this.filter.user.activeTag = uname;
+ if(uname) this.applyMessageFilter(this.filter.user);
+ this.filter.current = uname ? this.filter.user : undefined;
const self = this;
- let eLast;
- if(!uname){
- D.removeClass(Chat.e.viewMessages.querySelectorAll('.message-widget.hidden'),
- 'hidden');
- }else{
- mw.forEach(function(w){
- if(self.filterState.match(w.dataset.xfrom)){
- w.classList.remove('hidden');
- eLast = w;
- }else{
- w.classList.add('hidden');
- }
- });
- }
- if(eLast) eLast.scrollIntoView(false);
- else this.scrollMessagesTo(1);
- cs.e.activeUserList.querySelectorAll('.chat-user').forEach(function(e){
- e.classList[uname===e.dataset.uname ? 'add' : 'remove']('selected');
- });
+ this.e.activeUserList.querySelectorAll('.chat-user').forEach(function(e){
+ e.classList[
+ self.filter.user.activeTag===e.dataset.uname
+ ? 'add' : 'remove'
+ ]('selected');
+ });
+ return this;
+ },
+ /**
+ Applies a hashtag filter to all current messages, or clears
+ the filter if tag is falsy.
+ */
+ setHashtagFilter: function(tag){
+ if(!tag || (this.filter.current
+ && this.filter.current!==this.filter.hashtag)){
+ this.clearFilters();
+ }
+ this.filter.hashtag.activeTag = tag;
+ if(tag) this.applyMessageFilter(this.filter.hashtag);
+ this.filter.current = tag ? this.filter.hashtag : undefined;
return this;
},
/**
If animations are enabled, passes its arguments
@@ -875,22 +968,106 @@
cs.setCurrentView(cs.e.viewMessages);
if(eUser.classList.contains('selected')){
/* If curently selected, toggle filter off */
eUser.classList.remove('selected');
cs.setUserFilter(false);
- delete f.$eSelected;
}else{
- if(f.$eSelected) f.$eSelected.classList.remove('selected');
- f.$eSelected = eUser;
eUser.classList.add('selected');
cs.setUserFilter(uname);
}
return false;
}, false);
+
+ cs.e.btnClearFilter.addEventListener('click',function(){
+ D.addClass(this,'hidden');
+ cs.clearFilters();
+ }, false);
return cs;
})()/*Chat initialization*/;
+ /**
+ An experiment in history navigation: when a message numtag is
+ clicked, we push the origin message onto the history and
+ set up the back button to return to that message.
+ */
+ window.onpopstate = function(event){
+ const msgid = Chat.numtagHistoryStack.pop();
+ if(msgid){
+ const e = Chat.setCurrentView(Chat.e.viewMessages).
+ querySelector('.message-widget[data-msgid="'+msgid+'"]');
+ //console.debug("Popping history back to",msgid, e);
+ if(e){
+ Chat.MessageWidget.scrollToMessageElem(e);
+ return;
+ }
+ }
+ Chat.scrollMessagesTo(1);
+ };
+ Chat.numtagHistoryStack = [
+ /* Relying on the pushHistory() state object for holding
+ the message ID is completely misbehaving, not giving
+ us the expected state object when window.onpopstate
+ is triggered (plus, the browser persists it, which
+ introduces its own problems). Thus we use our own
+ stack of message IDs for history navigation purposes. */];
+
+ /** If e or one of its parents has the given CSS class, that element
+ is returned, else falsy is returned. */
+ const findParentWithClass = function(e, className){
+ while(e && !e.classList.contains(className)){
+ e = e.parentNode;
+ }
+ return e;
+ };
+
+ /** To be passed each MessageWidget's top-level DOM element
+ after initial processing of the message, to set up
+ hashtag and numtag references. */
+ const setupHashtags = function f(elem){
+ if(!f.$clickTag){
+ f.$clickTag = function(ev){
+ /* Click handler for hashtags */
+ const tag = ev.target.dataset.hashtag;
+ if(tag){
+ Chat.setHashtagFilter(
+ tag===Chat.filter.hashtag.activeTag
+ ? false : tag
+ );
+ }
+ };
+ f.$clickNum = function(ev){
+ /* Click handler for #NNN references */
+ const tag = ev.target.dataset.numtag;
+ if(tag){
+ const e = Chat.e.viewMessages.querySelector(
+ '.message-widget[data-msgid="'+tag+'"]'
+ );
+ if(e){
+ Chat.MessageWidget.scrollToMessageElem(e);
+ //Set up window.history() state...
+ const p = 0 ? false : findParentWithClass(ev.target, 'message-widget');
+ if(p){
+ const state = {msgId: p.dataset.msgid};
+ Chat.numtagHistoryStack.push(p.dataset.msgid);
+ const rc = window.history.pushState(state, "");
+ //console.debug("Pushing history for msgid", state);
+ //console.debug("Chat.numtagHistoryStack =",Chat.numtagHistoryStack);
+ }
+ }else{
+ Chat.submitSearch('#'+tag);
+ }
+ }
+ };
+ }
+ elem.querySelectorAll('[data-hashtag]').forEach(function(e){
+ e.dataset.hashtag = e.dataset.hashtag.toLowerCase();
+ e.addEventListener('click', f.$clickTag, false);
+ })
+ elem.querySelectorAll('[data-numtag]').forEach(
+ (e)=>e.addEventListener('click', f.$clickNum, false)
+ )
+ }/*setupHashtags()*/;
/** Returns the first .message-widget element in DOM element
e's lineage. */
const findMessageWidgetParent = function(e){
while( e && !e.classList.contains('message-widget')){
@@ -1155,10 +1332,11 @@
// Used by Chat.reportErrorAsMessage()
D.append(contentTarget, m.xmsg);
}else{
contentTarget.innerHTML = m.xmsg;
contentTarget.querySelectorAll('a').forEach(addAnchorTargetBlank);
+ setupHashtags(contentTarget);
if(F.pikchr){
F.pikchr.addSrcView(contentTarget.querySelectorAll('svg.pikchr'));
}
}
}
@@ -1260,31 +1438,23 @@
y: 'a'
}), "User's Timeline"),
'target', '_blank'
);
D.append(toolbar2, timelineLink);
- if(Chat.filterState.activeUser &&
- Chat.filterState.match(eMsg.dataset.xfrom)){
- /* Add a button to clear user filter and jump to
+ if(Chat.filter.current){
+ /* Add a button to clear filter and jump to
this message in its original context. */
D.append(
this.e,
D.append(
D.addClass(D.div(), 'toolbar'),
D.button(
"Message in context",
function(){
self.hide();
- Chat.setUserFilter(false);
- eMsg.scrollIntoView(false);
- Chat.animate(
- eMsg.firstElementChild, 'anim-flip-h'
- //eMsg.firstElementChild, 'anim-flip-v'
- //eMsg.childNodes, 'anim-rotate-360'
- //eMsg.childNodes, 'anim-flip-v'
- //eMsg, 'anim-flip-v'
- );
+ Chat.clearFilters();
+ Chat.MessageWidget.scrollToMessageElem(eMsg);
})
)
);
}/*jump-to button*/
}
@@ -1309,10 +1479,20 @@
}/*f.popup*/;
}/*end static init*/
const theMsg = findMessageWidgetParent(ev.target);
if(theMsg) f.popup.show(theMsg);
}/*_handleLegendClicked()*/
+ }/*MessageWidget.prototype*/;
+ /** Assumes that e is a MessageWidget element, ensures that
+ Chat.e.viewMessages is visible, scrolls the message,
+ and animates it a bit to make it more visible. */
+ ctor.scrollToMessageElem = function(e){
+ if(e.firstElementChild){
+ Chat.setCurrentView(Chat.e.viewMessages);
+ e.scrollIntoView(false);
+ Chat.animate(e, 'anim-fade-out-in');
+ }
};
return ctor;
})()/*MessageWidget*/;
/**
@@ -1815,14 +1995,13 @@
if(!Chat.e.activeUserListWrapper.classList.contains('collapsed')){
Chat.animate(optAu.theList,'anim-flip-v');
}
}, false);
}/*namedOptions.activeUsers additional setup*/
- /* Settings menu entries... the most frequently-needed ones "should"
- (arguably) be closer to the start of this list. */
/**
- Settings ops structure:
+ Settings options structure: an array of Objects with the
+ following properties:
label: string for the UI
boolValue: string (name of Chat.settings setting) or a function
which returns true or false. If it is a string, it gets
@@ -1831,10 +2010,11 @@
to the persistentSetting property of this object.
select: SELECT element (instead of boolValue)
callback: optional handler to call after setting is modified.
+ It gets passed the setting object: {key:string, value:something}.
Its "this" is the options object. If this object has a
boolValue string or a persistentSetting property, the argument
passed to the callback is a settings object in the form {key:K,
value:V}. If this object does not have boolValue string or
persistentSetting then the callback is passed an event object
@@ -2157,10 +2337,11 @@
const btnPreview = Chat.e.btnPreview;
Chat.setPreviewText = function(t){
this.setCurrentView(this.e.viewPreview);
this.e.previewContent.innerHTML = t;
this.e.viewPreview.querySelectorAll('a').forEach(addAnchorTargetBlank);
+ setupHashtags(this.e.previewContent)/*arguable, for usability reasons*/;
this.inputFocus();
};
Chat.e.viewPreview.querySelector('button.action-close').
addEventListener('click', ()=>Chat.setCurrentView(Chat.e.viewMessages), false);
let previewPending = false;
@@ -2388,15 +2569,16 @@
}
return e;
};
Chat.clearSearch(true);
/**
- Submits a history search using the main input field's current
- text. It is assumed that Chat.e.viewSearch===Chat.e.currentView.
+ Submits a history search using either its argument or the the
+ main input field's current text.
*/
- Chat.submitSearch = function(){
- const term = this.inputValue(true);
+ Chat.submitSearch = function(term){
+ Chat.setCurrentView(Chat.e.viewSearch);
+ if(!arguments.length) term = this.inputValue(true);
const eMsgTgt = this.clearSearch(true);
if( !term ) return;
D.append( eMsgTgt, "Searching for ",term," ...");
const fd = new FormData();
fd.set('q', term);
Index: src/markdown.c
==================================================================
--- src/markdown.c
+++ src/markdown.c
@@ -39,10 +39,16 @@
MKDA_NOT_AUTOLINK, /* used internally when it is not an autolink*/
MKDA_NORMAL, /* normal http/http/ftp link */
MKDA_EXPLICIT_EMAIL, /* e-mail link with explicit mailto: */
MKDA_IMPLICIT_EMAIL /* e-mail link without mailto: */
};
+
+/* mkd_tagspan -- type of tagged */
+enum mkd_tagspan {
+ MKDT_HASHTAG, /* #hashtags */
+ MKDT_NUMTAG /* #123[.456] /chat or /forum message IDs. */
+};
/* mkd_renderer -- functions for rendering parsed data */
struct mkd_renderer {
/* document level callbacks */
void (*prolog)(struct Blob *ob, void *opaque);
@@ -80,10 +86,12 @@
struct Blob *alt, void *opaque);
int (*linebreak)(struct Blob *ob, void *opaque);
int (*link)(struct Blob *ob, struct Blob *link, struct Blob *title,
struct Blob *content, void *opaque);
int (*raw_html_tag)(struct Blob *ob, struct Blob *tag, void *opaque);
+ int (*tagspan)(struct Blob *ob, struct Blob *ref, enum mkd_tagspan type,
+ void *opaque);
int (*triple_emphasis)(struct Blob *ob, struct Blob *text,
char c, void *opaque);
int (*footnote_ref)(struct Blob *ob, const struct Blob *span,
const struct Blob *upc, int index, int locus, void *opaque);
@@ -950,10 +958,163 @@
}else{
blob_append(ob, data, end);
}
return end;
}
+
+/* char_hashref_tag -- '#' followed by "word" characters to tag
+** post numbers, hashtags, etc.
+**
+** Basic syntax:
+**
+** ^[a-zA-Z]X*
+**
+** Where X is:
+**
+** - Any number of alphanumeric characters.
+**
+** - Single underscores. Adjacent underscores are not recognized
+** as valid hashtags. That decision is somewhat arbitrary
+** and up for debate.
+**
+** Hashtags must end at the end of input or be followed by whitespace
+** or what appears to be the end or separator of a logical
+** natural-language construct, e.g. period, colon, etc.
+**
+** Current limitations of this implementation:
+**
+** - Currently requires starting alpha and trailing
+** alphanumeric or underscores. "Should" be extended to
+** handle #X[.Y], where X and optional Y are integer
+** values, for forum post references.
+*/
+static size_t char_hashref_tag(
+ struct Blob *ob,
+ struct render *rndr,
+ char *data,
+ size_t offset,
+ size_t size
+){
+ size_t end;
+ struct Blob work = BLOB_INITIALIZER;
+ int nUscore = 0; /* Consecutive underscore counter */
+ int numberMode = 0 /* 0 for normal, 1 for #NNN numeric,
+ and 2 for #NNN.NNN. */;
+ if(offset>0 && !fossil_isspace(data[-1])){
+ /* Only ever match if the *previous* character is whitespace or
+ ** we're at the start of the input. Note that we rely on fossil
+ ** processing emphasis markup before reaching this function, so
+ ** *#Hash* will Do The Right Thing. Not that this means that
+ ** "#Hash." will match while ".#Hash" won't. That's okay. */
+ return 0;
+ }
+ assert( '#' == data[0] );
+ if(size < 2) return 0;
+ end = 2;
+ if(fossil_isdigit(data[1])){
+ numberMode = 1;
+ }else if(!fossil_isalpha(data[1])){
+ switch(data[1] & 0xF0){
+ /* Reminder: UTF8 char lengths can be determined by
+ ** masking against 0xF0: 0xf0==4, 0xe0==3, 0xc0==2,
+ ** else 1. */
+ case 0xF0: end+=3; break;
+ case 0xE0: end+=2; break;
+ case 0xC0: end+=1; break;
+ default: return 0;
+ }
+ }
+#if 0
+ fprintf(stderr,"HASHREF offset=%d size=%d: %.*s\n",
+ (int)offset, (int)size, (int)size, data);
+#endif
+#define HASHTAG_LEGAL_END \
+ case ' ': case '\t': case '\r': case '\n': \
+ case ':': case ';': case '!': case '?': case ','
+ /* ^^^^ '.' is handled separately */
+ for(; end < size; ++end){
+ char ch = data[end];
+ switch(ch & 0xF0){
+ case 0xF0: end+=3; continue;
+ case 0xE0: end+=2; continue;
+ case 0xC0: end+=1; continue;
+ case 0x80: goto hashref_bailout /*invalid UTF8*/;
+ default: break;
+ }
+#if 0
+ fprintf(stderr,"hashtag? checking... length=%d: %.*s\n",
+ (int)end, (int)end, data);
+#endif
+ switch(ch){
+ case '_':
+ /* Multiple adjacent underscores not permitted. */
+ if(++nUscore>1) goto hashref_bailout;
+ numberMode = 0;
+ break;
+ case '.':
+ if(1==numberMode) ++numberMode;
+ ch = 0;
+ break;
+ HASHTAG_LEGAL_END:
+ ch = 0;
+ break;
+ case '0': case '1': case '2': case '3': case '4':
+ case '5': case '6': case '7': case '8': case '9':
+ nUscore = 0;
+ break;
+ default:
+ if(numberMode || !fossil_isalpha(ch)){
+ goto hashref_bailout;
+ }
+ nUscore = 0;
+ break;
+ }
+ if(ch) continue;
+ break;
+ }
+ if((end<3/* #. or some such */ && !numberMode)
+ || end>size/*from truncated multi-byte char*/){
+ return 0;
+ }
+ if(numberMode>1){
+ /* Check for trailing part of #NNN.nnn... */
+ assert('.'==data[end]);
+ if(endmake.tagspan(ob, &work,
+ numberMode ? MKDT_NUMTAG : MKDT_HASHTAG,
+ rndr->make.opaque);
+ return end;
+ hashref_bailout:
+#if 0
+ fprintf(stderr,"BAILING HASHREF examined=%d:\n[%.*s] of\n[%.*s]\n",
+ (int)end, (int)end, data, (int)size, data);
+#endif
+#undef HASHTAG_LEGAL_END
+ return 0;
+}
/*
** char_langle_tag -- '<' when tags or autolinks are allowed.
*/
static size_t char_langle_tag(
@@ -2686,10 +2847,11 @@
}
}
if( rndr.make.codespan ) rndr.active_char['`'] = char_codespan;
if( rndr.make.linebreak ) rndr.active_char['\n'] = char_linebreak;
if( rndr.make.image || rndr.make.link ) rndr.active_char['['] = char_link;
+ rndr.active_char['#'] = char_hashref_tag;
if( rndr.make.footnote_ref ) rndr.active_char['('] = char_footnote;
rndr.active_char['<'] = char_langle_tag;
rndr.active_char['\\'] = char_escape;
rndr.active_char['&'] = char_entity;
Index: src/markdown_html.c
==================================================================
--- src/markdown_html.c
+++ src/markdown_html.c
@@ -827,10 +827,42 @@
blob_appendb(ob, content);
}
blob_append(ob, zClose, -1);
return 1;
}
+
+/* Invoked for @name and #tag tagged words, marked up in the
+** output text in a way that JS and CSS can do something
+** interesting with them. This isn't standard Markdown, so
+** it's implementation-specific what occurs here. More, each
+** Fossil feature using Markdown is free to apply styling and
+** behavior to these in feature-specific ways.
+*/
+static int html_tagspan(
+ struct Blob *ob, /* Write the output here */
+ struct Blob *text, /* The word after the tag character */
+ enum mkd_tagspan type, /* Which type of tagspan we're creating */
+ void *opaque
+){
+ if( text==0 ){
+ /* no-op */
+ }else{
+ char cPrefix = '!';
+ blob_append_literal(ob, "%c%b", cPrefix,text);
+ }
+ return 1;
+}
static int html_triple_emphasis(
struct Blob *ob,
struct Blob *text,
char c,
@@ -885,10 +917,11 @@
html_emphasis,
html_image,
html_linebreak,
html_link,
html_raw_html_tag,
+ html_tagspan,
html_triple_emphasis,
html_footnote_ref,
/* low level elements */
0, /* entity */
Index: src/style.chat.css
==================================================================
--- src/style.chat.css
+++ src/style.chat.css
@@ -623,10 +623,21 @@
}
body.chat #chat-user-list .chat-user.selected {
font-weight: bold;
text-decoration: underline;
}
+
+body.chat span[data-hashtag],
+body.chat span[data-numtag]{
+ font-family: monospace;
+ text-decoration: underline;
+ cursor: pointer;
+}
+
+body.chat #chat-clear-filter {
+ margin: 0.25em 0.5em;
+}
body.chat .searchForm {
margin-top: 1em;
}
body.chat .spacer-widget button {
@@ -660,19 +671,26 @@
}
body.chat .anim-fade-in {
animation: fade-in 750ms linear;
}
body.chat .anim-fade-in-fast {
- animation: fade-in 350ms linear;
+ animation: fade-in 300ms linear;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
body.chat .anim-fade-out-fast {
- animation: fade-out 250ms linear;
+ animation: fade-out 300ms linear;
}
@keyframes fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
+body.chat .anim-fade-out-in {
+ animation: fade-out-in 1000ms linear;
+}
+@keyframes fade-out-in {
+ 0%,100% { opacity: 0 }
+ 50% { opacity: 1 }
+}
Index: src/wikiformat.c
==================================================================
--- src/wikiformat.c
+++ src/wikiformat.c
@@ -1901,13 +1901,15 @@
}
/*
** COMMAND: test-markdown-render
**
-** Usage: %fossil test-markdown-render FILE ...
+** Usage: %fossil test-markdown-render TEXT ...
**
-** Render markdown in FILE as HTML on stdout.
+** Render markdown in TEXT as HTML on stdout, where TEXT
+** may be a file name or a markdown-formatted string.
+**
** Options:
**
** --safe Restrict the output to use only "safe" HTML
** --lint-footnotes Print stats for footnotes-related issues
** --dark-pikchr Render pikchrs in dark mode
@@ -1923,13 +1925,20 @@
pikchr_to_html_add_flags( PIKCHR_PROCESS_DARK_MODE );
}
verify_all_options();
for(i=2; i3 ){
- fossil_print("\n", g.argv[i]);
+ if(file_isfile(g.argv[i], ExtFILE)){
+ blob_read_from_file(&in, g.argv[i], ExtFILE);
+ if( g.argc>3 ){
+ fossil_print("\n", g.argv[i]);
+ }
+ }else{
+ blob_init(&in, g.argv[i], -1);
+ if( g.argc>3 ){
+ fossil_print("\n", i-1);
+ }
}
markdown_to_html(&in, 0, &out);
safe_html_context( bSafe ? DOCSRC_UNTRUSTED : DOCSRC_TRUSTED );
safe_html(&out);
blob_write_to_file(&out, "-");