Index: src/backlink.c ================================================================== --- src/backlink.c +++ src/backlink.c @@ -285,10 +285,12 @@ static int mkdn_noop_r_html_tag(Blob *b1, Blob *b2, void *v){ return 1; } static int (*mkdn_noop_tri_emphas)(Blob*, Blob*, char, void*) = mkdn_noop_emphasis; static int mkdn_noop_footnoteref(Blob *b1, const Blob *b2, const Blob *b3, int i1, int i2, void *v){ return 1; } +static int mkdn_noop_tagref(Blob *b1,Blob *b2, enum mkd_tagspan t, + void *p){ return 1; } /* ** Scan markdown text and add self-hyperlinks to the BACKLINK table. */ void markdown_extract_links( @@ -319,10 +321,11 @@ /* emphasis */ mkdn_noop_emphasis, /* image */ mkdn_noop_image, /* linebreak */ mkdn_noop_linebreak, /* link */ backlink_md_link, /* r_html_tag */ mkdn_noop_r_html_tag, + /* #tags */ mkdn_noop_tagref, /* tri_emphas */ mkdn_noop_tri_emphas, /* footnoteref*/ mkdn_noop_footnoteref, 0, /* entity */ 0, /* normal_text */ Index: src/chat.c ================================================================== --- src/chat.c +++ src/chat.c @@ -216,10 +216,11 @@ @ @ Active users (sorted by last message time) @ @
@ + @ @ 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, "-");