diff --git a/src/Renderer/Latte/LatteRenderer.php b/src/Renderer/Latte/LatteRenderer.php index 358f1646a..df7a1b1c1 100644 --- a/src/Renderer/Latte/LatteRenderer.php +++ b/src/Renderer/Latte/LatteRenderer.php @@ -20,6 +20,7 @@ use Latte; use Nette\Utils\FileSystem; use Nette\Utils\Finder; +use Nette\Utils\Json; use ReflectionClass; use SplFileInfo; use Symfony\Component\Console\Helper\ProgressBar; @@ -39,6 +40,7 @@ use function pcntl_wifexited; use function pcntl_wifsignaled; use function pcntl_wtermsig; +use function sprintf; use function substr; use const PHP_SAPI; @@ -68,6 +70,7 @@ public function render(ProgressBar $progressBar, Index $index): void $tasks = [ [$this->copyAsset(...), $assets], + [$this->renderElementsJs(...), [null]], [$this->renderIndex(...), [null]], [$this->renderTree(...), [null]], [$this->renderNamespace(...), $index->namespace], @@ -89,6 +92,42 @@ protected function copyAsset(Index $index, ConfigParameters $config, SplFileInfo } + protected function renderElementsJs(Index $index, ConfigParameters $config): void + { + $elements = []; + + foreach ($index->namespace as $namespace) { + $elements['namespace'][] = [$namespace->name->full, $this->urlGenerator->getNamespaceUrl($namespace)]; + } + + foreach ($index->classLike as $classLike) { + $members = []; + + foreach ($classLike->constants as $constant) { + $members['constant'][] = [$constant->name, $this->urlGenerator->getMemberAnchor($constant)]; + } + + foreach ($classLike->properties as $property) { + $members['property'][] = [$property->name, $this->urlGenerator->getMemberAnchor($property)]; + } + + foreach ($classLike->methods as $method) { + $members['method'][] = [$method->name, $this->urlGenerator->getMemberAnchor($method)]; + } + + $elements['classLike'][] = [$classLike->name->full, $this->urlGenerator->getClassLikeUrl($classLike), $members]; + } + + foreach ($index->function as $function) { + $elements['function'][] = [$function->name->full, $this->urlGenerator->getFunctionUrl($function)]; + } + + $js = sprintf('window.ApiGen?.resolveElements(%s)', Json::encode($elements)); + $assetPath = $this->urlGenerator->getAssetPath('elements.js'); + FileSystem::write("$this->outputDir/$assetPath", $js); + } + + protected function renderIndex(Index $index, ConfigParameters $config): void { $this->renderTemplate($this->urlGenerator->getIndexPath(), new IndexTemplate( diff --git a/src/Renderer/Latte/Template/assets/main.css b/src/Renderer/Latte/Template/assets/main.css index 4ba6584c9..a43a751d0 100644 --- a/src/Renderer/Latte/Template/assets/main.css +++ b/src/Renderer/Latte/Template/assets/main.css @@ -8,6 +8,7 @@ --color-link: #006aeb; --color-selected: #fffbdd; --border-color: #cccccc; + --background-color: #ecede5; } @@ -142,7 +143,7 @@ code a b { .classLikeDescription { margin-top: 1em; border: 1px solid var(--border-color); - background-color: #ecede5; + background-color: var(--background-color); padding: 10px; } @@ -224,8 +225,6 @@ code a b { .layout { display: flex; flex-direction: row; - min-width: min(1400px, 100%); - max-width: fit-content; } .layout-aside { @@ -236,6 +235,7 @@ code a b { overflow-y: auto; scrollbar-width: none; background-color: white; + border-right: 1px solid var(--border-color); } .layout-aside::-webkit-scrollbar { @@ -247,19 +247,30 @@ code a b { flex: 1 1 0; display: flex; flex-direction: column; - margin-left: 320px; - margin-right: 10px; + min-width: calc(min(1300px, 100%) - 300px); + max-width: fit-content; + margin-left: 300px; + margin-right: 0; } .layout-content { flex: 1 1 0; + padding: 0 20px; } .layout-footer { margin: 20px auto; + padding: 0 20px; color: #929292; } +.layout-rest { + flex: 1 1 0; +} + +.layout-rest > .navbar { + height: 40px; +} /* menu */ .menu { @@ -299,6 +310,54 @@ code a b { } +/* navbar */ +.navbar { + display: flex; + flex-direction: row; + justify-content: space-between; + flex-wrap: wrap; + align-items: center; + gap: 10px 20px; + padding: 4px 8px 4px 8px; + border-bottom: 1px solid var(--border-color); + background-color: var(--background-color); + font-size: 14px; +} + +.navbar-links { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + font-family: var(--font-family-heading); + font-weight: bold; +} + +.navbar-links li.active { + background-color: var(--color-heading-dark); + color: white; +} + +.navbar-links li > * { + display: block; + padding: 6px 6px; + line-height: 1; +} + +.navbar-links li > a { + color: inherit; +} + +.navbar-links li > span { + cursor: default; + color: #888; +} + +.navbar-right { + flex: 0 1 300px; +} + + /* php */ .php-tag { color: #ff0000; @@ -328,6 +387,56 @@ code a b { } +/* search */ +.search { + position: relative; +} + +.search-input { + display: flex; + width: 100%; + border: 1px solid var(--border-color); + border-radius: 0; + padding: 6px; + outline: none; + font-family: inherit; + font-size: inherit; + line-height: 1; +} + +.search-input:focus { + border-color: var(--color-heading-dark); +} + +.search-results { + display: none; + position: absolute; + top: 30px; + width: fit-content; + min-width: 100%; + max-width: calc(100vw - 16px); + overflow: hidden; + background-color: white; + border: 1px solid #888; + z-index: 1; +} + +.search:focus-within .search-results:not(:empty) { + display: block; +} + +.search-results a { + display: block; + color: inherit; + padding: 0 8px; +} + +.search-results li:hover, .search-results li.active { + background-color: var(--color-link); + color: white; +} + + /* source */ .source { font-family: var(--font-family-code); @@ -371,7 +480,7 @@ code a b { .table-heading { border: 1px solid var(--border-color); padding: 3px 5px; - background: #ecede5; + background: var(--background-color); color: var(--color-heading-dark); font-family: var(--font-family-heading); font-size: 1.2em; @@ -434,12 +543,17 @@ code a b { .layout-aside { position: static; + border-right: none; width: auto; } + .layout-rest { + display: none; + } + .layout-main { - margin-left: 10px; min-width: 100%; + margin-left: 0; flex-grow: 0; order: -1; } diff --git a/src/Renderer/Latte/Template/assets/main.js b/src/Renderer/Latte/Template/assets/main.js index 049924ddb..9b8173414 100644 --- a/src/Renderer/Latte/Template/assets/main.js +++ b/src/Renderer/Latte/Template/assets/main.js @@ -24,6 +24,131 @@ document.querySelectorAll('.sortable').forEach(el => { }) +// search +document.querySelectorAll('.search').forEach(el => { + function tokenize(s, offset = 0) { + return Array.from(s.matchAll(/[A-Z]{2,}|[a-zA-Z][a-z]*|\S/g)).map(it => ([it.index + offset, it[0].toLowerCase()])) + } + + function prefix(a, aa, b, bb) { + let len = 0 + while (aa < a.length && bb < b.length && a[aa++] === b[bb++]) len++ + return len + } + + function matchTokens(elementTokens, queryTokens, i = 0, ii = 0, j = 0, jj = 0) { + if (i === queryTokens.length) { + return [] + + } else if (j === elementTokens.length) { + return null + } + + const [elementOffset, elementToken] = elementTokens[j] + const [, queryToken] = queryTokens[i] + const prefixLength = prefix(queryToken, ii, elementToken, jj) + + const subMatches = ii + prefixLength === queryToken.length + ? matchTokens(elementTokens, queryTokens, i + 1, 0, j, jj + prefixLength) + : jj + prefixLength === elementToken.length + ? matchTokens(elementTokens, queryTokens, i, ii + prefixLength, j + 1, 0) + : null + + return subMatches + ? [[elementOffset + jj, prefixLength], ...subMatches] + : matchTokens(elementTokens, queryTokens, i, ii, j + 1, 0) + } + + const searchInput = el.querySelector('.search-input') + const resultsDiv = el.querySelector('.search-results') + + let dataset = null + let datasetPromise = null + + searchInput.addEventListener('input', async () => { + dataset ??= await (datasetPromise ??= new Promise(resolve => { + const script = document.createElement('script') + script.src = el.dataset.elements + document.head.appendChild(script) + window.ApiGen ??= {} + window.ApiGen.resolveElements = (elements) => { + const unified = [ + ...(elements.namespace ?? []).map(([name, path]) => [name, path, tokenize(name)]), + ...(elements.function ?? []).map(([name, path]) => [name, path, tokenize(name)]), + ...(elements.classLike ?? []).flatMap(([classLikeName, path, members]) => [ + [classLikeName, path, tokenize(classLikeName)], + ...(members.constant ?? []).map(([constantName, anchor]) => [`${classLikeName}::${constantName}`, `${path}#${anchor}`, tokenize(`${constantName}`, classLikeName.length + 2)]), + ...(members.property ?? []).map(([propertyName, anchor]) => [`${classLikeName}::\$${propertyName}`, `${path}#${anchor}`, tokenize(`\$${propertyName}`, classLikeName.length + 2)]), + ...(members.method ?? []).map(([methodName, anchor]) => [`${classLikeName}::${methodName}()`, `${path}#${anchor}`, tokenize(`${methodName}()`, classLikeName.length + 2)]), + ]), + ] + + resolve(unified.sort((a, b) => a[0].localeCompare(b[0]))) + } + })) + + const queryTokens = tokenize(searchInput.value) + const results = [] + + for (const [name, path, tokens] of dataset) { + const matches = matchTokens(tokens, queryTokens) + + if (matches) { + results.push([name, path, matches]) + + if (results.length === 20) { + break + } + } + } + + resultsDiv.replaceChildren(...results.map(([name, path, matches]) => { + const li = document.createElement('li') + const anchor = li.appendChild(document.createElement('a')) + anchor.href = path + + let i = 0 + for (const [matchOffset, matchLength] of matches) { + anchor.append(name.slice(i, matchOffset)) + anchor.appendChild(document.createElement('b')).innerText = name.slice(matchOffset, matchOffset + matchLength) + i = matchOffset + matchLength + } + + if (i < name.length) { + anchor.append(name.slice(i)) + } + + return li + })) + }) + + searchInput.addEventListener('keydown', e => { + if (e.key === 'Escape') { + searchInput.blur() + + } else if (e.key === 'ArrowUp') { + e.preventDefault() + const active = resultsDiv.querySelector('.active') + const prev = active?.previousElementSibling ?? resultsDiv.lastElementChild + active?.classList.remove('active') + prev?.classList.add('active') + + } else if (e.key === 'ArrowDown') { + e.preventDefault() + const active = resultsDiv.querySelector('.active') + const next = active?.nextElementSibling ?? resultsDiv.firstElementChild + active?.classList.remove('active') + next?.classList.add('active') + + } else if (e.key === 'Enter') { + e.preventDefault() + const active = resultsDiv.querySelector('.active') ?? resultsDiv.firstElementChild + active?.querySelector('a').click() + } + }) +}) + + // line selection let ranges = [] let last = null diff --git a/src/Renderer/Latte/Template/blocks/@index.latte b/src/Renderer/Latte/Template/blocks/@index.latte index 42e96910c..7a6f82627 100644 --- a/src/Renderer/Latte/Template/blocks/@index.latte +++ b/src/Renderer/Latte/Template/blocks/@index.latte @@ -37,6 +37,7 @@ {import 'methodUsed.latte'} {import 'methodUsedSummary.latte'} {import 'namespaceLinks.latte'} +{import 'navbar.latte'} {import 'parameter.latte'} {import 'property.latte'} {import 'propertyInherited.latte'} @@ -44,5 +45,6 @@ {import 'propertySummary.latte'} {import 'propertyUsed.latte'} {import 'propertyUsedSummary.latte'} +{import 'search.latte'} {import 'source.latte'} {import 'type.latte'} diff --git a/src/Renderer/Latte/Template/blocks/layout.latte b/src/Renderer/Latte/Template/blocks/layout.latte index a39e6db70..b077ab912 100644 --- a/src/Renderer/Latte/Template/blocks/layout.latte +++ b/src/Renderer/Latte/Template/blocks/layout.latte @@ -19,6 +19,10 @@
+
+ {include navbar} +
+
{include content}
@@ -27,6 +31,10 @@ {$config->title} API documentation generated by ApiGen {$config->version}
+ +
+ +
{/define} diff --git a/src/Renderer/Latte/Template/blocks/navbar.latte b/src/Renderer/Latte/Template/blocks/navbar.latte new file mode 100644 index 000000000..5736fd008 --- /dev/null +++ b/src/Renderer/Latte/Template/blocks/navbar.latte @@ -0,0 +1,36 @@ +{varType ApiGen\Index\Index $index} +{varType ApiGen\Renderer\Latte\Template\ConfigParameters $config} +{varType ApiGen\Renderer\Latte\Template\LayoutParameters $layout} + +{define navbar} + +{/define} diff --git a/src/Renderer/Latte/Template/blocks/search.latte b/src/Renderer/Latte/Template/blocks/search.latte new file mode 100644 index 000000000..b63428851 --- /dev/null +++ b/src/Renderer/Latte/Template/blocks/search.latte @@ -0,0 +1,10 @@ +{varType ApiGen\Index\Index $index} +{varType ApiGen\Renderer\Latte\Template\ConfigParameters $config} +{varType ApiGen\Renderer\Latte\Template\LayoutParameters $layout} + +{define search} + +{/define}