From 246f79444207f009aa9033f5a821da539f0673a5 Mon Sep 17 00:00:00 2001 From: EnixCoda Date: Mon, 7 Jun 2021 17:22:00 +0800 Subject: [PATCH 1/2] feat: fold source code button --- src/components/SideBar.tsx | 7 +- src/components/settings/SettingsBar.tsx | 13 +- src/platforms/GitHub/index.ts | 1 + src/platforms/GitHub/useGitHubCodeFold.tsx | 162 +++++++++++++++++++++ src/styles/index.scss | 95 ++++++++++++ src/utils/config/helper.ts | 3 + 6 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 src/platforms/GitHub/useGitHubCodeFold.tsx diff --git a/src/components/SideBar.tsx b/src/components/SideBar.tsx index 62bcf8c..b2b7fea 100644 --- a/src/components/SideBar.tsx +++ b/src/components/SideBar.tsx @@ -7,7 +7,11 @@ import { SideBarBodyWrapper } from 'components/SideBarBodyWrapper' import { ToggleShowButton } from 'components/ToggleShowButton' import { useConfigs } from 'containers/ConfigsContext' import { platform } from 'platforms' -import { useGitHubAttachCopyFileButton, useGitHubAttachCopySnippetButton } from 'platforms/GitHub' +import { + useGitHubAttachCopyFileButton, + useGitHubAttachCopySnippetButton, + useGitHubCodeFold, +} from 'platforms/GitHub' import * as React from 'react' import { cx } from 'utils/cx' import * as DOMHelper from 'utils/DOMHelper' @@ -98,6 +102,7 @@ export function SideBar() { useGitHubAttachCopyFileButton(configContext.value.copyFileButton) useGitHubAttachCopySnippetButton(configContext.value.copySnippetButton) + useGitHubCodeFold(configContext.value.codeFolding) usePJAX() useProgressBar() diff --git a/src/components/settings/SettingsBar.tsx b/src/components/settings/SettingsBar.tsx index 6f84007..3a29e78 100644 --- a/src/components/settings/SettingsBar.tsx +++ b/src/components/settings/SettingsBar.tsx @@ -15,6 +15,7 @@ const WIKI_HOME_LINK = 'https://github.com/EnixCoda/Gitako/wiki' export const wikiLinks = { compressSingletonFolder: `${WIKI_HOME_LINK}/Compress-Singleton-Folder`, changeLog: `${WIKI_HOME_LINK}/Change-Log`, + codeFolding: `${WIKI_HOME_LINK}/Code-folding`, copyFileButton: `${WIKI_HOME_LINK}/Copy-file-and-snippet`, copySnippet: `${WIKI_HOME_LINK}/Copy-file-and-snippet`, createAccessToken: `${WIKI_HOME_LINK}/Access-token-for-Gitako`, @@ -29,18 +30,24 @@ function SettingsBarContent() { const useReloadHint = useStateIO('') const { value: reloadHint } = useReloadHint - const moreFields: SimpleField<'copyFileButton' | 'copySnippetButton'>[] = + const moreFields: SimpleField<'copyFileButton' | 'copySnippetButton'|'codeFolding'>[] = platform === GitHub ? [ + { + key: 'codeFolding', + label: 'Fold source code button', + wikiLink: wikiLinks.codeFolding, + tooltip: `Read more in Gitako's Wiki`, + }, { key: 'copyFileButton', - label: 'Copy file shortcut', + label: 'Copy file button', wikiLink: wikiLinks.copyFileButton, tooltip: `Read more in Gitako's Wiki`, }, { key: 'copySnippetButton', - label: 'Copy snippet shortcut', + label: 'Copy snippet button', wikiLink: wikiLinks.copySnippet, tooltip: `Read more in Gitako's Wiki`, }, diff --git a/src/platforms/GitHub/index.ts b/src/platforms/GitHub/index.ts index 8af9ae4..b6dab28 100644 --- a/src/platforms/GitHub/index.ts +++ b/src/platforms/GitHub/index.ts @@ -8,6 +8,7 @@ import { sortFoldersToFront } from 'utils/treeParser' import * as API from './API' import * as DOMHelper from './DOMHelper' import * as URLHelper from './URLHelper' +export { useGitHubCodeFold } from './useGitHubCodeFold' function processTree(tree: TreeNode[]): TreeNode { // nodes are created from items and put onto tree diff --git a/src/platforms/GitHub/useGitHubCodeFold.tsx b/src/platforms/GitHub/useGitHubCodeFold.tsx new file mode 100644 index 0000000..df71aae --- /dev/null +++ b/src/platforms/GitHub/useGitHubCodeFold.tsx @@ -0,0 +1,162 @@ +import { platform } from 'platforms' +import { useCallback, useEffect } from 'react' +import { useOnPJAXDone } from 'utils/hooks/usePJAX' +import { GitHub } from '.' + +const theCSSClassMark = 'gitako-code-fold-attached' +const theCSSClassMarkWhenDisabled = 'gitako-code-fold-attached-disabled' +const theCSSClassForHiddenLine = 'gitako-code-fold-hidden' +const theCSSClassForToggleElement = 'gitako-code-fold-handler' +const tableSelector = `.blob-wrapper table` +const selectorOfLineNumber = `.blob-num` +const selectorOfLineContent = `.blob-code` + +const theCSSClassForToggleElementOnActive = 'active' +function init() { + type LineNumber = number // alias + const blocks: LineNumber[] = [] // startLine -> exclusiveEndLine + /** + * For example, given such input + * + * 0 |∇ line1 + * 1 |∇ line2 + * 2 | + * 3 |∇ line3 + * 4 | line4 + * 5 | line5 + * + * blocks will be + * 0 -> 5 + * 1 -> 3 + * 2 + * 3 -> 5 + * 4 + * 5 + */ + + const table = document.querySelector(tableSelector) + const lineElements = Array.from(document.querySelectorAll([tableSelector, 'tr'].join(' '))) + if (!table || !lineElements.length) return + if (table.classList.contains(theCSSClassMark)) { + if (table.classList.contains(theCSSClassMarkWhenDisabled)) { + // reactivate + table.classList.remove(theCSSClassMarkWhenDisabled) + } + return + } + table.classList.add(theCSSClassMark) + + // setup blocks + { + type Level = number // measured by leading whitespace amount + const stack: [Level, LineNumber][] = [] + + function trySeal(lineNumber: number, level: number) { + let ignoredTheHighestLevelItem = false + while (stack.length) { + const top = stack.pop()! // safe + const [$LineNumber, $level] = top + + if ($level < level) { + stack.push(top) + break + } else if (!ignoredTheHighestLevelItem) { + ignoredTheHighestLevelItem = true + } else { + blocks[$LineNumber] = lineNumber + } + } + stack.push([lineNumber, level]) + } + + lineElements.forEach((element, lineNumber) => { + const text = element.querySelector(selectorOfLineContent)?.textContent || null + if (text === null) return + + const level = getTextLevel(text) + if (level === -1) return + + trySeal(lineNumber, level) + }) + + trySeal(lineElements.length, -1) + } + + // attach toggle buttons + blocks.forEach((end, line) => { + if (!end) return + + const toggleElement = document.createElement('div') + toggleElement.setAttribute('role', 'button') + toggleElement.classList.add(theCSSClassForToggleElement) + + const lineNumberElement = lineElements[line].querySelector(selectorOfLineNumber) + lineNumberElement?.appendChild(toggleElement) + }) + + const foldLines = new Set() + function toggleLine(line: number) { + // const lineElements = Array.from(document.querySelectorAll(lineSelector)) + const element = lineElements[line] + element.classList.toggle(theCSSClassForToggleElementOnActive) + if (foldLines.has(line)) { + foldLines.delete(line) + } else { + foldLines.add(line) + } + + const linesToHide = new Set() + for (const line of foldLines.values()) { + const end = blocks[line] + for (let $line = line + 1; $line < end; $line++) linesToHide.add($line) + } + + lineElements.forEach((element, i) => { + if (linesToHide.has(i)) element.classList.add(theCSSClassForHiddenLine) + else element.classList.remove(theCSSClassForHiddenLine) + }) + } + + table.addEventListener('click', e => { + if (e.target instanceof HTMLElement) { + if (e.target.classList.contains(theCSSClassForToggleElement)) { + const tr = e.target.parentElement?.parentElement + if (tr) { + toggleLine(lineElements.indexOf(tr)) + e.stopPropagation() + } + } + } + }) +} + +function cancelToggleFeature() { + const table = document.querySelector(tableSelector) + table?.classList.add(theCSSClassMarkWhenDisabled) +} + +function getTextLevel(text: string) { + const leading = countLeadingWhitespace(text) + return leading === text.length ? -1 : leading +} + +function countLeadingWhitespace(text: string) { + let i = 0 + for (; i < text.length; i++) { + // Mixed usage of space and table indentation? ¯\_(ツ)_/¯ + if (!(text[i] === ' ' || text[i] === '\t' || text[i] === '\n')) break + } + return i +} + +export function useGitHubCodeFold(active: boolean) { + const effect = useCallback(() => { + if (platform !== GitHub) return + if (active) { + init() + return () => cancelToggleFeature() + } + }, [active]) + useEffect(effect, [effect]) + useOnPJAXDone(effect) +} diff --git a/src/styles/index.scss b/src/styles/index.scss index 374b9cd..d867b7a 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -13,6 +13,32 @@ $github-header-z-index: 32; $github-pull-request-float-header-z-index: 110; $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header-z-index) + 1; +@mixin interactive-background($default, $hover, $active) { + background-color: $default; + &:hover { + background-color: $hover; + } + &:active { + background-color: $active; + } +} + +@mixin interactive-background-on-before($default, $hover, $active) { + &::before { + background-color: $default; + } + &:hover { + &::before { + background-color: $hover; + } + } + &:active { + &::before { + background-color: $active; + } + } +} + :root { --gitako-width: #{$side-bar-base-width}; } @@ -53,6 +79,75 @@ $minimal-z-index: max($github-header-z-index, $github-pull-request-float-header- } } } + .blob-wrapper table .blob-num { + position: relative; // for positioning + min-width: 60px; + padding-right: 20px; + } + + // cancel code fold if not wrapped with .gitako-code-fold-mark + .gitako-code-fold-handler { + display: none; + } + .gitako-code-fold-hidden { + display: table-cell; + } + + .gitako-code-fold-attached { + tr { + .gitako-code-fold-handler { + display: initial; + position: absolute; + top: 0px; + right: 0px; + width: 20px; + height: 20px; + display: flex; + justify-content: center; + align-items: center; + + &::before { + content: ''; + display: block; + width: 10px; + height: 20px; + cursor: pointer; + user-select: none; + transition: 0.25s ease; + -webkit-mask-image: url('~@primer/octicons/build/svg/chevron-down.svg?inline'); + mask-image: url('~@primer/octicons/build/svg/chevron-down.svg?inline'); + -webkit-mask-size: contain; + mask-size: contain; + -webkit-mask-position: center; + mask-position: center; + } + + @include interactive-background-on-before( + var(--gitako-icon-tertiary), + var(--gitako-icon-primary), + var(--gitako-icon-secondary) + ); + } + + &.active { + .gitako-code-fold-handler { + &::before { + transform: rotate(-90deg); + } + + @include interactive-background-on-before( + var(--gitako-icon-secondary), + var(--gitako-icon-tertiary), + var(--gitako-icon-primary) + ); + } + } + } + + .gitako-code-fold-hidden { + display: none; + } + } // gitee &.git-project { diff --git a/src/utils/config/helper.ts b/src/utils/config/helper.ts index 2bacbcf..20e2960 100644 --- a/src/utils/config/helper.ts +++ b/src/utils/config/helper.ts @@ -18,6 +18,7 @@ export type Config = { searchMode: SearchMode sidebarToggleMode: 'persistent' | 'float' commentToggle: boolean + codeFolding: boolean } enum configKeys { @@ -35,6 +36,7 @@ enum configKeys { searchMode = 'searchMode', sidebarToggleMode = 'sidebarToggleMode', commentToggle = 'commentToggle', + codeFolding = 'codeFolding', } export const defaultConfigs: Config = { @@ -52,6 +54,7 @@ export const defaultConfigs: Config = { searchMode: 'fuzzy', sidebarToggleMode: 'float', commentToggle: true, + codeFolding: true, } const configKeyArray = Object.values(configKeys) From ad3d2b10bc50628682f4d9221398aff009387944 Mon Sep 17 00:00:00 2001 From: EnixCoda Date: Mon, 7 Jun 2021 17:22:59 +0800 Subject: [PATCH 2/2] refactor: clean style code --- src/components/SearchBar.tsx | 2 +- src/styles/index.scss | 131 +++++++++++++++++------------------ 2 files changed, 65 insertions(+), 68 deletions(-) diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index d266f7d..9f21e50 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -35,7 +35,7 @@ export function SearchBar({ onSearch, onFocus, value }: Props) { />