Improved search result rendering and icon search

This commit is contained in:
squidfunk 2021-02-14 14:51:08 +01:00
parent a8b5cafa73
commit 4ef15bd440
50 changed files with 836 additions and 145 deletions

View File

@ -34,6 +34,7 @@ manifest.json
site
# Configuration
typings
webpack.config.ts
# Distribution files

1
.gitignore vendored
View File

@ -58,6 +58,7 @@ mkdocs_material.egg-info
.DS_Store
# Temporary files
TODO
tmp
# IDEs

View File

@ -11,15 +11,22 @@ and used in `mkdocs.yml`, documents and templates.
## Search
<input id="icon-search" class="md-input" placeholder="Search the icon database" />
<div class="tx-icon-result" markdown="1">
<div class="mdx-icon-search" data-mdx-component="icon-search">
<input
class="md-input md-input--stretch mdx-icon-search__input"
placeholder="Search the icon database"
data-mdx-component="icon-search-query"
/>
<div class="mdx-icon-search-result" data-mdx-component="icon-search-result">
<div class="mdx-icon-search-result__meta"></div>
<ol class="mdx-icon-search-result__list"></ol>
</div>
</div>
<small>
:octicons-light-bulb-16:
**Tip:** Enter some keywords to find the perfect icon and click on the
shortcode to copy it to your clipboard.
</small>
</div>
## Configuration

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,18 +1,18 @@
{
"assets/javascripts/bundle.js": "assets/javascripts/bundle.b0aa5de8.min.js",
"assets/javascripts/bundle.js.map": "assets/javascripts/bundle.b0aa5de8.min.js.map",
"assets/javascripts/vendor.js": "assets/javascripts/vendor.e32ed4d0.min.js",
"assets/javascripts/vendor.js.map": "assets/javascripts/vendor.e32ed4d0.min.js.map",
"assets/javascripts/bundle.js": "assets/javascripts/bundle.3e5f7fbe.min.js",
"assets/javascripts/bundle.js.map": "assets/javascripts/bundle.3e5f7fbe.min.js.map",
"assets/javascripts/vendor.js": "assets/javascripts/vendor.00ecb175.min.js",
"assets/javascripts/vendor.js.map": "assets/javascripts/vendor.00ecb175.min.js.map",
"assets/javascripts/worker/search.js": "assets/javascripts/worker/search.b9424174.min.js",
"assets/javascripts/worker/search.js.map": "assets/javascripts/worker/search.b9424174.min.js.map",
"assets/stylesheets/main.css": "assets/stylesheets/main.25519675.min.css",
"assets/stylesheets/main.css.map": "assets/stylesheets/main.25519675.min.css.map",
"assets/stylesheets/main.css": "assets/stylesheets/main.f080134c.min.css",
"assets/stylesheets/main.css.map": "assets/stylesheets/main.f080134c.min.css.map",
"assets/stylesheets/palette.css": "assets/stylesheets/palette.e70b70b6.min.css",
"assets/stylesheets/palette.css.map": "assets/stylesheets/palette.e70b70b6.min.css.map",
"overrides/assets/javascripts/bundle.js": "overrides/assets/javascripts/bundle.c607e7b3.min.js",
"overrides/assets/javascripts/bundle.js.map": "overrides/assets/javascripts/bundle.c607e7b3.min.js.map",
"overrides/assets/javascripts/vendor.js": "overrides/assets/javascripts/vendor.1aa446d9.min.js",
"overrides/assets/javascripts/vendor.js.map": "overrides/assets/javascripts/vendor.1aa446d9.min.js.map",
"overrides/assets/stylesheets/main.css": "overrides/assets/stylesheets/main.d67efea2.min.css",
"overrides/assets/stylesheets/main.css.map": "overrides/assets/stylesheets/main.d67efea2.min.css.map"
"overrides/assets/javascripts/bundle.js": "overrides/assets/javascripts/bundle.b60297a2.min.js",
"overrides/assets/javascripts/bundle.js.map": "overrides/assets/javascripts/bundle.b60297a2.min.js.map",
"overrides/assets/javascripts/vendor.js": "overrides/assets/javascripts/vendor.be34bb11.min.js",
"overrides/assets/javascripts/vendor.js.map": "overrides/assets/javascripts/vendor.be34bb11.min.js.map",
"overrides/assets/stylesheets/main.css": "overrides/assets/stylesheets/main.552eceec.min.css",
"overrides/assets/stylesheets/main.css.map": "overrides/assets/stylesheets/main.552eceec.min.css.map"
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -39,7 +39,7 @@
{% endif %}
{% endblock %}
{% block styles %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.25519675.min.css' | url }}">
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.f080134c.min.css' | url }}">
{% if config.theme.palette %}
{% set palette = config.theme.palette %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/palette.e70b70b6.min.css' | url }}">
@ -216,8 +216,8 @@
</script>
{% endblock %}
{% block scripts %}
<script src="{{ 'assets/javascripts/vendor.e32ed4d0.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/bundle.b0aa5de8.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/vendor.00ecb175.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/bundle.3e5f7fbe.min.js' | url }}"></script>
{% for path in config["extra_javascript"] %}
<script src="{{ path | url }}"></script>
{% endfor %}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,2 +0,0 @@
!function(e,t){for(var n in t)e[n]=t[n]}(window,function(e){function t(t){for(var r,i,a=t[0],s=t[1],u=t[2],f=0,p=[];f<a.length;f++)i=a[f],Object.prototype.hasOwnProperty.call(o,i)&&o[i]&&p.push(o[i][0]),o[i]=0;for(r in s)Object.prototype.hasOwnProperty.call(s,r)&&(e[r]=s[r]);for(l&&l(t);p.length;)p.shift()();return c.push.apply(c,u||[]),n()}function n(){for(var e,t=0;t<c.length;t++){for(var n=c[t],r=!0,a=1;a<n.length;a++){var s=n[a];0!==o[s]&&(r=!1)}r&&(c.splice(t--,1),e=i(i.s=n[0]))}return e}var r={},o={0:0},c=[];function i(t){if(r[t])return r[t].exports;var n=r[t]={i:t,l:!1,exports:{}};return e[t].call(n.exports,n,n.exports,i),n.l=!0,n.exports}i.m=e,i.c=r,i.d=function(e,t,n){i.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},i.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.t=function(e,t){if(1&t&&(e=i(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(i.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var r in e)i.d(n,r,function(t){return e[t]}.bind(null,r));return n},i.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return i.d(t,"a",t),t},i.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},i.p="";var a=window.webpackJsonp=window.webpackJsonp||[],s=a.push.bind(a);a.push=t,a=a.slice();for(var u=0;u<a.length;u++)t(a[u]);var l=s;return c.push([37,1]),n()}({37:function(e,t,n){"use strict";n.r(t);var r=n(20),o=n(3),c=n(38),i=n(29),a=n(25);n(32),n(39);function s(e,t=document){return t.querySelector(e)||void 0}function u(e,t=document){const n=s(e,t);if(void 0===n)throw new ReferenceError(`Missing element: expected "${e}" to be present`);return n}n(50);var l=n(51);var f=n(17),p=n(40),b=n(41),d=n(42),g=n(43),h=n(44);n(45),n(46);const v=new f.a;Object(p.a)(()=>Object(b.a)(new ResizeObserver(e=>{for(const t of e)v.next(t)}))).pipe(Object(i.a)(e=>d.a.pipe(Object(l.a)(e)).pipe(Object(g.a)(()=>e.disconnect()))),Object(h.a)(1));n(31);u("[data-md-toggle=drawer]"),u("[data-md-toggle=search]");n(47);function O(){return new URL(location.href)}n(52),n(53);n(48),n(49);const j=u("#__config"),m=JSON.parse(j.textContent);function y(e,t){if("string"==typeof t||"number"==typeof t)e.innerHTML+=t.toString();else if(t instanceof Node)e.appendChild(t);else if(Array.isArray(t))for(const n of t)y(e,n)}function w(e,t,...n){const r=document.createElement(e);if(t)for(const e of Object.keys(t))"boolean"!=typeof t[e]?r.setAttribute(e,t[e]):t[e]&&r.setAttribute(e,"");for(const e of n)y(r,e);return r}m.base=new URL(m.base,O()).toString().replace(/\/$/,"");function x(e,t){return t.length?w("div",{class:""},w("span",null,function(e){if(e>999){return((e+1e-6)/1e3).toFixed(+((e-950)%1e3>99))+"k"}return e.toString()}(e.length)," results"),w("ul",{class:"tx-icon-search__list"},e.slice(0,10).map(e=>w("li",{class:"tx-icon-search__item"},w("span",{class:"twemoji"},w("img",{src:"https://raw.githubusercontent.com/squidfunk/mkdocs-material/master/material/.icons/"+e,style:"width: 18px; height: 18px"}))," ",w("button",{class:"md-clipboard--inline","data-clipboard-text":":"+e.replace(/\.svg$/,"").replace(/\//g,"-")+":"},w("code",null,function(e,t){return`:${Object(r.wrap)(e.replace(/\.svg$/,"").replace(/\//g,"-"),t,{wrap:{tagOpen:"<b>",tagClose:"</b>"}})}:`}(e,t))))))):w("div",{class:""})}const _=m,k=Object(o.a)(fetch(_.base+"/overrides/assets/javascripts/icons.json").then(e=>e.json())),S=s("#icon-search");S&&k.pipe(Object(i.a)(e=>Object(c.a)(S,"keyup").pipe(Object(a.a)(()=>Object(r.filter)(e,S.value))))).subscribe(e=>{const t=u(".tx-icon-result");t.innerHTML="",t.appendChild(x(e,S.value))}),Object(c.a)(document.body,"click").subscribe(e=>{if(e.target instanceof HTMLElement){e.target.closest("a[href^=http]")instanceof HTMLLinkElement&&ga("send","event","outbound","click",el.href)}})}}));
//# sourceMappingURL=bundle.c607e7b3.min.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -22,7 +22,7 @@
<meta name="twitter:title" content="{{ title }}">
<meta name="twitter:description" content="{{ config.site_description }}">
<meta name="twitter:image" content="{{ image }}">
<link rel="stylesheet" href="{{ 'overrides/assets/stylesheets/main.d67efea2.min.css' | url }}">
<link rel="stylesheet" href="{{ 'overrides/assets/stylesheets/main.552eceec.min.css' | url }}">
{% endblock %}
{% block announce %}
<a href="https://twitter.com/squidfunk">
@ -53,6 +53,6 @@
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="{{ 'overrides/assets/javascripts/vendor.1aa446d9.min.js' | url }}"></script>
<script src="{{ 'overrides/assets/javascripts/bundle.c607e7b3.min.js' | url }}"></script>
<script src="{{ 'overrides/assets/javascripts/vendor.be34bb11.min.js' | url }}"></script>
<script src="{{ 'overrides/assets/javascripts/bundle.b60297a2.min.js' | url }}"></script>
{% endblock %}

View File

@ -21,6 +21,7 @@
*/
import { translation } from "~/_"
import { round } from "~/utilities"
/* ----------------------------------------------------------------------------
* Functions
@ -49,7 +50,7 @@ export function setSearchResultMeta(
/* Multiple result */
default:
el.textContent = translation("search.result.other", value)
el.textContent = translation("search.result.other", round(value))
}
}

View File

@ -21,7 +21,9 @@
*/
import { Observable, fromEvent, merge } from "rxjs"
import { map, startWith } from "rxjs/operators"
import { distinctUntilChanged, map, startWith } from "rxjs/operators"
import { getElementContentSize, getElementSize } from "../size"
/* ----------------------------------------------------------------------------
* Types
@ -74,3 +76,30 @@ export function watchElementOffset(
startWith(getElementOffset(el))
)
}
/**
* Watch element threshold
*
* This function returns an observable which emits whether the bottom scroll
* offset of an elements is within a certain threshold.
*
* @param el - Element
* @param threshold - Threshold
*
* @returns Element threshold observable
*/
export function watchElementThreshold(
el: HTMLElement, threshold = 16
): Observable<boolean> {
return watchElementOffset(el)
.pipe(
map(({ y }) => {
const visible = getElementSize(el)
const content = getElementContentSize(el)
return y >= (
content.height - visible.height - threshold
)
}),
distinctUntilChanged()
)
}

View File

@ -120,7 +120,7 @@ export function getElementContentSize(el: HTMLElement): ElementSize {
/**
* Watch element size
*
* This function returns an observable that will subscribe to a single internal
* This function returns an observable that subscribes to a single internal
* instance of `ResizeObserver` upon subscription, and emit resize events until
* termination. Note that this function should not be called with the same
* element twice, as the first unsubscription will terminate observation.

View File

@ -73,7 +73,7 @@ interface WatchOptions<T extends WorkerMessage> {
/**
* Watch a web worker
*
* This function returns an observable that will send all values emitted by the
* This function returns an observable that sends all values emitted by the
* message observable to the web worker. Web worker communication is expected
* to be bidirectional (request-response) and synchronous. Messages that are
* emitted during a pending request are throttled, the last one is emitted.

View File

@ -20,7 +20,7 @@
* IN THE SOFTWARE.
*/
import { Observable, merge } from "rxjs"
import { Observable, ObservableInput, merge } from "rxjs"
import { filter, sample, take } from "rxjs/operators"
import { configuration } from "~/_"
@ -75,9 +75,9 @@ interface MountOptions {
*
* @param url - Search index URL
*
* @returns Promise resolving with search index
* @returns Promise or observable
*/
function fetchSearchIndex(url: string) {
function fetchSearchIndex(url: string): ObservableInput<SearchIndex> {
return __search?.index || requestJSON<SearchIndex>(url)
}
@ -104,7 +104,7 @@ export function mountSearch(
`${config.base}/search/search_index.json`
))
/* Retrieve elements */
/* Retrieve nested components */
const query = getComponentElement("search-query", el)
const result = getComponentElement("search-result", el)

View File

@ -20,14 +20,25 @@
* IN THE SOFTWARE.
*/
import { Observable, Subject } from "rxjs"
import {
Observable,
Subject,
animationFrameScheduler,
merge,
of
} from "rxjs"
import {
bufferCount,
distinctUntilKeyChanged,
filter,
finalize,
map,
observeOn,
startWith,
switchMap,
tap,
withLatestFrom
withLatestFrom,
zipWith
} from "rxjs/operators"
import {
@ -36,7 +47,10 @@ import {
resetSearchResultMeta,
setSearchResultMeta
} from "~/actions"
import { getElementOrThrow } from "~/browser"
import {
getElementOrThrow,
watchElementThreshold
} from "~/browser"
import {
SearchResult as SearchResultData,
SearchWorker,
@ -76,6 +90,9 @@ interface MountOptions {
/**
* Mount search result list
*
* This function will perform a lazy rendering of the search results, depending
* on the vertical offset of the search result container.
*
* @param el - Search result list element
* @param worker - Search worker
* @param options - Options
@ -86,11 +103,16 @@ export function mountSearchResult(
el: HTMLElement, { rx$ }: SearchWorker, { query$ }: MountOptions
): Observable<Component<SearchResult>> {
const internal$ = new Subject<SearchResult>()
const boundary$ = watchElementThreshold(el.parentElement!)
.pipe(
filter(Boolean)
)
/* Update search result metadata */
const meta = getElementOrThrow(":scope > :first-child", el)
internal$
.pipe(
observeOn(animationFrameScheduler),
withLatestFrom(query$)
)
.subscribe(([{ data }, { value }]) => {
@ -103,15 +125,21 @@ export function mountSearchResult(
/* Update search result list */
const list = getElementOrThrow(":scope > :last-child", el)
internal$
.subscribe(({ data }) => {
resetSearchResultList(list)
/* Compute thresholds and search results */
const thresholds = [...data.map(([best]) => best.score), 0]
for (let index = 0; index < data.length; index++)
addToSearchResultList(list, renderSearchResult(
data[index++], thresholds[index]
.pipe(
observeOn(animationFrameScheduler),
tap(() => resetSearchResultList(list)),
switchMap(({ data }) => merge(
of(...data.slice(0, 10)),
of(...data.slice(10))
.pipe(
bufferCount(4),
zipWith(boundary$),
switchMap(([chunk]) => of(...chunk))
)
))
)
.subscribe(result => {
addToSearchResultList(list, renderSearchResult(result))
})
/* Filter search result list */

View File

@ -54,7 +54,7 @@ const enum Flag {
*/
function renderSearchDocument(
document: SearchDocument & SearchMetadata, flag: Flag
) {
): HTMLElement {
const parent = flag & Flag.PARENT
const teaser = flag & Flag.TEASER
@ -101,13 +101,13 @@ function renderSearchDocument(
* Render a search result
*
* @param result - Search result
* @param threshold - Score threshold
*
* @returns Element
*/
export function renderSearchResult(
result: SearchResult, threshold = Infinity
result: SearchResult
): HTMLElement {
const threshold = result[0].score
const docs = [...result]
/* Find and extract parent article */

View File

@ -230,9 +230,11 @@
padding: 0 px2rem(44px) 0 px2rem(72px);
text-overflow: ellipsis;
background-color: var(--md-default-bg-color);
box-shadow: 0 0 px2rem(12px) transparent;
transition:
color 250ms,
background-color 250ms;
background-color 250ms,
box-shadow 250ms;
// Adjust for right-to-left languages
[dir="rtl"] & {
@ -255,6 +257,11 @@
display: none;
}
// Adjust appearance when search is active
[data-md-toggle="search"]:checked ~ .md-header & {
box-shadow: 0 0 px2rem(12px) var(--md-default-fg-color--lightest);
}
// [tablet portrait -]: Search modal
@include break-to-device(tablet portrait) {
width: 100%;
@ -448,7 +455,9 @@
background-color: var(--md-default-bg-color);
// Hack: promote to own layer to reduce jitter
backface-visibility: hidden;
scroll-snap-type: y mandatory;
// Hack: Chrome 88+ has weird overscroll behavior. Overall, scroll snapping
// seems to be something that is not ready for prime time on some browsers.
// scroll-snap-type: y mandatory;
touch-action: pan-y;
// Mitigiate excessive repaints on non-retina devices

View File

@ -0,0 +1,85 @@
/*
* Copyright (c) 2016-2020 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import { getElementOrThrow, getElements } from "~/browser"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Component
*/
export type ComponentType =
| "icon-search" /* Icon search */
| "icon-search-query" /* Icon search input */
| "icon-search-result" /* Icon search results */
/**
* A component
*
* @template T - Component type
* @template U - Reference type
*/
export type Component<
T extends {} = {},
U extends HTMLElement = HTMLElement
> =
T & {
ref: U /* Component reference */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Retrieve the element for a given component or throw a reference error
*
* @template T - Element type
*
* @param type - Component type
* @param node - Node of reference
*
* @returns Element
*/
export function getComponentElement<T extends HTMLElement>(
type: ComponentType, node: ParentNode = document
): T {
return getElementOrThrow(`[data-mdx-component=${type}]`, node)
}
/**
* Retrieve all elements for a given component
*
* @template T - Element type
*
* @param type - Component type
* @param node - Node of reference
*
* @returns Elements
*/
export function getComponentElements<T extends HTMLElement>(
type: ComponentType, node: ParentNode = document
): T[] {
return getElements(`[data-mdx-component=${type}]`, node)
}

View File

@ -0,0 +1,83 @@
/*
* Copyright (c) 2016-2020 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import { Observable, merge } from "rxjs"
import { configuration } from "~/_"
import { requestJSON } from "~/browser"
import { Component, getComponentElement } from "../../_"
import {
IconSearchQuery,
mountIconSearchQuery
} from "../query"
import {
IconSearchResult,
mountIconSearchResult
} from "../result"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Icon search index
*/
export type IconSearchIndex = string[]
/**
* Icon search
*/
export type IconSearch =
| IconSearchQuery
| IconSearchResult
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount icon search
*
* @param el - Icon search element
*
* @returns Icon search component observable
*/
export function mountIconSearch(
el: HTMLElement
): Observable<Component<IconSearch>> {
const config = configuration()
const index$ = requestJSON<IconSearchIndex>(
`${config.base}/overrides/assets/javascripts/icons.json`
)
/* Retrieve nested components */
const query = getComponentElement("icon-search-query", el)
const result = getComponentElement("icon-search-result", el)
/* Create and return component */
const query$ = mountIconSearchQuery(query as HTMLInputElement)
return merge(
query$,
mountIconSearchResult(result, { index$, query$ })
)
}

View File

@ -0,0 +1,25 @@
/*
* Copyright (c) 2016-2020 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
export * from "./_"
export * from "./query"
export * from "./result"

View File

@ -20,50 +20,60 @@
* IN THE SOFTWARE.
*/
/* eslint-disable */
import { Observable, combineLatest, fromEvent, merge } from "rxjs"
import {
delay,
distinctUntilChanged,
map,
startWith
} from "rxjs/operators"
import { wrap } from "fuzzaldrin-plus"
import { h, round } from "utilities"
import { watchElementFocus } from "~/browser"
import { Component } from "../../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Icon search query
*/
export interface IconSearchQuery {
value: string /* Query value */
focus: boolean /* Query focus */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
function transform(value: string, query: string) {
return `:${wrap(value.replace(/\.svg$/, "").replace(/\//g, "-"), query, {
wrap: {
tagOpen: "<b>",
tagClose: "</b>"
}
})}:`
}
/**
* Mount icon search query
*
* @param el - Icon search query element
*
* @returns Icon search query component observable
*/
export function mountIconSearchQuery(
el: HTMLInputElement
): Observable<Component<IconSearchQuery, HTMLInputElement>> {
const base = "https://raw.githubusercontent.com/squidfunk/mkdocs-material/master/material/.icons/"
/* Intercept focus and input events */
const focus$ = watchElementFocus(el)
const value$ = merge(
fromEvent(el, "keyup"),
fromEvent(el, "focus").pipe(delay(1))
)
.pipe(
map(() => el.value),
startWith(el.value),
distinctUntilChanged()
)
export function renderIconSearch(
results: string[], query: string
) {
if (!query.length)
return <div class=""></div>
return (
<div class="">
<span>{round(results.length)} results</span>
<ul class="tx-icon-search__list">
{results.slice(0, 10).map(result => (
<li class="tx-icon-search__item">
<span class="twemoji">
<img src={base + result} style="width: 18px; height: 18px" />
</span> <button
class="md-clipboard--inline"
data-clipboard-text={
":" + result.replace(/\.svg$/, "").replace(/\//g, "-") + ":"
}
>
<code>{transform(result, query)}</code>
</button>
</li>
))}
</ul>
</div>
/* Combine into single observable */
return combineLatest([value$, focus$])
.pipe(
map(([value, focus]) => ({ ref: el, value, focus })),
)
}

View File

@ -0,0 +1,151 @@
/*
* Copyright (c) 2016-2020 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import { filter as search } from "fuzzaldrin-plus"
import {
Observable,
Subject,
animationFrameScheduler,
combineLatest,
merge,
of
} from "rxjs"
import {
bufferCount,
distinctUntilKeyChanged,
filter,
finalize,
map,
observeOn,
switchMap,
tap,
withLatestFrom,
zipWith
} from "rxjs/operators"
import {
addToSearchResultList,
resetSearchResultList,
resetSearchResultMeta,
setSearchResultMeta
} from "~/actions"
import {
getElementOrThrow,
watchElementThreshold
} from "~/browser"
import { renderIconSearchResult } from "../../../templates"
import { Component } from "../../_"
import { IconSearchIndex } from "../_"
import { IconSearchQuery } from "../query"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Icon search result
*/
export interface IconSearchResult {
data: string[] /* Search result data */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Mount options
*/
interface MountOptions {
index$: Observable<IconSearchIndex> /* Search index observable */
query$: Observable<IconSearchQuery> /* Search query observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount icon search result
*
* @param el - Icon search result element
* @param options - Options
*
* @returns Icon search result component observable
*/
export function mountIconSearchResult(
el: HTMLElement, { index$, query$ }: MountOptions
): Observable<Component<IconSearchResult, HTMLElement>> {
const internal$ = new Subject<IconSearchResult>()
const boundary$ = watchElementThreshold(el)
.pipe(
filter(Boolean)
)
/* Update search result metadata */
const meta = getElementOrThrow(":scope > :first-child", el)
internal$
.pipe(
observeOn(animationFrameScheduler),
withLatestFrom(query$)
)
.subscribe(([{ data }, { value }]) => {
if (value)
setSearchResultMeta(meta, data.length)
else
resetSearchResultMeta(meta)
})
/* Update icon search result list */
const list = getElementOrThrow(":scope > :last-child", el)
internal$
.pipe(
observeOn(animationFrameScheduler),
tap(() => resetSearchResultList(list)),
switchMap(({ data }) => merge(
of(...data.slice(0, 10)),
of(...data.slice(10))
.pipe(
bufferCount(10),
zipWith(boundary$),
switchMap(([chunk]) => of(...chunk))
)
)),
withLatestFrom(query$)
)
.subscribe(([result, { value }]) => {
addToSearchResultList(list, renderIconSearchResult(result, value))
})
/* Crate and return component */
return combineLatest([
index$,
query$.pipe(distinctUntilKeyChanged("value"))
])
.pipe(
map(([index, { value }]) => ({ data: search(index, value) })),
tap(internal$),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
)
}

View File

@ -0,0 +1,24 @@
/*
* Copyright (c) 2016-2020 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
export * from "./_"
export * from "./icon-search"

View File

@ -20,42 +20,30 @@
* IN THE SOFTWARE.
*/
/* eslint-disable */
import { filter } from "fuzzaldrin-plus"
import { from, fromEvent } from "rxjs"
import { map, switchMap } from "rxjs/operators"
import { Observable, fromEvent, merge } from "rxjs"
import { switchMap } from "rxjs/operators"
import { configuration } from "~/_"
import { getElement, getElementOrThrow } from "~/browser"
import {
getComponentElements,
mountIconSearch
} from "./components"
import { renderIconSearch } from "./templates/icon"
/* ----------------------------------------------------------------------------
* Application
* ------------------------------------------------------------------------- */
// Obtain configuration
const config = configuration()
// Now, load icons.json
const icons$ =
from(fetch(`${config.base}/overrides/assets/javascripts/icons.json`)
.then(res => res.json())
)
// Render icon search, if present
const search = getElement<HTMLInputElement>("#icon-search")
if (search) {
icons$
/* Set up extra component observables */
declare const document$: Observable<Document>
document$
.pipe(
switchMap(icons => fromEvent<InputEvent>(search, "keyup")
.pipe(
map(() => filter(icons, search.value))
switchMap(() => merge(
/* Icon search */
...getComponentElements("icon-search")
.map(child => mountIconSearch(child))
))
)
)
)
.subscribe((result: any[]) => {
const list = getElementOrThrow(".tx-icon-result")
list.innerHTML = ""
list.appendChild(renderIconSearch(result, search.value))
})
}
.subscribe()
// Track click events
fromEvent(document.body, "click")
@ -63,8 +51,7 @@ fromEvent(document.body, "click")
if (ev.target instanceof HTMLElement) {
const el2 = ev.target.closest("a[href^=http]")
if (el2 instanceof HTMLLinkElement)
// @ts-ignore
ga("send", "event", "outbound", "click", el.href)
ga("send", "event", "outbound", "click", el2.href)
}
})

View File

@ -0,0 +1,23 @@
/*
* Copyright (c) 2016-2020 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
export * from "./analytics"

View File

@ -0,0 +1,101 @@
/*
* Copyright (c) 2016-2020 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import { wrap } from "fuzzaldrin-plus"
import { h } from "~/utilities"
import { translation } from "~/_"
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* Icon CDN URL
*/
const base =
"https://raw.githubusercontent.com/" +
"squidfunk/mkdocs-material/" +
"master/material/.icons/"
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Convert icon search result to shortcode
*
* @param value - Icon search result
*
* @returns Shortcode
*/
function shortcode(value: string): string {
return `:${value.replace(/\.svg$/, "").replace(/\//g, "-")}:`
}
/**
* Highlight an icon search result
*
* @param value - Icon search result
* @param query - Icon search query
*
* @returns Highlighted result
*/
function highlight(value: string, query: string) {
return wrap(shortcode(value), query, {
wrap: {
tagOpen: "<b>",
tagClose: "</b>"
}
})
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Render an icon search result
*
* @param value - Icon search result
* @param query - Icon search query
*
* @returns Element
*/
export function renderIconSearchResult(
value: string, query: string
): HTMLElement {
return (
<li class="mdx-icon-search-result__item">
<span class="twemoji">
<img src={base + value} />
</span>
<button
class="md-clipboard--inline"
title={translation("clipboard.copy")}
data-clipboard-text={shortcode(value)}
>
<code>{highlight(value, query)}</code>
</button>
</li>
)
}

View File

@ -0,0 +1,23 @@
/*
* Copyright (c) 2016-2020 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
export * from "./icon-search"

View File

@ -23,3 +23,97 @@
// ----------------------------------------------------------------------------
// Nothing to see here, move along
// ----------------------------------------------------------------------------
// Scoped in typesetted content to match specificity of regular content
.md-typeset {
// Icon search
.mdx-icon-search {
position: relative;
background-color: var(--md-default-bg-color);
border-radius: px2rem(2px);
box-shadow:
0 px2rem(4px) px2rem(10px) hsla(0, 0%, 0%, 0.1),
0 px2rem(0.5px) px2rem(1px) hsla(0, 0%, 0%, 0.1);
transition: box-shadow 125ms;
// Icon search on focus/hover
&:focus-within,
&:hover {
box-shadow:
0 px2rem(8px) px2rem(20px) hsla(0, 0%, 0%, 0.15),
0 px2rem(0.5px) px2rem(1px) hsla(0, 0%, 0%, 0.15);
}
// Icon search input
.md-input {
box-shadow: 0 0 px2rem(12px) var(--md-default-fg-color--lightest);
}
}
// Icon search result
.mdx-icon-search-result {
max-height: 50vh;
overflow-y: auto;
// Hack: promote to own layer to reduce jitter
backface-visibility: hidden;
touch-action: pan-y;
scrollbar-width: thin;
scrollbar-color: var(--md-default-fg-color--lighter) transparent;
// Webkit scrollbar
&::-webkit-scrollbar {
width: px2rem(4px);
height: px2rem(4px);
}
// Webkit scrollbar thumb
&::-webkit-scrollbar-thumb {
background-color: var(--md-default-fg-color--lighter);
// Webkit scrollbar thumb on hover
&:hover {
background-color: var(--md-accent-fg-color);
}
}
// Icon search result metadata
&__meta {
position: absolute;
top: px2rem(8px);
right: px2rem(12px);
color: var(--md-default-fg-color--lighter);
font-size: px2rem(12.8px);
}
// Icon search result list
&__list {
margin: 0;
padding: 0;
list-style: none;
}
// Icon search result item
&__item {
margin: 0;
padding: px2rem(4px) px2rem(12px);
border-bottom: px2rem(1px) solid var(--md-default-fg-color--lightest);
// TODO:
&:last-child {
border-bottom: none;
}
// Item content
> * {
margin-right: px2rem(12px);
}
// Set icon dimensions to fit
img {
width: px2rem(18px);
height: px2rem(18px);
}
}
}
}

View File

@ -37,5 +37,14 @@ export interface GlobalSearchConfig {
/* ------------------------------------------------------------------------- */
declare global {
/**
* GLobal search configuration
*/
var __search: GlobalSearchConfig | undefined
/**
* Google Analytics
*/
function ga(...args: string[]): void
}

View File

@ -29,7 +29,7 @@ import ImageminPlugin from "imagemin-webpack-plugin"
import MiniCssExtractPlugin = require("mini-css-extract-plugin")
import * as path from "path"
import { toPairs } from "ramda"
import glob = require("tiny-glob")
import glob from "tiny-glob"
import { minify as minjs } from "terser"
import { TsconfigPathsPlugin } from "tsconfig-paths-webpack-plugin"
import { Configuration } from "webpack"