code:
export interface MirrorSite {
  name: string;
  origin: string;
  provider: string;
  technology: string;
}

export const mirrorSites: Array<MirrorSite> = [
  {
    name: 'wt.bgme.me',
    origin: 'https://wt.bgme.me',
    provider: 'Shrink',
    technology: '自行构建',
  },
  {
    name: 'rbq.desi',
    origin: 'https://rbq.desi',
    provider: 'kookxiang',
    technology: '自行构建',
  },
  {
    name: 'wt.0w0.bid',
    origin: 'https://wt.0w0.bid',
    provider: '立音喵',
    technology: 'Cloudflare Workers',
  },
  {
    name: 'wt.umwings.com',
    origin: 'https://wt.umwings.com',
    provider: 'C86',
    technology: '同步构建产物',
  },
];

export const mirrorSitesPlusMainSite: Array<MirrorSite> = [{
  name: 'wt.tepis.me（主站）',
  origin: 'https://wt.tepis.me',
  provider: '琳 缇佩斯',
  technology: 'GitLab Pages',
}, ...mirrorSites];


code:
// ! NOTE: Redirects are only applied after the path fails to match

export const redirects: Array<(input: string) => string> = [
  input => input.replace(/^chapter\/人设卡\/(.*)$/, 'chapter/主线/人设卡/$1'),
  input => input.replace(/^chapter\/支线\/(.*)$/, 'chapter/$1'),
  input => input.replace(/^menu\/章节选择\/所有章节\/支线\/(.*)$/, 'menu/章节选择/所有章节/$1'),
  input => {
    if (input.startsWith('chapter/') && input.endsWith('.html') && !input.endsWith('第-1-章.html')) {
      return input.replace(/^(chapter\/.*)\.html$/, '$1/第-1-章.html');
    } else {
      return input;
    }
  },
];

license: unknown


code:
export const rolePriorities: Map<string, number> = new Map();

const rolesInOrder = [
  '作者',
  '画师',
  '编辑',
];

rolesInOrder.reverse().forEach((role, index) => rolePriorities.set(role, index));

export function getRolePriority(role: string) {
  return rolePriorities.get(role) ?? -1;
}


code:
export const stylePreviewArticle = `
已移除

已移除
`;


code:
export type Tag = string;
export type TagInfo = {
  tag: Tag,
  definition: string,
  details?: Array<string>,
  prerequisite: Array<Tag | [Tag, string]>,
};


code:
export const thanks: Array<{ name: string, link?: string }> = [
  { name: '立音喵' },
  { name: 'lgd_小翅膀' },
  { name: '青葉' },
  { name: 'kn' },
  { name: 'F74nk', link: 'https://t.me/F74nk_K' },
  { name: '杨佳文' },
  { name: '不知名的N姓人士就好' },
  { name: '某不愿透露姓名的N性？' },
  { name: '神楽坂 萌绫' },
  { name: 'Butby' },
  { name: 'NekoCaffeine' },
  { name: 'RainSlide' },
  { name: 'czp', link: 'https://www.hiczp.com' },
  { name: 'kookxiang' },
  { name: '櫻川 紗良' },
  { name: 'Skimige' },
  { name: 'TExL', link: 'http://texas.penguin-logistics.cn' },
  { name: '路人乙' },
  { name: 'pokemonchw', link: 'https://github.com/pokemonchw' },
  { name: '帕蒂卡', link: 'https://github.com/Patika-ailemait' },
  { name: '零件', link: 'https://nekosc.com' },
  { name: '幻梦', link: 'https://t.me/HuanmengQwQ' },
  { name: 'acted咕咕喵', link: 'https://acted.gitlab.io/h3' },
  { name: '重水时雨', link: 'https://t.me/boatmasteronD2O' },
  { name: '神楽坂 紫' },
  { name: 'Runian Lee', link: 'https://t.me/Runian' },
  { name: '琥珀' },
  { name: '为霜' },
  { name: '冰蓮音' },
  { name: 'Testingdoll01' },
  { name: 'VV', link: 'https://www.pixiv.net/users/58170013' },
  { name: '城岭 樱' },
  { name: '熵' },
  { name: '0 级骨钉' },
  { name: '夏克斯' },
  { name: '王木木' },
  { name: 'C86', link: 'https://c86.moe' },
  { name: '炽羽渡尘01' },
  { name: '47AD' },
  { name: '禾裱禾裱' },
  { name: '灵翼' },
  { name: '铱则' },
].sort(() => Math.random() - 0.5);


code:
function sign(x: number) {
  return x > 0 ? 1 : x < 0 ? -1 : 0;
}

function solveQuad(a: number, b: number, c: number) {
  const D = (b ** 2) - 4 * a * c;
  return (-b + sign(a) * (D ** 0.5)) / (2 * a);
}

export class MonoDimensionTransitionControl {
  /** Start time of the current transition */
  private initialTime: number = 0;
  /** Time when the acceleration is reversed of the current transition */
  private reverseTime: number = 0;
  /** Value when the acceleration is reversed of the current transition */
  private reverseValue: number = 0;
  /** Velocity when the acceleration is reversed of the current transition */
  private reverseVelocity: number = 0;
  /** Start velocity of the current transition */
  private lastStartingVelocity: number = 0;
  /** Time when the current transition is finished */
  private finalTime: number = 0;
  /** Target value of the current transition */
  private finalValue: number = 0;
  /** Acceleration in unit per ms */
  private acceleration: number = 0;
  public constructor(
    /** Initial value */
    private lastValue: number,
    accelerationPerSecSq: number,
  ) {
    this.finalValue = lastValue;
    this.acceleration = accelerationPerSecSq / 1000 / 1000;
  }

  public setTarget(targetValue: number) {
    this.lastValue = this.getValue();
    const x = targetValue - this.lastValue;

    if (x === 0) {
      return;
    }

    const now = Date.now();
    const v = this.getVelocity(now);

    // Find a solution for reverse time
    let t = solveQuad(this.acceleration, 2 * v, 0.5 * (v ** 2) / this.acceleration - x);
    if (Number.isNaN(t) || t < 0) {
      // If a reverse time cannot be found with current sign of acceleration, try again with the opposite of acceleration
      this.acceleration = -this.acceleration;
      t = solveQuad(this.acceleration, 2 * v, 0.5 * (v ** 2) / this.acceleration - x);
    }
    const a = this.acceleration;

    this.initialTime = now;
    this.reverseTime = this.initialTime + t;
    this.reverseValue = this.lastValue + 0.5 * a * (t ** 2) + v * t;
    this.reverseVelocity = v + a * t;
    this.finalTime = this.reverseTime + t + v / a;
    this.lastStartingVelocity = v;
    this.finalValue = targetValue;
  }

  public getVelocity(now = Date.now()) {
    return now < this.reverseTime
      ? this.lastStartingVelocity + (now - this.initialTime) * this.acceleration
      : now < this.finalTime
        ? this.lastStartingVelocity + (2 * this.reverseTime - this.initialTime - now) * this.acceleration
        : 0;
  }

  public getValue(now = Date.now()) {
    if (now < this.reverseTime) {
      const t = now - this.initialTime;
      return this.lastValue + 0.5 * this.acceleration * (t ** 2) + this.lastStartingVelocity * t;
    } else if (now < this.finalTime) {
      const t = now - this.reverseTime;
      return this.reverseValue - 0.5 * this.acceleration * (t ** 2) + this.reverseVelocity * t;
    } else {
      return this.finalValue;
    }
  }
  public isFinished(now = Date.now()) {
    return now >= this.finalTime;
  }
}


code:
import { FeatureProvider } from '../../wtcd/FeatureProvider';
import { resolvePath } from '../util/resolvePath';
import { DebugLogger } from '../DebugLogger';
import { Chapter } from '../../Data';
import { AutoCache } from '../data/AutoCache';
import { loadGoogleFonts } from '../util/loadGooleFonts';
import { WTCD_CANVAS_LOADING } from '../constant/messages';

const debugLogger = new DebugLogger('WTCD Feature Provider');
const imageCache = new AutoCache<string, HTMLImageElement>(url => {
  return new Promise((resolve, reject) => {
    const image = document.createElement('img');
    image.onload = () => resolve(image);
    image.onerror = () => reject(new Error(`Failed to load ${url}.`));
    image.src = url;
  });
}, new DebugLogger('WTCD Image Cache'));
const fontsCache = new AutoCache<string, string>(identifier => {
  // Identifier should look like: "googleFonts:ZCOOL KuaiLe"
  const modeSeparatorIndex = identifier.indexOf(':');
  if (modeSeparatorIndex === -1) {
    return Promise.reject(new Error('Cannot find mode separator ":".'));
  }
  const mode = identifier.substr(0, modeSeparatorIndex);
  if (mode !== 'googleFonts') {
    return Promise.reject(new Error(`Unknown mode: "${mode}".`));
  }
  return loadGoogleFonts(identifier.substr(modeSeparatorIndex + 1));
}, new DebugLogger('WTCD Font Cache'));
export class WTCDFeatureProvider extends FeatureProvider {
  public constructor(
    private chapter: Chapter
  ) {
    super();
  }
  public loadImage(path: string): Promise<CanvasImageSource> {
    if (!path.startsWith('./')) {
      return Promise.reject(new Error('Path has to be relative and start ' +
      `with "./". Received: "${path}"`));
    }
    let resolved = resolvePath(
      'chapters',
      this.chapter.htmlRelativePath,
      '..',
      path.substr(2),
    );
    if (resolved === null) {
      return Promise.reject(new Error(`Failed to resolve path: "${path}".`));
    }
    resolved = '/' + resolved;
    debugLogger.log('Resolved from:', path, 'to:', resolved);
    return imageCache.get(resolved);
  }
  public loadFont(identifier: string): Promise<string> {
    return fontsCache.get(identifier);
  }
  public drawLoadingCanvas($canvas: HTMLCanvasElement) {
    const ctx = $canvas.getContext('2d')!;
    ctx.font = `${$canvas.width / 40}px sans-serif`;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.strokeStyle = 'rgba(0,0,0,0.8)';
    ctx.lineJoin = 'round';
    ctx.lineCap = 'round';
    ctx.lineWidth = $canvas.width / 200;
    ctx.strokeText(WTCD_CANVAS_LOADING, $canvas.width / 2, $canvas.height / 2);
    ctx.fillStyle = 'white';
    ctx.fillText(WTCD_CANVAS_LOADING, $canvas.width / 2, $canvas.height / 2);
  }
}


code:
import { GameReader } from '../../wtcd/GameReader';
import { WTCDRoot } from '../../wtcd/types';
import {
  WTCD_GAME_LOAD,
  WTCD_GAME_LOAD_CANCEL,
  WTCD_GAME_LOAD_OK,
  WTCD_GAME_LOAD_QUICK,
  WTCD_GAME_LOAD_TITLE,
  WTCD_GAME_NO_DESC,
  WTCD_GAME_QUICK_LOAD,
  WTCD_GAME_QUICK_LOAD_CONFIRM_CANCEL,
  WTCD_GAME_QUICK_LOAD_CONFIRM_CONFIRM,
  WTCD_GAME_QUICK_LOAD_CONFIRM_DESC,
  WTCD_GAME_QUICK_LOAD_CONFIRM_TITLE,
  WTCD_GAME_QUICK_LOAD_NOT_EXIST,
  WTCD_GAME_QUICK_LOAD_OK,
  WTCD_GAME_QUICK_SAVE,
  WTCD_GAME_QUICK_SAVE_OK,
  WTCD_GAME_RESTART,
  WTCD_GAME_RESTART_ALL,
  WTCD_GAME_RESTART_ALL_DESC,
  WTCD_GAME_RESTART_CANCEL,
  WTCD_GAME_RESTART_DECISION_ONLY,
  WTCD_GAME_RESTART_DECISION_ONLY_DESC,
  WTCD_GAME_RESTART_DESC,
  WTCD_GAME_RESTART_OK,
  WTCD_GAME_RESTART_TITLE,
  WTCD_GAME_SAVE,
  WTCD_GAME_SAVE_CANCEL,
  WTCD_GAME_SAVE_NEW,
  WTCD_GAME_SAVE_OK,
  WTCD_GAME_SAVE_OVERWRITE_CANCEL,
  WTCD_GAME_SAVE_OVERWRITE_CONFIRM,
  WTCD_GAME_SAVE_OVERWRITE_TITLE,
  WTCD_GAME_SAVE_TITLE,
} from '../constant/messages';
import { wtcdGameQuickLoadConfirm } from '../data/settings';
import { DebugLogger } from '../DebugLogger';
import { h } from '../hs';
import { formatTimeSimple } from '../util/formatTime';
import { Content, ContentBlock } from './contentControl';
import { createWTCDErrorMessageFromError } from './createWTCDErrorMessageFromError';
import { createHint } from './hintControl';
import { confirm, Modal } from './modalControl';
import { FeatureProvider, defaultFeatureProvider } from '../../wtcd/FeatureProvider';

const debugLogger = new DebugLogger('WTCD Game Reader UI');

export class WTCDGameReaderUI {
  private reader: GameReader;
  private controlsBlock!: ContentBlock;
  private mainBlock: ContentBlock | null = null;
  public constructor(
    private content: Content,
    docIdentifier: string,
    private slideAnimation: boolean,
    wtcdRoot: WTCDRoot,
    private elementPreprocessor: ($element: HTMLElement) => void,
    featureProvider: FeatureProvider = defaultFeatureProvider,
  ) {
    this.reader = new GameReader(
      docIdentifier,
      wtcdRoot,
      this.onOutput,
      this.onError,
      featureProvider,
    );
  }
  private started = false;
  public start() {
    if (this.started) {
      throw new Error('Already started.');
    }
    this.started = true;
    this.controlsBlock = this.content.addBlock({
      initElement: h('div.wtcd-game-control',
        h('.button-container', [
          h('div', { onclick: this.onClickRestart }, WTCD_GAME_RESTART),
          h('div', { onclick: this.onClickSave }, WTCD_GAME_SAVE),
          h('div', { onclick: this.onClickLoad }, WTCD_GAME_LOAD),
          h('div', { onclick: this.onClickQuickSave }, WTCD_GAME_QUICK_SAVE),
          h('div', { onclick: this.onClickQuickLoad }, WTCD_GAME_QUICK_LOAD),
        ]),
      ) as HTMLDivElement,
    });
    const startTime = Date.now();
    this.reader.start();
    debugLogger.log(`Game loaded in ${Date.now() - startTime}ms.`);
  }
  private onClickRestart = () => {
    const modal = new Modal(h('div', [
      h('h1', WTCD_GAME_RESTART_TITLE),
      h('p', WTCD_GAME_RESTART_DESC),
      h('ul', [
        h('li', WTCD_GAME_RESTART_ALL_DESC),
        h('li', WTCD_GAME_RESTART_DECISION_ONLY_DESC),
      ]),
      h('.button-container', [
        h('div', { onclick: () => {
          this.reader.reset(true);
          createHint(WTCD_GAME_RESTART_OK, 1000);
          modal.close();
        }}, WTCD_GAME_RESTART_ALL),
        h('div', { onclick: () => {
          this.reader.reset(false);
          createHint(WTCD_GAME_RESTART_OK, 1000);
          modal.close();
        }}, WTCD_GAME_RESTART_DECISION_ONLY),
        h('div', { onclick: () => modal.close() }, WTCD_GAME_RESTART_CANCEL),
      ]),
    ]));
    modal.setDismissible();
    modal.open();
  }
  private onClickSave = () => {
    const modal = new Modal(h('div', [
      h('h1', WTCD_GAME_SAVE_TITLE),
      h('.wtcd-save-button-list', this.reader.getSaves().map(
        (save, saveIndex) => {
          if (saveIndex === 0) {
            return null; // quick save
          }
          if (save === null) {
            return h('.new', {
              onclick: () => {
                this.reader.save(saveIndex);
                createHint(WTCD_GAME_SAVE_OK, 1000);
                modal.close();
              },
            }, WTCD_GAME_SAVE_NEW);
          } else {
            return h('.save', {
              onclick: () => {
                confirm(
                  WTCD_GAME_SAVE_OVERWRITE_TITLE,
                  '',
                  WTCD_GAME_SAVE_OVERWRITE_CONFIRM,
                  WTCD_GAME_SAVE_OVERWRITE_CANCEL,
                ).then(result => {
                  if (result) {
                    this.reader.save(saveIndex);
                    createHint(WTCD_GAME_SAVE_OK, 1000);
                    modal.close();
                  }
                });
              },
            }, [
              h('.id', String(saveIndex)),
              h('.info', [
                h('.state-desc', save.desc === ''
                  ? WTCD_GAME_NO_DESC
                  : save.desc),
                h('.date', formatTimeSimple(save.date)),
              ]),
            ]);
          }
        },
      )),
      h('.button-container', { style: { 'margin-top': '1.2vh' } }, [
        h('div', { onclick: () => modal.close() }, WTCD_GAME_SAVE_CANCEL),
      ]),
    ]));
    modal.setDismissible();
    modal.open();
  }
  private onClickLoad = () => {
    const modal = new Modal(h('div', [
      h('h1', WTCD_GAME_LOAD_TITLE),
      h('.wtcd-save-button-list', this.reader.getSaves().map(
        (save, saveIndex) => {
          if (save === null)  {
            return null;
          }
          return h('.save', {
            onclick: () => {
              this.reader.load(saveIndex);
              createHint(WTCD_GAME_LOAD_OK, 1000);
              modal.close();
            },
          }, [
            saveIndex !== 0
              ? h('.id', String(saveIndex))
              : h('.small.id', WTCD_GAME_LOAD_QUICK),
            h('.info', [
              h('.state-desc', save.desc === ''
                ? WTCD_GAME_NO_DESC
                : save.desc),
              h('.date', formatTimeSimple(save.date)),
            ]),
          ]);
        },
      )),
      h('.button-container', { style: { 'margin-top': '1.2vh' } }, [
        h('div', { onclick: () => modal.close() }, WTCD_GAME_LOAD_CANCEL),
      ]),
    ]));
    modal.setDismissible();
    modal.open();
  }
  private onClickQuickSave = () => {
    this.reader.save(0);
    createHint(WTCD_GAME_QUICK_SAVE_OK, 1000);
  }
  private onClickQuickLoad = () => {
    if (this.reader.getSaves()[0] === null) {
      createHint(WTCD_GAME_QUICK_LOAD_NOT_EXIST, 3000);
      return;
    }
    if (wtcdGameQuickLoadConfirm.getValue()) {
      confirm(
        WTCD_GAME_QUICK_LOAD_CONFIRM_TITLE,
        WTCD_GAME_QUICK_LOAD_CONFIRM_DESC,
        WTCD_GAME_QUICK_LOAD_CONFIRM_CONFIRM,
        WTCD_GAME_QUICK_LOAD_CONFIRM_CANCEL,
      ).then(result => {
        if (result) {
          this.reader.load(0);
          createHint(WTCD_GAME_QUICK_LOAD_OK, 1000);
        }
      });
    } else {
      this.reader.load(0);
      createHint(WTCD_GAME_QUICK_LOAD_OK, 1000);
    }
  }
  private onOutput = (content: HTMLDivElement) => {
    this.elementPreprocessor(content);
    if (this.mainBlock === null) {
      debugLogger.log('Initialize main block.');
      this.mainBlock = this.content.addBlock({
        initElement: content,
        slidable: true,
      });
    } else {
      debugLogger.log('Updating main block.');
      this.content.scrollTo(this.controlsBlock.element.offsetTop);
      if (this.slideAnimation) {
        this.mainBlock.slideReplace(content);
      } else {
        this.mainBlock.directReplace(content);
      }
    }
  }
  private onError = (error: Error) => {
    debugLogger.warn('Game reader reported error.');
    this.content.addBlock({
      initElement: createWTCDErrorMessageFromError(error),
    });
  }
}


code:
import { backendUrl } from './backendControl';
import { AutoCache } from '../data/AutoCache';
import { DebugLogger } from '../DebugLogger';
import { loadChapterEvent } from './chapterControl';

// 《可穿戴科技》统计系统
// 本系统服务端开源，并且不收集任何个人信息。
// 其存在目的仅仅是为了让琳知道有多少读者在看，以满足她的虚荣心。
//
// 服务端源代码：https://github.com/SCLeoX/wt_analytics

const analyticsCache = new AutoCache<string, any>(relativePath => {
  return fetch(backendUrl + '/stats/count', {
    method: 'POST',
    body: relativePath,
  });
}, new DebugLogger('Analytics Cache'));

loadChapterEvent.on((chapterRelativePath) => {
  if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
    return;
  }
  const startPageSpecifier = window.location.hash.split('~')[0]; // Remove arguments
  // Wait for 5 seconds in order to confirm the user is still reading the same
  // chapter.
  setTimeout(() => {
    if (startPageSpecifier !== window.location.hash.split('~')[0]) {
      return;
    }
    analyticsCache.get(chapterRelativePath);
  }, 5000);
});


code:
import {
  BACKEND_ERROR_COMMENT_TOO_LONG,
  BACKEND_ERROR_COMMENT_TOO_SHORT,
  BACKEND_ERROR_EMAIL_DUPLICATED,
  BACKEND_ERROR_EMAIL_INVALID,
  BACKEND_ERROR_EMAIL_TOO_LONG,
  BACKEND_ERROR_NAME_DUPLICATED,
  BACKEND_ERROR_NAME_INVALID,
  BACKEND_ERROR_NAME_TOO_LONG,
  BACKEND_ERROR_NAME_TOO_SHORT,
  BACKEND_ERROR_TOKEN_INVALID,
  BACKEND_ERROR_UNKNOWN
} from '../constant/messages';

export const backendUrl = 'https://wtb.tepis.me';

export enum ErrorCode {
  NAME_DUPLICATED = 1,
  EMAIL_DUPLICATED = 2,
  NAME_TOO_LONG = 3,
  EMAIL_TOO_LONG = 4,
  EMAIL_INVALID = 5,
  COMMENT_TOO_LONG = 6,
  TOKEN_INVALID = 7,
  NAME_TOO_SHORT = 8,
  COMMENT_TOO_SHORT = 9,
  NAME_INVALID = 10,
}

export function getErrorMessage(errorCode: ErrorCode): string {
  switch (errorCode) {
    case ErrorCode.NAME_DUPLICATED:
      return BACKEND_ERROR_NAME_DUPLICATED;
    case ErrorCode.EMAIL_DUPLICATED:
      return BACKEND_ERROR_EMAIL_DUPLICATED;
    case ErrorCode.NAME_TOO_LONG:
      return BACKEND_ERROR_NAME_TOO_LONG;
    case ErrorCode.EMAIL_TOO_LONG:
      return BACKEND_ERROR_EMAIL_TOO_LONG;
    case ErrorCode.EMAIL_INVALID:
      return BACKEND_ERROR_EMAIL_INVALID;
    case ErrorCode.COMMENT_TOO_LONG:
      return BACKEND_ERROR_COMMENT_TOO_LONG;
    case ErrorCode.TOKEN_INVALID:
      return BACKEND_ERROR_TOKEN_INVALID;
    case ErrorCode.NAME_TOO_SHORT:
      return BACKEND_ERROR_NAME_TOO_SHORT;
    case ErrorCode.COMMENT_TOO_SHORT:
      return BACKEND_ERROR_COMMENT_TOO_SHORT;
    case ErrorCode.NAME_INVALID:
      return BACKEND_ERROR_NAME_INVALID;
    default:
      return BACKEND_ERROR_UNKNOWN;
  }
}

export type ResultOf<T> = T extends (...[]: any) => Promise<infer U> ? U : never;
export function fetchInit(token: string): Promise<{ success: false } | {
  success: true,
  user_name: string,
  email?: string,
  display_name: string,
  mentions: number,
}> {
  return fetch(`${backendUrl}/user/init`, {
    cache: 'no-cache',
    method: 'POST',
    headers: new Headers({
      'Content-Type': 'application/json'
    }),
    body: JSON.stringify({ token }),
  }).then(response => response.json());
}

export function fetchUpdateProfile(token: string, displayName: string, email?: string): Promise<{ success: true } | {
  success: false,
  code: ErrorCode,
}> {
  return fetch(`${backendUrl}/user/updateProfile`, {
    cache: 'no-cache',
    method: 'POST',
    headers: new Headers({
      'Content-Type': 'application/json'
    }),
    body: JSON.stringify({ token, display_name: displayName, email }),
  }).then(response => response.json());
}

export interface CommentData {
  body: string;
  create_timestamp: number;
  update_timestamp: number;
  relative_path: string;
  id: number;
  user: {
    avatar_url: string;
    user_name: string;
    display_name: string;
  };
}

export function fetchGetChapterComments(pageName: string): Promise<Array<CommentData>> {
  return fetch(`${backendUrl}/comment/getChapter?relative_path=${pageName}`, {
    cache: 'no-cache',
    method: 'GET',
  }).then(response => response.json());
}

export function fetchGetRecentComments(): Promise<Array<CommentData>> {
  return fetch(`${backendUrl}/comment/getRecent`, {
    cache: 'no-cache',
    method: 'GET',
  }).then(response => response.json());
}

export function fetchGetRecentMentionedComments(token?: string): Promise<Array<CommentData>> {
  if (token === undefined) {
    return Promise.resolve([]);
  }
  return fetch(`${backendUrl}/comment/getRecentMentioned`, {
    cache: 'no-cache',
    method: 'POST',
    headers: new Headers({
      'Content-Type': 'application/json'
    }),
    body: JSON.stringify({ token }),
  }).then(response => response.json());
}

export function fetchSendComment(token: string, pageName: string, content: string): Promise<{ success: true } | {
  success: false,
  code: ErrorCode,
}> {
  return fetch(`${backendUrl}/comment/send`, {
    cache: 'no-cache',
    method: 'POST',
    headers: new Headers({
      'Content-Type': 'application/json'
    }),
    body: JSON.stringify({
      token,
      relative_path: pageName,
      content,
    }),
  }).then(response => response.json());
}

export function fetchRegister(displayName: string, email?: string): Promise<{
  success: true,
  token: string,
  user_name: string,
} | {
  success: false,
  code: ErrorCode,
}> {
  return fetch(`${backendUrl}/user/register`, {
    cache: 'no-cache',
    method: 'POST',
    headers: new Headers({
      'Content-Type': 'application/json'
    }),
    body: JSON.stringify({
      display_name: displayName,
      email,
    }),
  }).then(response => response.json());
}

export function fetchDeleteComment(token: string, commentId: number): Promise<void> {
  return fetch(`${backendUrl}/comment/delete`, {
    cache: 'no-cache',
    method: 'POST',
    headers: new Headers({
      'Content-Type': 'application/json'
    }),
    body: JSON.stringify({
      token,
      comment_id: commentId,
    }),
  }).then(() => undefined);
}


code:
import { $e } from '../$e';
import { Chapter, WTCDChapter, WTCDChapterFlow, WTCDChapterGame } from '../../Data';
import { FlowReader } from '../../wtcd/FlowReader';
import { WTCDParseResult, WTCDRoot } from '../../wtcd/types';
import { CHAPTER_FAILED, GO_TO_MENU, NEXT_CHAPTER, PREVIOUS_CHAPTER } from '../constant/messages';
import { AutoCache } from '../data/AutoCache';
import { authorInfoMap, ChapterContext, relativePathLookUpMap } from '../data/data';
import { chapterHref, pageHref, tagSearchHref } from '../data/hrefs';
import { updateChapterProgress } from '../data/readingProgress';
import { chapterRecommendationCount, earlyAccess, gestureSwitchChapter, showAbandonedChapters } from '../data/settings';
import { DebugLogger } from '../DebugLogger';
import { Event } from '../Event';
import { h } from '../hs';
import { SwipeDirection, swipeEvent } from '../input/gestures';
import { ArrowKey, arrowKeyPressEvent, escapeKeyPressEvent } from '../input/keyboard';
import { getTextNodes, id } from '../util/DOM';
import { formatRelativePath } from '../util/formatRelativePath';
import { removePotentialSuffix } from '../util/string';
import { tagSpan } from '../util/tag';
import { smartThrottle } from '../util/throttle';
import { resetUrlArgumentsTo } from '../util/urlArguments';
import { addPushHelperBlock } from './pushHelper';
import { loadChapterComments } from './commentsControl';
import { loadContactInfo } from './contactInfoControl';
import { Content, ContentBlock, ContentBlockSide, ContentBlockStyle, focus, newContent, Side } from './contentControl';
import { createWTCDErrorMessage } from './createWTCDErrorMessage';
import { createWTCDErrorMessageFromError } from './createWTCDErrorMessageFromError';
import { followQuery } from './followQuery';
import { Layout, setLayout } from './layoutControl';
import { enterMenuMode } from './menuControl';
import { isAnyModalOpened, Modal } from './modalControl';
import { resetProgressIndicator, setNavbarPath, setProgressIndicator } from './navbarControl';
import { processElements } from './processElements';
import { recommend } from './recommendControl';
import { WTCDFeatureProvider } from './WTCDFeatureProvider';
import { WTCDGameReaderUI } from './WTCDGameReaderUI';
import { loadWtcupInfo, loadWtcupInfoPre } from './wtcupInfoControl';
import { addPreloaderBlock } from './preloaderBlock';

type Selection = [number, number, number, number];

const debugLogger = new DebugLogger('Chapter Control');

export const loadChapterEvent = new Event<string>();

/** This is only used for determining the direction of animation when changing chapter. */
let lastChapterCtx: ChapterContext | null = null;

const select = (textNodes: Array<Text>, [
  anchorNodeIndex,
  anchorOffset,
  focusNodeIndex,
  focusOffset,
]: Selection) => {
  const anchorNode = textNodes[anchorNodeIndex];
  const focusNode = textNodes[focusNodeIndex];
  if (anchorNode === undefined || focusNode === undefined) {
    return;
  }
  document.getSelection()!.setBaseAndExtent(
    anchorNode,
    anchorOffset,
    focusNode,
    focusOffset,
  );
  const element = anchorNode.parentElement;
  if (element !== null && (typeof element.scrollIntoView) === 'function') {
    element.scrollIntoView();
  }
};

export function canChapterShown(chapter: Chapter) {
  return (earlyAccess.getValue() || !chapter.isEarlyAccess)
    && (!chapter.hidden)
    && (showAbandonedChapters.getValue() || !chapter.abandoned);
}

function findNextChapter(chapterCtx: ChapterContext) {
  const index = chapterCtx.inFolderIndex;
  const folderChapters = chapterCtx.folder.children;
  for (let i = index + 1; i < folderChapters.length; i++) {
    const child = folderChapters[i];
    if (child.type !== 'folder' && canChapterShown(child)) {
      return child;
    }
  }
  return null;
}

function findPreviousChapter(chapterCtx: ChapterContext) {
  const index = chapterCtx.inFolderIndex;
  const folderChapters = chapterCtx.folder.children;
  for (let i = index - 1; i >= 0; i--) {
    const child = folderChapters[i];
    if (child.type !== 'folder' && canChapterShown(child)) {
      return child;
    }
  }
  return null;
}

function getChapterRelativeLink(chapter: Chapter) {
  return `${window.location.pathname}#/chapter/${chapter.htmlRelativePath}`;
}

function navigateToChapter(chapter: Chapter) {
  window.location.href = getChapterRelativeLink(chapter);
}

const chaptersCache = new AutoCache<string, string>(
  chapterHtmlRelativePath => {
    const url = `./chapters/${chapterHtmlRelativePath}`;
    debugLogger.log(`Loading chapter from ${url}.`);
    return fetch(url).then(response => response.text());
  },
  new DebugLogger('Chapters Cache'),
);

function updateNavbar(htmlRelativePath: string) {
  setNavbarPath([
    {
      display: '所有章节',
      hash: '#/menu/章节选择/所有章节',
    },
    ...htmlRelativePath.split('/').map((segment, index, split) => {
      segment = segment.replace(/-/g, ' ');
      if (index === split.length - 1) {
        // Last element
        return {
          display: removePotentialSuffix(segment, '.html'),
          hash: null,
        };
      } else {
        return {
          display: segment,
          hash: `#/menu/章节选择/所有章节/${split.slice(0, index + 1).join('/')}`,
        };
      }
    })
  ]);
}

function addAuthorsInfoBlock(content: Content, chapter: Chapter) {
  if (chapter.authors.length > 0) {
    const $authorsDiv = h('.authors',
      h('h3', '本文作者'),
      h('.outer-container',
        ...chapter.authors.map(authorRole => {
          const authorInfo = authorInfoMap.get(
            authorRole.name
          ) ?? {
            name: authorRole.name,
            avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(authorRole.name)}`,
          };
          return h('.author-container',
            {
              onclick: () => {
                window.history.pushState(null, document.title, pageHref(`author/${authorInfo.name}`));
                followQuery();
              },
            },
            h('img.avatar', {
              src: authorInfo.avatar,
            }),
            h('.author-role-container',
              h('.role', authorRole.role),
              h('.name', authorInfo.name),
            ),
          );
        }),
        h('.reprint', {
          href: '#',
          onclick: ((event: any) => {
            event.preventDefault();
            const modal = new Modal(h('div',
              h('h1', '转载须知'),
              h('p',
                '《可穿戴科技》内所有文章均以 ',
                h('a.regular', {
                  target: '_blank',
                  href: 'https://creativecommons.org/licenses/by-nc-nd/4.0/',
                  rel: 'noopener noreferrer',
                }, 'CC BY-NC-ND 4.0'),
                ' 协议发布。转载时请注明以下信息：',
              ),
              h('pre.wrapping', h('code',
                '本文内容摘自《可穿戴科技》（https://wt.tepis.me）。' +
                  chapter.authors.map(authorInfo => authorInfo.role + '：' + authorInfo.name).join('，') +
                  '。本文以 CC BY-NC-ND 4.0 协议发布，转载请注明上述所有信息。'
              )),
              h('.button-container', [
                h('div', {
                  onclick: () => {
                    modal.close();
                  },
                }, '我知道了'),
              ]),
            ));
            modal.setDismissible();
            modal.open();
          }),
        }, h('div', '转载须知')),
      ),
    ) as HTMLDivElement;
    content.addBlock({ initElement: $authorsDiv, side: ContentBlockSide.LEFT });
  }
}

function addTagInfoBlock(content: Content, chapter: Chapter) {
  if (chapter.tags !== undefined) {
    content.addBlock({
      initElement: (

本文标签

          { chapter.tags.map(tag => (

              { tagSpan(tag, false) }

          )) }

      ) as HTMLDivElement,
      side: ContentBlockSide.LEFT,
    });
  } else if (!chapter.htmlRelativePath.includes('META')) {
    content.addBlock({
      initElement: (

本文标签缺失

本文目前没有标签。如果你愿意帮忙打标签，请至此查看协助打标签的方式。

      ),
      side: ContentBlockSide.LEFT,
    });
  }
}

function addRecommendedBlock(content: Content, chapter: Chapter) {
  if (chapterRecommendationCount.getValue() === 0) {
    return;
  }
  if (chapter.tags !== undefined && chapter.tags.length > 0) {
    recommend(chapter).then(results => {
      if (results === null) {
        return;
      }
      results = results
        .filter(({ chapter: targetChapter }) => targetChapter.htmlRelativePath !== chapter.htmlRelativePath)
        .filter((_, index) => index < (chapterRecommendationCount.getValue() * 5));
      content.addBlock({
        initElement: (

类似文章推荐

              { results.map(({ chapter: recommendedChapter, score }) => (

                    [{ (score * 100).toFixed(0) }%] { formatRelativePath(recommendedChapter.htmlRelativePath) }

              )) }

你也可以点此查看《可穿戴科技》当前使用的推荐算法。

        ) as HTMLDivElement,
        side: ContentBlockSide.RIGHT,
      });
    }).catch(error => debugLogger.error(error));

  }
}

function loadChapter(
  chapterHtmlRelativePath: string,
  selection?: Selection,
  side: Side = Side.LEFT,
) {
  debugLogger.log('Load chapter', chapterHtmlRelativePath, 'with selection', selection);
  loadChapterEvent.emit(chapterHtmlRelativePath);
  const chapterCtx = relativePathLookUpMap.get(chapterHtmlRelativePath)!;
  lastChapterCtx = chapterCtx;

  document.title = formatRelativePath(chapterHtmlRelativePath) + ' - 可穿戴科技';
  window.history.replaceState(null, document.title);

  updateNavbar(chapterHtmlRelativePath);

  const content = newContent(side);

  maybeAddEarlyAccessWarning(chapterCtx, content);
  maybeAddAbandonedWarning(chapterCtx, content);
  const loadingBlock = addPreloaderBlock(content);

  setLayout(Layout.MAIN);

  function loadPrevChapter() {
    const previousChapter = findPreviousChapter(chapterCtx);
    if (previousChapter !== null) {
      navigateToChapter(previousChapter);
    }
  }
  function loadNextChapter() {
    const nextChapter = findNextChapter(chapterCtx);
    if (nextChapter !== null) {
      navigateToChapter(nextChapter);
    }
  }

  registerSwipeEvents(loadPrevChapter, loadNextChapter, content);
  registerArrowKeyEvents(loadPrevChapter, loadNextChapter, content);
  attachEscapeEvent(content);
  chaptersCache.get(chapterHtmlRelativePath).then(text => {
    if (content.isDestroyed) {
      debugLogger.log('Chapter loaded, but abandoned since the original ' +
        'content page is already destroyed.');
      return;
    }
    debugLogger.log('Chapter loaded.');

    loadingBlock.directRemove();
    loadWtcupInfoPre(content, chapterHtmlRelativePath);
    content.appendLeftSideContainer();
    const mainBlock = insertContent(content, text, chapterCtx.chapter);
    const postMainBlock = mainBlock ?? content.addBlock();
    content.appendRightSideContainer();
    addAuthorsInfoBlock(content, chapterCtx.chapter);
    addTagInfoBlock(content, chapterCtx.chapter);
    addPushHelperBlock(content, chapterCtx.chapter);
    addRecommendedBlock(content, chapterCtx.chapter);

    const textNodes = getTextNodes(postMainBlock.element);
    restoreSelections(selection, textNodes);
    registerSelectionChangeListener(textNodes, content);
    addPageSwitcherBlock(chapterCtx, postMainBlock);
    startUpdatingProgressBar(mainBlock, content, chapterCtx);

    // Re-focus the rect so it is arrow-scrollable
    setTimeout(() => {
      focus();
    }, 1);
    loadWtcupInfo(content);
    loadContactInfo(content);
    loadChapterComments(chapterCtx, content);
  }).catch(error => {
    debugLogger.error(`Failed to load chapter.`, error);
    loadingBlock.element.innerText = CHAPTER_FAILED;
  });
}

export enum ErrorType {
  COMPILE,
  RUNTIME,
  INTERNAL,
}

function registerSelectionChangeListener(textNodes: Array<Text>, content: Content) {
  const selectionChangeListener = getSelectionChangeListener(textNodes);
  document.addEventListener('selectionchange', selectionChangeListener);
  content.leavingEvent.once(() => {
    document.removeEventListener('selectionchange', selectionChangeListener);
  });
}

function restoreSelections(selection: Selection | undefined, textNodes: Array<Text>) {
  if (selection !== undefined) {
    if (id('warning') === null) {
      select(textNodes, selection);
    } else {
      id('warning').addEventListener('click', () => {
        select(textNodes, selection);
      });
    }
  }
}

function maybeAddAbandonedWarning(chapterCtx: ChapterContext, content: Content) {
  if (chapterCtx.chapter.abandoned) {
    content.addBlock({
      initElement: (

弃坑警告

本文作者已弃坑，文章内容并不完整。

      ),
      style: ContentBlockStyle.WARNING,
    });
  }
}

function maybeAddEarlyAccessWarning(chapterCtx: ChapterContext, content: Content) {
  if (chapterCtx.chapter.isEarlyAccess) {
    content.addBlock({
      initElement: (

编写中章节

请注意，本文正在编写中，因此可能会含有未完成的句子或是尚未更新的信息。

      ),
      style: ContentBlockStyle.WARNING,
    });
  }
}

function addPageSwitcherBlock(chapterCtx: ChapterContext, postMainBlock: ContentBlock) {
  const prevChapter = findPreviousChapter(chapterCtx);
  const nextChapter = findNextChapter(chapterCtx);
  postMainBlock.element.appendChild(h('div.page-switcher', [
    // 上一章
    (prevChapter !== null)
      ? h('a', {
        href: getChapterRelativeLink(prevChapter),
      }, PREVIOUS_CHAPTER)
      : null,

    // 返回菜单
    h('a', {
      href: window.location.pathname,
      onclick: (event: MouseEvent) => {
        event.preventDefault();
        enterMenuMode();
      },
    }, GO_TO_MENU),

    // 下一章
    (nextChapter !== null)
      ? h('a', {
        href: getChapterRelativeLink(nextChapter),
      }, NEXT_CHAPTER)
      : null,
  ]));
}

function startUpdatingProgressBar(mainBlock: ContentBlock | undefined, content: Content, chapterCtx: ChapterContext) {
  if (mainBlock !== undefined) {
    const updateProgress = smartThrottle(() => {
      const scrollableRange = mainBlock.element.clientHeight - content.$element.clientHeight;
      let progress;
      if (scrollableRange > 0) {
        progress = (content.$element.scrollTop - mainBlock.element.offsetTop) / scrollableRange;
      } else {
        progress = (content.$element.scrollTop + content.$element.clientHeight) > (mainBlock.element.offsetTop + mainBlock.element.clientHeight) ? 1 : 0;
      }
      progress = Math.min(progress, 1);
      progress = Math.max(progress, 0);
      setProgressIndicator(progress);
      updateChapterProgress(chapterCtx.chapter.htmlRelativePath, progress)
        .catch(error => debugLogger.warn(`Failed to save chapter progress for ${chapterCtx.chapter.htmlRelativePath}:`, error));
    }, 100);
    content.scrollEvent.on(() => {
      updateProgress();
    });
    content.leavingEvent.on(() => {
      resetProgressIndicator();
    });
  }
}

function getSelectionChangeListener(textNodes: Array<Text>) {
  return smartThrottle(() => {
    const selection = document.getSelection();
    const urlArguments = new Map<string, string>();
    if (selection !== null) {
      const anchor = ((selection.anchorNode instanceof HTMLElement)
        ? selection.anchorNode.firstChild
        : selection.anchorNode) as Text;
      const anchorNodeIndex = textNodes.indexOf(anchor);
      const focus = ((selection.focusNode instanceof HTMLElement)
        ? selection.focusNode.firstChild
        : selection.focusNode) as Text;
      const focusNodeIndex = textNodes.indexOf(focus);
      if ((anchorNodeIndex !== -1) && (focusNodeIndex !== -1) &&
        !(anchorNodeIndex === focusNodeIndex && selection.anchorOffset === selection.focusOffset)) {
        if ((anchorNodeIndex < focusNodeIndex) ||
          (anchorNodeIndex === focusNodeIndex && selection.anchorOffset < selection.focusOffset)) {
          urlArguments.set('selection', `${anchorNodeIndex},${selection.anchorOffset},${focusNodeIndex},${selection.focusOffset}`);
        } else {
          urlArguments.set('selection', `${focusNodeIndex},${selection.focusOffset},${anchorNodeIndex},${selection.anchorOffset}`);
        }
      }
    }
    resetUrlArgumentsTo(urlArguments);
  }, 250);
}

function attachEscapeEvent(content: Content) {
  escapeKeyPressEvent.onUntil(() => {
    if (isAnyModalOpened()) {
      return;
    }
    enterMenuMode();
  }, content.leavingEvent);
}

function registerArrowKeyEvents(loadPrevChapter: () => void, loadNextChapter: () => void, content: Content) {
  arrowKeyPressEvent.onUntil(arrowKey => {
    if (isAnyModalOpened()) {
      return;
    }
    if (arrowKey === ArrowKey.LEFT) {
      loadPrevChapter();
    } else if (arrowKey === ArrowKey.RIGHT) {
      loadNextChapter();
    }
  }, content.leavingEvent);
}

function registerSwipeEvents(loadPrevChapter: () => void, loadNextChapter: () => void, content: Content) {
  swipeEvent.onUntil(direction => {
    if (!gestureSwitchChapter.getValue()) {
      return;
    }
    if (isAnyModalOpened()) {
      return;
    }
    if (direction === SwipeDirection.TO_RIGHT) {
      // 上一章
      loadPrevChapter();
    } else if (direction === SwipeDirection.TO_LEFT) {
      // 下一章
      loadNextChapter();
    }
  }, content.leavingEvent);
}

function insertContent(content: Content, text: string, chapter: Chapter) {
  switch (chapter.type) {
    case 'Markdown':
      return loadMarkdown(content, text);
    case 'WTCD': {
      loadWTCD(text, content, chapter);
    }
  }
}

function loadWTCD(text: string, content: Content, chapter: WTCDChapter) {
  const wtcdParseResult: WTCDParseResult = JSON.parse(text);
  if (wtcdParseResult.error === true) {
    content.addBlock({
      initElement: createWTCDErrorMessage({
        errorType: ErrorType.COMPILE,
        message: wtcdParseResult.message,
        internalStack: wtcdParseResult.internalStack,
      }),
    });
    return;
  }
  const featureProvider = new WTCDFeatureProvider(chapter);
  switch (chapter.preferredReader) {
    case 'flow': {
      loadWTCDFlowReader(chapter, wtcdParseResult, featureProvider, content);
      return;
    }
    case 'game': {
      loadWTCDGameReader(content, chapter, wtcdParseResult, featureProvider);
      return;
    }
  }
}

function loadWTCDGameReader(content: Content, chapter: WTCDChapterGame, wtcdParseResult: {
  error: false,
  wtcdRoot: WTCDRoot,
}, featureProvider: WTCDFeatureProvider) {
  new WTCDGameReaderUI(
    content,
    chapter.htmlRelativePath,
    chapter.slideAnimation,
    wtcdParseResult.wtcdRoot,
    processElements,
    featureProvider
  ).start();
}

function loadWTCDFlowReader(chapter: WTCDChapterFlow, wtcdParseResult: {
  error: false,
  wtcdRoot: WTCDRoot,
}, featureProvider: WTCDFeatureProvider, content: Content) {
  const flowReader = new FlowReader(
    chapter.htmlRelativePath,
    wtcdParseResult.wtcdRoot,
    createWTCDErrorMessageFromError,
    processElements,
    featureProvider
  );
  const $wtcdContainer = content.addBlock().element;
  flowReader.renderTo($wtcdContainer);
}

function loadMarkdown(content: Content, text: string) {
  const block = content.addBlock();
  block.element.innerHTML = text;
  processElements(block.element);
  return block;
}

function followQueryToChapter(relativePath: string, args: Map<string, string>): boolean {
  const chapterCtx = relativePathLookUpMap.get(relativePath);
  if (chapterCtx === undefined) {
    // Cannot find chapter
    return false;
  }
  const side = (
    lastChapterCtx !== null &&
    chapterCtx.inFolderIndex < lastChapterCtx.inFolderIndex
  ) ? Side.LEFT : Side.RIGHT;

  const selection: Array<number> = args.has('selection')
    ? args.get('selection')!.split(',').map(str => +str)
    : [];
  if (selection.length !== 4 || !selection.every(
    num => (num >= 0) && (num % 1 === 0) && (!Number.isNaN(num)) && (Number.isFinite(num)),
  )) {
    loadChapter(relativePath, undefined, side);
  } else {
    loadChapter(
      relativePath,
      selection as [number, number, number, number],
      side,
    );
  }
  return true;
}

export function chapterPathHandler(path: string, args: Map<string, string>) {
  const handled = followQueryToChapter(path, args);
  return handled;
}


code:
import {
  COMMENTS_DELETE,
  COMMENTS_DELETE_CONFIRMATION,
  COMMENTS_DELETING,
  COMMENTS_FAILED,
  COMMENTS_LOADED,
  COMMENTS_LOADING,
  COMMENTS_LOSE_EDITED_CANCEL,
  COMMENTS_LOSE_EDITED_CONFIRM,
  COMMENTS_LOSE_EDITED_TITLE,
  COMMENTS_MENTION_LOADED,
  COMMENTS_MENTION_REPLIED_OK,
  COMMENTS_MENTION_REPLIED_TITLE,
  COMMENTS_MENTION_SECTION,
  COMMENTS_MODIFIED_DATE,
  COMMENTS_RECENT_LOADED,
  COMMENTS_RECENT_SECTION,
  COMMENTS_REPLY,
  COMMENTS_SECTION,
  COMMENTS_SEND,
  COMMENTS_SENDING,
  COMMENTS_SEND_CONFIRM,
  COMMENTS_SEND_DISPLAY_NAME_0,
  COMMENTS_SEND_DISPLAY_NAME_1,
  COMMENTS_SEND_DISPLAY_NAME_EDIT,
  COMMENTS_SEND_DISPLAY_NAME_PREFIX,
  COMMENTS_SEND_EMAIL_INPUT_PREFIX,
  COMMENTS_SEND_HINT,
  COMMENTS_SEND_TITLE,
  COMMENTS_SUBMIT_BY,
  GENERIC_CANCEL,
  GENERIC_CONFIRM,
  GENERIC_INTERNET_ERROR,
  GO_TO_MENU
} from '../constant/messages';
import { AutoCache, AutoSingleCache } from '../data/AutoCache';
import { ChapterContext } from '../data/data';
import { chapterHref } from '../data/hrefs';
import { useComments } from '../data/settings';
import { DebugLogger } from '../DebugLogger';
import { h } from '../hs';
import { autoExpandTextArea, linkButton } from '../util/DOM';
import { formatRelativePath } from '../util/formatRelativePath';
import { formatTimeRelative } from '../util/formatTime';
import { padName } from '../util/padName';
import {
  CommentData,
  fetchDeleteComment,
  fetchGetChapterComments,
  fetchGetRecentComments,
  fetchGetRecentMentionedComments,
  fetchSendComment,
  getErrorMessage,
  ResultOf
} from './backendControl';
import { Content, ContentBlock } from './contentControl';
import { enterMenuMode } from './menuControl';
import { confirm, Modal, notify, showGenericError, showGenericLoading } from './modalControl';
import { getCurrentUser, registerWithResultPopup, showUpdateProfileModal, tokenItem } from './userControl';

const debugLogger = new DebugLogger('Comments Control');

async function promptDeleteComment(pageName: string, commentId: number) {
  if (await confirm(COMMENTS_DELETE, COMMENTS_DELETE_CONFIRMATION, GENERIC_CONFIRM, GENERIC_CANCEL)) {
    const loadingModal = showGenericLoading(COMMENTS_DELETING);
    try {
      await fetchDeleteComment(tokenItem.getValue()!, commentId);
    } catch (error) {
      showGenericError(GENERIC_INTERNET_ERROR);
      debugLogger.error(error);
      return false;
    } finally {
      loadingModal.close();
    }
    recentCommentsCache.delete();
    chapterCommentsCache.delete(pageName);
    return true;
  }
  return false;
}

function createCommentElement(comment: CommentData, onComment: () => void, showPath: boolean) {
  const pageName = comment.relative_path;
  const actionButton = (comment.user.user_name === getCurrentUser()?.userName)
    ? h('a.action', { // 删除按钮
      onclick: () => {
        promptDeleteComment(pageName!, comment.id).then(deleted => {
          if (deleted) {
            $comment.remove();
          }
        });
      },
    }, COMMENTS_DELETE)
    : pageName && h('a.action', { // 回复按钮
      onclick: () => {
        promptComment(pageName!, '@' + comment.user.user_name + ' ').then(replied => {
          if (replied) {
            onComment();
          }
        });
      },
    }, COMMENTS_REPLY);

  const $comment = h('.comment', [
    h('img.avatar', { src: comment.user.avatar_url }),
    h('.author', comment.user.display_name),
    h('.time', COMMENTS_SUBMIT_BY.replace('$', ` @${comment.user.user_name} `)
      + formatTimeRelative(new Date(comment.create_timestamp))
      + ((comment.create_timestamp === comment.update_timestamp)
        ? '' : COMMENTS_MODIFIED_DATE.replace('$', formatTimeRelative(new Date(comment.update_timestamp))))
    ),
    actionButton,
    ...comment.body.split('\n\n').map(paragraph => h('p', paragraph)),
    showPath ? h('p', h('a.dimmed', {
      href: chapterHref(pageName),
    }, `发表于${padName(formatRelativePath(pageName))}`)) : null,
  ]);
  return $comment;
}

function loadComments(
  content: Content,
  loadComment: () => Promise<Array<CommentData>>,
  title: string,
  desc: string,
  onComment: () => void,
  backButton: boolean = true,
  commentingPageName?: string,
) {
  const $commentsStatus = h('p', COMMENTS_LOADING);
  const $comments = h('.comments', [
    h('h1', title),
    $commentsStatus,
  ]) as HTMLDivElement;
  const block = content.addBlock({
    initElement: $comments,
  });

  block.onEnteringView(() => {
    loadComment().then(data => {
      if (content.isDestroyed) {
        debugLogger.log('Comments loaded, but abandoned since the original ' +
          'content page is already destroyed.');
        return;
      }
      debugLogger.log('Comments loaded.');
      $commentsStatus.innerText = desc;
      const appendCreateComment = (commentingPageName: string) => {
        $comments.appendChild(
          h('.create-comment', {
            onclick: () => {
              promptComment(commentingPageName).then(commented => {
                if (commented) {
                  onComment();
                }
              });
            },
          }, COMMENTS_SEND),
        );
      };
      if (commentingPageName !== undefined && data.length >= 6) {
        appendCreateComment(commentingPageName);
      }
      data.forEach((comment: any) => {
        $comments.appendChild(createCommentElement(comment, onComment, commentingPageName === undefined));
      });
      if (commentingPageName !== undefined) {
        appendCreateComment(commentingPageName);
      }
    }).catch(error => {
      $commentsStatus.innerText = COMMENTS_FAILED;
      debugLogger.error('Failed to load comments.', error);
    }).then(() => {
      if (backButton) {
        $comments.appendChild(createToMenuButton());
      }
    });
  });

  return block;
}

export function createToMenuButton() {
  return h('div.page-switcher', [
    h('a', {
      href: window.location.pathname,
      onclick: (event: MouseEvent) => {
        event.preventDefault();
        enterMenuMode();
      },
    }, GO_TO_MENU),
  ]);
}

export async function sendComment(token: string, pageName: string, content: string) {
  let result: ResultOf<typeof fetchSendComment>;
  try {
    result = await fetchSendComment(token, pageName, content);
  } catch (error) {
    showGenericError(GENERIC_INTERNET_ERROR);
    debugLogger.error(error);
    return false;
  }
  if (!result.success) {
    showGenericError(getErrorMessage(result.code));
    return false;
  } else {
    // Cache invalidation
    recentCommentsCache.delete();
    chapterCommentsCache.delete(pageName);
    return true;
  }
}

export function promptComment(pageName: string, preFilled?: string) {
  return new Promise<boolean>((resolve, reject) => {
    const $nameInput = h('input') as HTMLInputElement;
    const $emailInput = h('input') as HTMLInputElement;
    const user = getCurrentUser();
    const $displayNameSpan = h('span');
    const updateDisplayName = () => {
      if (user !== null) {
        $displayNameSpan.innerText = padName(user.displayName);
      }
    };
    updateDisplayName();
    const name = tokenItem.exists()
      ? user === null
        ? h('div', { style: { 'margin-bottom': '0.8em' } })
        : h('p', [
          COMMENTS_SEND_DISPLAY_NAME_0,
          $displayNameSpan,
          COMMENTS_SEND_DISPLAY_NAME_1,
          linkButton(COMMENTS_SEND_DISPLAY_NAME_EDIT, () => {
            showUpdateProfileModal().then(updateDisplayName);
          }),
        ])
      : [
        h('.input-group', [
          h('span', COMMENTS_SEND_DISPLAY_NAME_PREFIX),
          $nameInput,
        ]),
        h('.input-group', [
          h('span', COMMENTS_SEND_EMAIL_INPUT_PREFIX),
          $emailInput,
        ]),
      ];
    const $textarea = h('textarea.general.large') as HTMLTextAreaElement;
    if (preFilled !== undefined) {
      $textarea.value = preFilled;
    }

    const onSubmit = async () => {
      const loadingModal = showGenericLoading(COMMENTS_SENDING);
      try {
        if (!tokenItem.exists()) {
          if (!(await registerWithResultPopup(
            $nameInput.value,
            $emailInput.value.trim() === '' ? null : $emailInput.value
          ))) {
            return false;
          }
        }
        return await sendComment(tokenItem.getValue()!, pageName, $textarea.value);
      } finally {
        loadingModal.close();
      }
    };

    const modal = new Modal(h('div', [
      h('h1', COMMENTS_SEND_TITLE),
      h('p', COMMENTS_SEND_HINT),
      $textarea,
      name,
      h('.button-container', [
        h('div', { // Submit
          onclick: () => {
            onSubmit().then(commented => {
              if (commented) {
                modal.close();
                resolve(true);
              }
              return commented;
            }).catch(reject);
          }
        }, COMMENTS_SEND_CONFIRM),
        h('div', {
          onclick: () => {
            if ($textarea.value === '') {
              modal.close();
              resolve(false);
            } else {
              confirm(
                COMMENTS_LOSE_EDITED_TITLE,
                '',
                COMMENTS_LOSE_EDITED_CONFIRM,
                COMMENTS_LOSE_EDITED_CANCEL
              ).then(confirmed => {
                if (confirmed) {
                  modal.close();
                  resolve(false);
                }
              });
            }
          }
        }, GENERIC_CANCEL),
      ]),
    ]));
    modal.open();
    $textarea.focus();
    autoExpandTextArea($textarea);
  });
}

const chapterCommentsCache = new AutoCache<string, Array<CommentData>>(
  fetchGetChapterComments,
  new DebugLogger('Chapter Comments Cache'),
);
export function loadChapterComments(chapterCtx: ChapterContext, content: Content) {
  if (useComments.getValue() === false) {
    return;
  }
  let block: ContentBlock | null = null;
  const pageName = chapterCtx.chapter.htmlRelativePath;
  function load() {
    if (block !== null) {
      block.directRemove();
    }
    block = loadComments(
      content,
      () => chapterCommentsCache.get(pageName),
      COMMENTS_SECTION,
      COMMENTS_LOADED,
      load,
      false,
      chapterCtx.chapter.htmlRelativePath,
    );
  }
  load();
}

const recentCommentsCache = new AutoSingleCache<Array<CommentData>>(
  fetchGetRecentComments,
  new DebugLogger('Recent Comments Cache'),
);
export function loadRecentComments(content: Content) {
  let block: ContentBlock | null = null;
  function load() {
    if (block !== null) {
      block.directRemove();
    }
    block = loadComments(
      content,
      () => recentCommentsCache.get(),
      COMMENTS_RECENT_SECTION,
      COMMENTS_RECENT_LOADED,
      load,
    );
  }
  load();
}

const recentMentionedCommentsCache = new AutoSingleCache<Array<CommentData>>(
  () => fetchGetRecentMentionedComments(tokenItem.getValue() ?? undefined),
  new DebugLogger('Recent Mentioned Comments Cache'),
);
export function loadRecentMentions(content: Content, token: string) {
  loadComments(
    content,
    () => recentMentionedCommentsCache.get(),
    COMMENTS_MENTION_SECTION,
    COMMENTS_MENTION_LOADED,
    () => {
      notify(COMMENTS_MENTION_REPLIED_TITLE, '', COMMENTS_MENTION_REPLIED_OK);
    },
  );
}


code:
import { $e } from '../$e';
import { contactInfo } from '../data/settings';
import { h } from '../hs';
import { Content } from './contentControl';

export function contactInfoElement(){
  return (

        Telegram 讨论组：

          https://t.me/joinchat/Dt8_WlJnmEwYNbjzlnLyNA

非常欢迎色情，但是若要讨论血腥内容，请用群描述内的 R18G 群组。

        Telegram 更新推送频道：

          https://t.me/joinchat/AAAAAEpkRVwZ-3s5V3YHjA

        Twitter：
        @NovelWTech

        Discord：

            https://discord.gg/HyKA6XCp6E

非常欢迎色情，但是若要讨论血腥内容，请用“#血腥猎奇”频道。

      {/*

        QQ 群

不到万不得已请不要使用 QQ 群！请考虑使用上述明确允许色情内容的联系方式！QQ 群非常容易被封！我们的五百人 ① 群已经被封了！

严格禁止政治/色情媒体，发现一次就踢了！

            只收大号，小号勿扰！

如果申请被拒绝，说明等级不够高，请使用大号。如果大号也被拒绝，请使用别的联系方式（推荐 Telegram 讨论组）。

因人数限制，满人时会踢出不活跃用户。若要潜水，请使用别的联系方式（推荐 Telegram 讨论组）。

再重复一遍：不到万不得已，不要用 QQ 群！大多数人都在 Telegram 讨论组。

二群：1154421904

 */}

  );
}

export function loadContactInfo(content: Content) {
  if (!contactInfo.getValue()) {
    return;
  }
  const block = content.addBlock({
    initElement: h('div',
      h('h3', '欢迎加入《可穿戴科技》相关讨论组'),
      contactInfoElement(),
      h('a.regular', {
        href: '#',
        onclick: ((event: any) => {
          event.preventDefault();
          block.directRemove();
          contactInfo.setValue(false);
        }),
      }, '点此永久关闭本提示'),
    ),
  });
}


code:
import { $e } from '../$e';
import { animation } from '../data/settings';
import { DebugLogger } from '../DebugLogger';
import { Event } from '../Event';
import { h } from '../hs';
import { ArrowKey, arrowKeyPressEvent } from '../input/keyboard';
import { forceReflow, id } from '../util/DOM';
import { getCurrentLayout, Layout, layoutChangeEvent } from './layoutControl';
import { MonoDimensionTransitionControl } from './MonoDimensionTransitionControl';

const $contentContainer = id('content-container');

const debugLogger = new DebugLogger('Content Control');

export enum Side {
  LEFT,
  RIGHT,
}

function setSide($element: HTMLElement, side: Side) {
  if (side === Side.LEFT) {
    $element.classList.add('left');
    $element.classList.remove('right');
  } else {
    $element.classList.add('right');
    $element.classList.remove('left');
  }
}
function otherSide(side: Side) {
  return side === Side.LEFT ? Side.RIGHT : Side.LEFT;
}
let currentContent: Content | null = null;

export function getCurrentContent() {
  return currentContent;
}

export function focus() {
  if (currentContent !== null) {
    currentContent.$element.focus({
      // Why:
      // https://stackoverflow.com/questions/26782998/why-does-calling-focus-break-my-css-transition
      preventScroll: true,
    });
  }
}

/** Global scrolling event */
export const contentScrollEvent = new Event<{ scrollTop: number }>();
export const contentChangeEvent = new Event<{ newContent: Content }>();

/**
 * 创建一个新的 Content 并替换之前的 Content。
 *
 * @param side 如果有动画，那么入场位置。
 * @returns 创建的 Content 对象
 */
export function newContent(side: Side): Content {
  const newContent = new Content();
  currentContent?.ensureLeavingTriggered();
  if (getCurrentLayout() === Layout.OFF) {
    if (currentContent !== null) {
      currentContent.destroy();
    }
  } else {
    if (animation.getValue()) {
      // Animation is enabled
      if (currentContent !== null) {
        setSide(currentContent.$element, otherSide(side));

        // Remove the content after a timeout instead of listening for
        // transition event
        const oldContent = currentContent;
        setTimeout(() => {
          oldContent.destroy();
        }, 2500);
      }
      setSide(newContent.$element, side);
      // Force reflow, so transition starts now
      forceReflow(newContent.$element);
      newContent.$element.classList.remove('left', 'right');
    } else {
      if (currentContent !== null) {
        currentContent.destroy();
      }
    }
  }
  currentContent = newContent;
  contentChangeEvent.emit({ newContent });
  return newContent;
}

export enum ContentBlockStyle {
  REGULAR,
  WARNING,
}

export class Content {
  public readonly $element: HTMLDivElement | HTMLElement; // jsx workaround
  public isDestroyed = false;
  private readonly blocks: Array<ContentBlock> = [];
  private scrollTransition: null | MonoDimensionTransitionControl = null;
  public leavingEvent = new Event();
  public scrollEvent = new Event<{ scrollTop: number }>();
  private leavingTriggered = false;
  private $leftSideContainer: null | HTMLDivElement | HTMLElement = null; // jsx workaround
  private $rightSideContainer: null | HTMLDivElement | HTMLElement = null; // jsx workaround
  public constructor() {
    const $content = h('div.content', { tabIndex: -1 }) as HTMLDivElement;
    $contentContainer.appendChild($content);
    this.$element = $content;

    this.$element.addEventListener('wheel', () => {
      this.interruptScrolling();
    }, { passive: true });
    this.$element.addEventListener('scroll', () => {
      this.scrollEvent.emit({ scrollTop: this.$element.scrollTop });
      contentScrollEvent.emit({ scrollTop: this.$element.scrollTop });
    }, { passive: true });
    arrowKeyPressEvent.on(this.onKeyPress);

    layoutChangeEvent.on(({ newLayout }, listener) => {
      if (newLayout === Layout.OFF) {
        this.ensureLeavingTriggered();
        layoutChangeEvent.off(listener);
      }
    });
  }
  public appendLeftSideContainer() {
    if (this.$leftSideContainer !== null) {
      throw new Error('Left side container already exists.');
    }
    this.$leftSideContainer =
;
    this.$element.append(this.$leftSideContainer);
  }
  public appendRightSideContainer() {
    if (this.$rightSideContainer !== null) {
      throw new Error('Right side container already exists.');
    }
    this.$rightSideContainer =
;
    this.$element.append(this.$rightSideContainer);
  }
  public getLeftSideContainer() {
    if (this.$leftSideContainer === null) {
      throw new Error('Left side container does not exist.');
    }
    return this.$leftSideContainer;
  }
  public getRightSideContainer() {
    if (this.$rightSideContainer === null) {
      throw new Error('Right side container does not exist.');
    }
    return this.$rightSideContainer;
  }
  public ensureLeavingTriggered() {
    if (!this.leavingTriggered) {
      debugLogger.log('Triggering leaving event.');
      this.leavingTriggered = true;
      this.leavingEvent.emit();
    }
  }
  public addBlock(opts: ContentBlockOpts = {}) {
    const block = new ContentBlock(this, opts);
    this.blocks.push(block);
    return block;
  }
  public destroy() {
    this.isDestroyed = true;
    this.$element.remove();
    window.removeEventListener('wheel', this.interruptScrolling);
    arrowKeyPressEvent.off(this.onKeyPress);
  }
  private onKeyPress = (key: ArrowKey) => {
    if (key === ArrowKey.UP || key === ArrowKey.DOWN) {
      this.interruptScrolling();
    }
  }
  private interruptScrolling = () => {
    if (this.scrollTransition !== null) {
      this.scrollTransition = null;
      debugLogger.log('Transition interrupted.');
    }
  }
  private scrollAnimation = () => {
    if (this.scrollTransition === null) {
      return;
    }
    const now = Date.now();
    this.$element.scrollTop = this.scrollTransition.getValue(now);
    if (this.scrollTransition.isFinished(now)) {
      debugLogger.log('Transition finished.');
      this.scrollTransition = null;
    } else {
      requestAnimationFrame(this.scrollAnimation);
    }
  }
  public scrollTo(target: number) {
    if (!animation.getValue() || getCurrentLayout() === Layout.OFF) {
      debugLogger.log(`Scroll to ${target}, no animation.`);
      this.$element.scrollTop = target;
      return;
    }
    if (this.scrollTransition === null) {
      debugLogger.log(`Scrolling to ${target}, new transition stared.`);
      this.scrollTransition = new MonoDimensionTransitionControl(
        this.$element.scrollTop,
        20_000,
      );
      this.scrollTransition.setTarget(target);
      requestAnimationFrame(this.scrollAnimation);
    } else {
      debugLogger.log(`Scrolling to ${target}, existing transition updated.`);
      this.scrollTransition.setTarget(target);
    }
  }
}

export enum ContentBlockSide {
  LEFT,
  RIGHT,
}

interface ContentBlockOpts {
  initElement?: HTMLDivElement | HTMLElement; // jsx workaround
  style?: ContentBlockStyle;
  slidable?: boolean;
  prepend?: boolean;
  side?: ContentBlockSide;
}

export class ContentBlock {
  private slideContainer: HTMLDivElement | null = null;
  private heightHolder: HTMLDivElement | null = null;
  private sliding: number = 0;
  public element: HTMLDivElement;
  public constructor(
    content: Content,
    {
      initElement = h('div'),
      style = ContentBlockStyle.REGULAR,
      slidable = false,
      prepend = false,
      side,
    }: ContentBlockOpts,
  ) {
    if (!(initElement instanceof HTMLDivElement)) {
      throw new Error('Init element must be a div.');
    }
    if (side !== undefined && slidable) {
      throw new Error('"side" and "slidable" are incompatible.');
    }
    this.element = initElement;
    initElement.classList.add('content-block');
    switch (style) {
      case ContentBlockStyle.WARNING:
        initElement.classList.add('warning');
        break;
    }
    let $parent = content.$element;
    let $self = initElement;
    if (slidable) {
      this.slideContainer = h('.slide-container', $self) as HTMLDivElement;
      $self = this.slideContainer;
    }
    if (side === ContentBlockSide.LEFT) {
      $parent = content.getLeftSideContainer();
    } else if (side === ContentBlockSide.RIGHT) {
      $parent = content.getRightSideContainer();
    }
    if (prepend) {
      $parent.prepend($self);
    } else {
      $parent.append($self);
    }
  }
  public hide() {
    this.element.classList.add('display-none');
    return this;
  }
  public show() {
    this.element.classList.remove('display-none');
    return this;
  }
  public onEnteringView(callback: () => void) {
    const observer = new IntersectionObserver(entries => {
      const entry = entries[0];
      if (entry.isIntersecting) {
        observer.disconnect();
        callback();
      }
    }, {
      root: $contentContainer,
      threshold: 0,
    });
    observer.observe(this.element);
  }
  public directRemove() {
    if (this.slideContainer !== null) {
      this.slideContainer.remove();
    } else {
      this.element.remove();
    }
  }
  public directReplace($newElement: HTMLDivElement = h('div')) {
    $newElement.classList.add('content-block');
    this.element.parentElement!.replaceChild(
      $newElement,
      this.element,
    );
    this.element = $newElement;
  }
  public slideReplace($newElement: HTMLDivElement = h('div')) {
    if (!animation.getValue()) {
      this.directReplace($newElement);
      return;
    }
    const $container = this.slideContainer;
    if ($container === null) {
      throw new Error('Content block is not slidable.');
    }
    this.sliding++;
    $container.classList.add('in-transition');
    $newElement.classList.add('content-block');
    const $oldElement = this.element;

    $newElement.classList.add('right');
    // $newElement.style.top = `${$contentContainer.scrollTop - $container.offsetTop + 30}px`;
    $container.prepend($newElement);
    const newHeight = $newElement.offsetHeight; // This also forces reflow
    $newElement.classList.remove('right');
    // $newElement.style.top = null;

    if (this.heightHolder === null) {
      this.heightHolder = h('.height-holder') as HTMLDivElement;
      this.heightHolder.style.height = `${$oldElement.offsetHeight}px`;
      $container.appendChild(this.heightHolder);
      forceReflow(this.heightHolder);
    }
    this.heightHolder.style.height = `${newHeight}px`;

    $oldElement.classList.add('left');

    this.element = $newElement;
    setTimeout(() => {
      $oldElement.remove();
      this.sliding--;
      if (this.sliding === 0) {
        $container.classList.remove('in-transition');
        if (this.heightHolder !== null) {
          this.heightHolder.remove();
          this.heightHolder = null;
        }
      }
    }, 2500);
  }
}


code:
import {
  WTCD_ERROR_COMPILE_TITLE,
  WTCD_ERROR_INTERNAL_DESC,
  WTCD_ERROR_INTERNAL_STACK_DESC,
  WTCD_ERROR_INTERNAL_STACK_TITLE,
  WTCD_ERROR_INTERNAL_TITLE,
  WTCD_ERROR_MESSAGE,
  WTCD_ERROR_RUNTIME_DESC,
  WTCD_ERROR_RUNTIME_TITLE,
  WTCD_ERROR_WTCD_STACK_DESC,
  WTCD_ERROR_WTCD_STACK_TITLE,
} from '../constant/messages';

import { ErrorType } from './chapterControl';
export function createWTCDErrorMessage({
  errorType,
  message,
  internalStack,
  wtcdStack,
}: {
  errorType: ErrorType;
  message: string;
  internalStack?: string;
  wtcdStack?: string;
}): HTMLDivElement {
  const $target = document.createElement('div');
  const $title = document.createElement('h1');
  const $desc = document.createElement('p');
  switch (errorType) {
    case ErrorType.COMPILE:
      $title.innerText = WTCD_ERROR_COMPILE_TITLE;
      $desc.innerText = WTCD_ERROR_COMPILE_TITLE;
      break;
    case ErrorType.RUNTIME:
      $title.innerText = WTCD_ERROR_RUNTIME_TITLE;
      $desc.innerText = WTCD_ERROR_RUNTIME_DESC;
      break;
    case ErrorType.INTERNAL:
      $title.innerText = WTCD_ERROR_INTERNAL_TITLE;
      $desc.innerText = WTCD_ERROR_INTERNAL_DESC;
      break;
  }
  $target.appendChild($title);
  $target.appendChild($desc);
  const $message = document.createElement('p');
  $message.innerText = WTCD_ERROR_MESSAGE + message;
  $target.appendChild($message);
  if (wtcdStack !== undefined) {
    const $stackTitle = document.createElement('h2');
    $stackTitle.innerText = WTCD_ERROR_WTCD_STACK_TITLE;
    $target.appendChild($stackTitle);
    const $stackDesc = document.createElement('p');
    $stackDesc.innerText = WTCD_ERROR_WTCD_STACK_DESC;
    $target.appendChild($stackDesc);
    const $pre = document.createElement('pre');
    const $code = document.createElement('code');
    $code.innerText = wtcdStack;
    $pre.appendChild($code);
    $target.appendChild($pre);
  }
  if (internalStack !== undefined) {
    const $stackTitle = document.createElement('h2');
    $stackTitle.innerText = WTCD_ERROR_INTERNAL_STACK_TITLE;
    $target.appendChild($stackTitle);
    const $stackDesc = document.createElement('p');
    $stackDesc.innerText = WTCD_ERROR_INTERNAL_STACK_DESC;
    $target.appendChild($stackDesc);
    const $pre = document.createElement('pre');
    const $code = document.createElement('code');
    $code.innerText = internalStack;
    $pre.appendChild($code);
    $target.appendChild($pre);
  }
  return $target;
}


code:
import { WTCDError } from '../../wtcd/WTCDError';
import { ErrorType } from './chapterControl';
import { createWTCDErrorMessage } from './createWTCDErrorMessage';
export function createWTCDErrorMessageFromError(error: Error) {
  return createWTCDErrorMessage({
    errorType: (error instanceof WTCDError)
      ? ErrorType.RUNTIME
      : ErrorType.INTERNAL,
    message: error.message,
    internalStack: error.stack,
    wtcdStack: (error instanceof WTCDError)
      ? error.wtcdStack
      : undefined,
  });
}


code:
import { BROKEN_LINK_DESC, BROKEN_LINK_OK, BROKEN_LINK_TITLE } from '../constant/messages';
import { redirects } from '../constant/redirects';
import { DebugLogger } from '../DebugLogger';
import { Event } from '../Event';
import { chapterPathHandler } from './chapterControl';
import { handleMainMenu, menuPathHandler } from './menuControl';
import { mirrorLandingHandler } from './mirrorControl';
import { notify } from './modalControl';
import { pagePathHandler } from './pageControl';

const debugLogger = new DebugLogger('Follow Query');

/**
 * Example:
 *   If the URL is "https://wt.tepis.me/#/chapter/a/b~selection=1,2,3,4~another=wow"
 *   path will be "a/b"
 *   args will be {selection: "1,2,3,4"}
 * Returns whether the handling was successful
 */
export type PathHandler = (path: string, args: Map<string, string>) => boolean;

export const onPathHandled = new Event<{ handlerId: string }>();

let pathHandlers: Map<string, PathHandler>;

export function initPathHandlers() {
  pathHandlers = new Map([
    ['chapter', chapterPathHandler],
    ['page', pagePathHandler],
    ['menu', menuPathHandler],
    ['mirror-landing', mirrorLandingHandler],
  ]);
}

export function followQuery() {
  if (window.location.hash === '') {
    document.title = '可穿戴科技';
    // No hash, go to main menu.
    handleMainMenu();
    return;
  }
  const segments = window.location.hash.split('~').map(decodeURIComponent);
  // First segment is the page specifier
  let pageSpecifier = segments[0];
  const args = new Map(segments.slice(1).map(arg => {
    const split = arg.split('=');
    if (split.length === 2) {
      return [split[0], split[1]];
    } else {
      return [split[0], ''];
    }
  }));
  // Legacy conversion
  if (!pageSpecifier.startsWith('#/')) {
    // URL looks like https://wt.tepis.me/#a/b
    // This is a legacy URL. Now convert it to the new style.
    pageSpecifier = `#/chapter/${pageSpecifier.substr('#'.length)}`;
    if (window.location.search !== '') {
      // There are query parameters, which were previously used for saving selections
      const match = /^\?selection=((?:0|[1-9][0-9]*),(?:0|[1-9][0-9]*),(?:0|[1-9][0-9]*),(?:0|[1-9][0-9]*))$/.exec(window.location.search);
      if (match !== null) {
        args.set('selection', match[1]);
      }
    }
  }
  // At this point, page specifier should look like #/chapter/xxxx
  pageSpecifier = pageSpecifier.substr('#/'.length); // Removes #/
  let handlerIdEndIndex = pageSpecifier.indexOf('/'); // Finds the "/" after handlerId
  if (handlerIdEndIndex === -1) {
    // If not found, extend to the entire string
    handlerIdEndIndex = pageSpecifier.length;
  }
  const handlerId = pageSpecifier.substr(0, handlerIdEndIndex); // chapter
  const path = pageSpecifier.substr(handlerIdEndIndex + '/'.length); // xxx
  const handler = pathHandlers.get(handlerId);
  let handled = false;
  if (handler !== undefined) {
    // Found handler
    handled = handler(path, args);
    if (handled) {
      onPathHandled.emit({ handlerId });
    }
  }

  if (!handled) {
    // Try redirects
    for (const redirect of redirects) {
      const newPageSpecifier = redirect(pageSpecifier);
      if (newPageSpecifier !== pageSpecifier) {
        // Redirect
        debugLogger.log('Redirect matched: ', redirect, `\nOld page specifier: ${pageSpecifier}\nNew page specifier: ${newPageSpecifier}`);
        window.location.hash = '#/' + [newPageSpecifier, ...segments.slice(1)].join('~');
        return;
      }
    }
    debugLogger.log(`${redirects.length} possible redirects processed. None matched.`);
    notify(BROKEN_LINK_TITLE, BROKEN_LINK_DESC, BROKEN_LINK_OK);
    document.title = '可穿戴科技';
    window.history.replaceState(null, '可穿戴科技', window.location.pathname);
    followQuery();
  }
}


code:
import { h } from '../hs';

// Promise queue
let current: Promise<void> = Promise.resolve();

export function createHint(text: string, timeMs = 2000) {
  current = current.then(async () => {
    const $hint = h('.hint', text) as HTMLDivElement;
    document.body.appendChild($hint);
    $hint.style.opacity = '0';
    // tslint:disable-next-line:no-unused-expression
    $hint.offsetWidth;
    $hint.style.removeProperty('opacity');
    await new Promise(resolve => setTimeout(resolve, timeMs));
    $hint.style.opacity = '0';
    await new Promise(resolve => setTimeout(resolve, 500));
    $hint.remove();
  });
}


code:
import { DebugLogger } from '../DebugLogger';
import { Event } from '../Event';

export enum Layout {
  SIDE,
  MAIN,
  OFF,
}

const $body = document.body;

const debugLogger = new DebugLogger('Layout');

export const layoutChangeEvent = new Event<{
  previousLayout: Layout,
  newLayout: Layout,
}>();

layoutChangeEvent.on(({ newLayout }) => {
  $body.classList.remove('layout-side', 'layout-main', 'layout-off');
  switch (newLayout) {
    case Layout.SIDE:
      $body.classList.add('layout-side');
      break;
    case Layout.MAIN:
      $body.classList.add('layout-main');
      break;
    case Layout.OFF:
      $body.classList.add('layout-off');
      break;
  }
});

let layout: Layout = Layout.OFF;
export function getCurrentLayout() {
  return layout;
}
export function setLayout(newLayout: Layout) {
  if (newLayout !== layout) {
    debugLogger.log(`${Layout[layout]} -> ${Layout[newLayout]}`);
  }

  if (layout === newLayout) {
    return;
  }

  // if (newLayout === Layout.OFF) {
  //   $rect.classList.remove('reading');
  // } else {
  //   if (layout === Layout.MAIN) {
  //     $rect.classList.remove('main');
  //   } else if (layout === Layout.SIDE) {
  //     $rect.classList.remove('side');
  //   } else {
  //     $rect.classList.remove('main', 'side');
  //     $rect.classList.add('reading');
  //   }
  //   if (newLayout === Layout.MAIN) {
  //     $rect.classList.add('main');
  //   } else {
  //     $rect.classList.add('side');
  //   }
  // }
  layoutChangeEvent.emit({
    previousLayout: layout,
    newLayout,
  });
  layout = newLayout;
}


code:
import { $e } from '../$e';
import { dbKVGet, dbKVKey, dbKVSet } from '../data/db';
import { DebugLogger } from '../DebugLogger';
import { contactInfoElement } from './contactInfoControl';
import { Modal } from './modalControl';

const debugLogger = new DebugLogger('Lose Contact Prevention');
const hasShownKey = dbKVKey<boolean>('loseContactPreventionHasShown');

export async function initLoseContactPrevention() {
  try {
    if (await dbKVGet(hasShownKey) || true) {
      return;
    }
    const modal = new Modal(

防失联信息

本消息只显示一次。

可能大家已经知道了，即使我们如此严格地禁止在 QQ 群内讨论色情/政治内容，我们的 QQ ① 群还是被封了，并且没有给出任何封禁的理由。为了防止失联，欢迎通过以下方式加入我们的讨论组/频道。

        { contactInfoElement() }

除此之外，你也可以把本站的地址记下来。不过，我只能尽量确保网站地址不变。

 modal.close() }>关闭

 as HTMLDivElement
    );
    modal.setDismissible();
    modal.open();
    await dbKVSet(hasShownKey, true);
  } catch (error) {
    debugLogger.error(error);
  }
}


code:
// Provide menu coordination

import { DebugLogger } from '../DebugLogger';
import { h } from '../hs';
import { escapeKeyPressEvent } from '../input/keyboard';
import { Menu } from '../Menu';
import { MainMenu } from '../menu/MainMenu';
import { forceReflow, id } from '../util/DOM';
import { onPathHandled, PathHandler } from './followQuery';

export const mainMenu = new MainMenu('#/menu');
mainMenu.show();

const $path = id('menu-control-path');
const $back = id<HTMLAnchorElement>('menu-control-back');
const $control = id('menu-control');
const $titleContainer = id('title-container');

const debugLogger = new DebugLogger('Menu Control');

interface MenuStackItem {
  menu: Menu;
  urlSegment: string;
  pathSpan: HTMLSpanElement;
}

// Root behaves extremely different from other menus, therefore we will not
// include it in the stack.
const menuStack: Array<MenuStackItem> = [];

function getMenu(index: number) {
  if (index === -1) {
    return mainMenu;
  } else {
    return menuStack[index].menu;
  }
}

function getCurrentMenu() {
  return getMenu(menuStack.length - 1);
}

let isMenuMode = true;

/**
 * Used by "enterMenuMode" or regular handling of menu urls.
 */
function enteredMenuMode() {
  isMenuMode = true;
  document.title = '可穿戴科技';
  $titleContainer.classList.remove('hidden');
}

export function enterMenuMode() {
  debugLogger.log('Enter menu mode');
  if (menuStack.length === 0) {
    window.location.hash = '#';
  } else {
    window.location.hash = '#/menu/' + menuStack.map(({ urlSegment }) => urlSegment).join('/');
  }
  enteredMenuMode();
}

export function exitMenuMode() {
  isMenuMode = false;
  debugLogger.log('Exit menu mode');
  getCurrentMenu().hide();
  $control.classList.add('hidden');
  $titleContainer.classList.add('hidden');
}

function pop() {
  const { menu, pathSpan: $pathSpan } = menuStack.pop()!;
  menu.destroy();
  const startX = $pathSpan.offsetLeft;
  $pathSpan.classList.add('exiting');
  if (menuStack.length !== 0) {
    // Only play the moving animation when it is not the first element
    $pathSpan.style.left = `${startX}px`;
    forceReflow($pathSpan);
    $pathSpan.style.left = `${startX + 10}px`;
  } else {
    $pathSpan.classList.add('first');
  }
  setTimeout(() => $pathSpan.remove(), 500);
}

function popUntil(targetLength: number) {
  if (menuStack.length > targetLength) {
    debugLogger.log('Pop until', targetLength);
  }
  while (menuStack.length > targetLength) {
    pop();
  }
  menuStack.length = targetLength;
}

function updateShowingMenuControl() {
  $control.classList.toggle('hidden', menuStack.length === 0);
}

export function handleMainMenu() {
  debugLogger.log('Handle main menu');
  enteredMenuMode();
  popUntil(0);
  mainMenu.show();
  updateShowingMenuControl();
}

export const menuPathHandler: PathHandler = path => {
  debugLogger.log(`Handle path=${path}`);
  enteredMenuMode();
  const urlSegments = path.split('/');
  // Step 0: Hide current menu
  getCurrentMenu().hide();

  // Step 1: Forward iterate until point of diversion
  let index = 0;
  while (index < menuStack.length && index < urlSegments.length && menuStack[index].urlSegment === urlSegments[index]) {
    index++;
  }

  // Step 2: Discard additional menus from menu stack
  popUntil(index);

  // Step 3: Add additional menus
  for (; index < urlSegments.length; index++) {
    const urlSegment = urlSegments[index];
    const parentMenu = getMenu(index - 1);
    if (!parentMenu.subMenus.has(urlSegment)) {
      return false;
    }
    const { factory, name } = parentMenu.subMenus.get(urlSegment)!;
    const subMenu = factory();
    const $pathSpan = h('span', [
      h('span', '>'),
      h('a.button', {
        href: subMenu.urlBase,
      }, name),
    ]) as HTMLSpanElement;
    if (index !== 0) {
      $pathSpan.classList.add('pre-entering');
      $path.append($pathSpan);
      forceReflow($pathSpan);
      $pathSpan.classList.remove('pre-entering');
    } else {
      $path.append($pathSpan);
    }
    menuStack.push({
      menu: subMenu,
      pathSpan: $pathSpan,
      urlSegment,
    });
  }

  // Step 4: Show new menu
  getCurrentMenu().show();

  // Step 5: Show/hide menu control
  updateShowingMenuControl();

  // Step 6: Update back URL
  if (menuStack.length > 1) {
    // There are something other than main menu
    $back.href = menuStack[menuStack.length - 2].menu.urlBase;
  } else {
    $back.href = '#';
  }

  return true;
};

escapeKeyPressEvent.on(() => {
  if (!isMenuMode) {
    return;
  }
  if (menuStack.length > 1) {
    window.location.hash = menuStack[menuStack.length - 2].menu.urlBase;
  } else if (menuStack.length === 1) {
    window.location.hash = '';
  }
});

onPathHandled.on(({ handlerId }) => {
  if (handlerId !== 'menu') {
    exitMenuMode();
  }
});

const $logo = id('navbar-logo').cloneNode(true) as SVGElement;
$logo.id = 'path-logo';
$path.prepend(h('a', {
  href: '#',
}, $logo));


code:
import {
  GENERIC_CLOSE,
  MIRROR_DESC_0,
  MIRROR_DESC_0_NO_TOKEN,
  MIRROR_DESC_1,
  MIRROR_HINT_0,
  MIRROR_HINT_1,
  MIRROR_HINT_CLICK_TO_CHANGE,
  MIRROR_LANDING_CONFLICT_DESC,
  MIRROR_LANDING_CONFLICT_KEEP,
  MIRROR_LANDING_CONFLICT_OVERWRITE,
  MIRROR_LANDING_CONFLICT_TITLE,
  MIRROR_LANDING_INVALID_REFERRAL,
  MIRROR_LANDING_SUCCESS_HINT,
  MIRROR_PROVIDED_BY,
  MIRROR_TECHNOLOGY,
  MIRROR_TITLE,
  MIRROR_URL,
} from '../constant/messages';
import { mirrorSites, mirrorSitesPlusMainSite } from '../constant/mirrorSites';
import { chapterHref, mirrorLandingHref } from '../data/hrefs';
import { h } from '../hs';
import { padName } from '../util/padName';
import { PathHandler } from './followQuery';
import { createHint } from './hintControl';
import { confirm, Modal, showGenericError } from './modalControl';
import { tokenItem } from './userControl';

export function showMirrorSitesModal(scroll?: number) {
  const modal = new Modal(h('.mirror-site-modal', [
    h('h1', MIRROR_TITLE),
    h('p', tokenItem.exists() ? MIRROR_DESC_0 : MIRROR_DESC_0_NO_TOKEN),
    h('p', MIRROR_DESC_1),
    h('p', {
      style: { 'font-weight': 'bold' },
    }, [
      '主站暂时无法工作。其原因参阅',
      h('a.regular', {
        href: chapterHref('META/公告/关于被-GitLab.com-封禁的公告.html'),
        onclick: () => {
          modal.close();
        },
      }, '这条公告'),
      '。',
    ]),
    h('.button-container', [
      ...mirrorSitesPlusMainSite.map(({ name, origin, provider, technology }) => {
        const $button = h('a.rich', {
          href: (origin === window.location.origin) ? '#' : mirrorLandingHref(origin, tokenItem.getValue()),
          onclick: (event: MouseEvent) => {
            event.preventDefault();
            if (origin === window.location.origin) {
              return;
            }
            window.location.replace(mirrorLandingHref(origin, tokenItem.getValue(), modal.modal.scrollTop));
          },
        }, [
          h('h2', name),
          h('p', [
            MIRROR_URL + origin,
            h('br'),
            MIRROR_PROVIDED_BY + provider,
            h('br'),
            MIRROR_TECHNOLOGY + technology,
          ]),
        ]);
        if (origin === window.location.origin) {
          $button.classList.add('selected');
        }
        return $button;
      }),
      h('div', {
        onclick: () => modal.close(),
      }, GENERIC_CLOSE),
    ]),
  ]) as HTMLDivElement);
  modal.setDismissible();
  modal.open();
  if (scroll !== undefined) {
    modal.modal.scrollTop = scroll;
  }
}

export const mirrorLandingHandler: PathHandler = (_, args) => {
  // Prevent leaving the token in browser history
  window.history.replaceState(null, document.title, '#/mirror-landing');
  window.location.hash = '#';
  showMirrorSitesModal(args.has('scroll') ? +args.get('scroll')! : undefined);
  const newToken = args.get('token');
  if (newToken === undefined) {
    return true;
  }
  if (!mirrorSitesPlusMainSite.some(
    mirror => document.referrer === mirror.origin
      || document.referrer.startsWith(mirror.origin + '/'))
  ) {
    showGenericError(MIRROR_LANDING_INVALID_REFERRAL);
    return true;
  }
  const oldToken = tokenItem.getValue();
  if (oldToken !== null) {
    if (oldToken !== newToken) {
      confirm(
        MIRROR_LANDING_CONFLICT_TITLE,
        MIRROR_LANDING_CONFLICT_DESC,
        MIRROR_LANDING_CONFLICT_OVERWRITE,
        MIRROR_LANDING_CONFLICT_KEEP,
      ).then(result => {
        if (result) {
          // Overwrite
          tokenItem.setValue(newToken);
          createHint(MIRROR_LANDING_SUCCESS_HINT);
        }
      });
    }
  } else {
    // No conflict
    tokenItem.setValue(newToken);
    createHint(MIRROR_LANDING_SUCCESS_HINT);
  }

  return true;
};

const currentMirrorSite = mirrorSites.find(({ origin }) => origin === window.location.origin);
if (currentMirrorSite !== undefined) {
  const $mirrorHint = h('.mirror-hint', [
    MIRROR_HINT_0.replace('$', padName(currentMirrorSite.provider)),
    h('span', {
      onclick: () => showMirrorSitesModal(),
    }, MIRROR_HINT_CLICK_TO_CHANGE),
    MIRROR_HINT_1,
  ]);
  document.body.append($mirrorHint);
}


code:
import { GENERIC_CONFIRM, GENERIC_ERROR_DESC, GENERIC_ERROR_TITLE, GENERIC_HINT_TITLE, GENERIC_LOADING_DESC, GENERIC_LOADING_TITLE, GENERIC_SUCCESS_DESC, GENERIC_SUCCESS_TITLE } from '../constant/messages';
import { animation } from '../data/settings';
import { h } from '../hs';
import { escapeKeyPressEvent } from '../input/keyboard';
import { forceReflow, id } from '../util/DOM';

const $modalHolder = id('modal-holder');

export class Modal {
  public readonly modal: HTMLDivElement;
  public readonly modalContainer: HTMLDivElement;
  public constructor(
    $initElement: HTMLDivElement = h('div'),
  ) {
    $initElement.classList.add('modal');
    this.modal = $initElement;
    this.modalContainer = h(
      '.modal-container.closed',
      $initElement,
    ) as HTMLDivElement;
    $modalHolder.appendChild(this.modalContainer);
  }
  public open() {
    forceReflow(this.modalContainer);
    this.modalContainer.classList.remove('closed');
  }
  public close() {
    if (animation.getValue()) {
      this.modalContainer.classList.add('closed');
      setTimeout(() => {
        this.modalContainer.remove();
      }, 400);
    } else {
      this.modalContainer.remove();
    }
    if (this.escKeyListener !== null) {
      escapeKeyPressEvent.off(this.escKeyListener);
    }
  }
  private dismissSet = false;
  private escKeyListener: (() => void) | null = null;
  public setDismissible(onDismiss: () => void = () => {
    this.close();
  }) {
    if (this.dismissSet) {
      throw new Error('Dismissible already set.');
    }
    this.dismissSet = true;
    escapeKeyPressEvent.on(onDismiss);
    this.modalContainer.addEventListener('click', event => {
      if (event.target === this.modalContainer) {
        onDismiss();
      }
    });
  }
}

export function confirm(title: string, desc: string, yes: string, no: string) {
  let resolved = false;
  return new Promise<boolean>(resolve => {
    const modal = new Modal(h('div', [
      h('h1', title),
      desc === '' ? null : h('p', desc),
      h('.button-container', [
        h('div', {
          onclick: () => {
            if (resolved) {
              return;
            }
            resolved = true;
            modal.close();
            resolve(true);
          },
        }, yes),
        h('div', {
          onclick: () => {
            if (resolved) {
              return;
            }
            resolved = true;
            modal.close();
            resolve(false);
          },
        }, no),
      ]),
    ]));
    modal.open();
  });
}

export function notify(title: string, desc: string, yes: string) {
  let resolved = false;
  return new Promise<void>(resolve => {
    const modal = new Modal(h('div', [
      h('h1', title),
      desc === '' ? null : h('p', desc),
      h('.button-container', [
        h('div', {
          onclick: () => {
            if (resolved) {
              return;
            }
            resolved = true;
            modal.close();
            resolve();
          },
        }, yes),
      ]),
    ]));
    modal.open();
  });
}

export function showGenericSuccess(desc?: string) {
  return notify(GENERIC_SUCCESS_TITLE, desc ?? GENERIC_SUCCESS_DESC, GENERIC_CONFIRM);
}

export function showGenericError(desc?: string) {
  return notify(GENERIC_ERROR_TITLE, desc ?? GENERIC_ERROR_DESC, GENERIC_CONFIRM);
}

export function showGenericHint(desc: string) {
  return notify(GENERIC_HINT_TITLE, desc, GENERIC_CONFIRM);
}

export function showGenericLoading(desc?: string) {
  const modal = new Modal(h('div', [
    h('h1', GENERIC_LOADING_TITLE),
    h('p', desc ?? GENERIC_LOADING_DESC),
  ]));
  modal.open();
  return modal;
}

export function isAnyModalOpened() {
  return $modalHolder.childElementCount > 0;
}


code:
import { animation } from '../data/settings';
import { h } from '../hs';
import { forceReflow, id } from '../util/DOM';
import { contentChangeEvent, contentScrollEvent } from './contentControl';
import { Layout, layoutChangeEvent } from './layoutControl';
import { enterMenuMode } from './menuControl';

const $navbar = id('navbar');
const $navbarPathContainer = id('navbar-path-container');
const $progressIndicator = id('navbar-progress-indicator');
const $logo = id('navbar-logo');
const $title = id('navbar-title');

/**
 * Used for skipping the animation when transitioning from main layout.
 */
let delayedLayout: Layout;

export function setProgressIndicator(progress: number) {
  $progressIndicator.style.transform = `scaleX(${progress})`;
  $progressIndicator.classList.toggle('hidden', progress === 1 || progress === 0);
}

export function resetProgressIndicator() {
  setProgressIndicator(0);
}

layoutChangeEvent.on(({ newLayout }) => {
  resetProgressIndicator();
  setTimeout(() => delayedLayout = newLayout, 100);
});

$logo.addEventListener('click', () => {
  enterMenuMode();
});

$title.addEventListener('click', event => {
  event.preventDefault();
  enterMenuMode();
});

contentScrollEvent.on(({ scrollTop }) => $navbar.classList.toggle('flat', scrollTop < 50));
contentChangeEvent.on(() => $navbar.classList.add('flat'));

export interface NavbarPathSegmentSpecifier {
  display: string;
  hash: string | null;
}

export interface NavbarPathSegment extends NavbarPathSegmentSpecifier {
  $anchor: HTMLElement;
  $arrow: HTMLDivElement;
}

const currentNavbarPath: Array<NavbarPathSegment> = [];

function compareNavbarPathSegment(
  a: NavbarPathSegmentSpecifier,
  b: NavbarPathSegmentSpecifier,
) {
  return a.display === b.display && a.hash === b.hash;
}

function removeElement($element: HTMLElement) {
  if (animation.getValue() && delayedLayout === Layout.MAIN) {
    $element.style.left = `${$element.offsetLeft}px`;
    $element.classList.add('exiting');
    setTimeout(() => {
      $element.remove();
    }, 500);
  } else {
    $element.remove();
  }
}

function removeNavbarPathSegment(segment: NavbarPathSegment) {
  removeElement(segment.$anchor);
  removeElement(segment.$arrow);
}

function createNavbarPathSegment(segmentSpecifier: NavbarPathSegmentSpecifier): NavbarPathSegment {
  const $arrow = h('.arrow', '>') as HTMLDivElement;
  const $anchor = segmentSpecifier.hash === null
    ? h('.anchor', segmentSpecifier.display) as HTMLElement
    : h('a.button.anchor', {
      href: segmentSpecifier.hash,
    }, segmentSpecifier.display) as HTMLElement;
  $navbarPathContainer.appendChild($arrow);
  $navbarPathContainer.appendChild($anchor);
  if (animation.getValue() && delayedLayout === Layout.MAIN) {
    $arrow.classList.add('entering');
    $anchor.classList.add('entering');
    forceReflow($anchor);
    $arrow.classList.remove('entering');
    $anchor.classList.remove('entering');
  }
  return {
    $arrow,
    $anchor,
    ...segmentSpecifier,
  };
}

export function setNavbarPath(newNavbarPath: Array<NavbarPathSegmentSpecifier>) {
  let firstDifferentIndex = 0;
  while (
    currentNavbarPath.length > firstDifferentIndex &&
    newNavbarPath.length > firstDifferentIndex &&
    compareNavbarPathSegment(
      currentNavbarPath[firstDifferentIndex],
      newNavbarPath[firstDifferentIndex]
    )
  ) {
    firstDifferentIndex++;
  }

  // Remove extras from current navbar path
  for (let i = currentNavbarPath.length - 1; i >= firstDifferentIndex; i--) {
    removeNavbarPathSegment(currentNavbarPath[i]);
  }
  currentNavbarPath.length = firstDifferentIndex; // Shorten the array

  // Add new segments
  for (let i = firstDifferentIndex; i < newNavbarPath.length; i++) {
    currentNavbarPath.push(createNavbarPathSegment(newNavbarPath[i]));
  }
}


code:
import { escapeKeyPressEvent } from '../input/keyboard';
import { author } from '../pages/author';
import { recentComments } from '../pages/recentComments';
import { recentMentions } from '../pages/recentMentions';
import { taggingTool } from '../pages/taggingTool';
import { tagSearch } from '../pages/tagSearch';
import { visitCount } from '../pages/visitCount';
import { wtcupVote } from '../pages/wtcupVote';
import { Content, newContent, Side } from './contentControl';
import { PathHandler } from './followQuery';
import { Layout, setLayout } from './layoutControl';
import { enterMenuMode } from './menuControl';

export interface Page {
  name: string;
  handler: (content: Content, pagePath: PagePath) => boolean;
}

const pages: Array<Page> = [
  recentComments,
  visitCount,
  recentMentions,
  author,
  wtcupVote,
  taggingTool,
  tagSearch,
];

class PagePath {
  public constructor(
    private current: string,
    private prefix: string,
  ) {}
  public get() {
    return this.current;
  }
  public set(newPath: string) {
    this.current = newPath;
    window.history.replaceState(null, document.title, this.prefix + newPath);
  }
}

export const pagePathHandler: PathHandler = path => {
  for (const page of pages) {
    if (path === page.name || path.startsWith(page.name + '/')) {
      const content = newContent(Side.RIGHT);
      escapeKeyPressEvent.onceUntil(enterMenuMode, content.leavingEvent);
      setLayout(Layout.MAIN);
      const handleResult = page.handler(content, new PagePath(path.substr(page.name.length + 1), `#/page/${page.name}/`));
      return handleResult;
    }
  }
  return false;
};


code:
export class PersistentItem<T> {
  public constructor(
    private key: string,
    private serializer: (value: T) => string,
    private deserializer: (str: string) => T | null,
  ) {}
  public getValue(): T | null {
    const str = window.localStorage.getItem(this.key);
    if (str === null) {
      return null;
    }
    return this.deserializer(str);
  }
  public setValue(value: T) {
    window.localStorage.setItem(this.key, this.serializer(value));
  }
  public remove() {
    window.localStorage.removeItem(this.key);
  }
  public setValueNullable(value: T | null) {
    if (value === null) {
      this.remove();
    } else {
      this.setValue(value);
    }
  }
  public exists(): boolean {
    return this.getValue() !== null;
  }
}

const identity = <T>(input: T) => input;

export class StringPersistentItem extends PersistentItem<string> {
  public constructor(key: string) {
    super(key, identity, identity);
  }
}

const numberToString = (input: number) => String(input);
const stringToNumber = (input: string) => {
  const num = +input;
  if (Number.isNaN(num)) {
    return null;
  }
  return num;
};

export class NumberPersistentItem extends PersistentItem<number> {
  public constructor(key: string) {
    super(key, numberToString, stringToNumber);
  }
}


code:
import { $e } from '../$e';
import { produce } from '../util/array';
import { randomInt } from '../util/math';
import { Content } from './contentControl';

function getLine(percentage: number) {
  const style = (percentage === 100) ? {} : { clipPath: `inset(0 ${100 - percentage}% 0 0)` };
  return (

  );
}

function getFullLine() {
  return getLine(100);
}

function getPartialLine() {
  return getLine(Math.random() * 60 + 20);
}

export function addPreloaderBlock(content: Content) {
  return content.addBlock({
    initElement: (

          {getPartialLine()}

        {produce(12, () => (

            {produce(randomInt(1, 5), getFullLine)}
            {getPartialLine()}

        ))}

    ),
  });
}


code:
import { selectNode } from '../util/DOM';

export function processElements($parent: HTMLElement) {
  Array.from($parent.getElementsByTagName('a')).forEach(($anchor: HTMLAnchorElement) => {
    const hrefAttribute = $anchor.attributes.getNamedItem('href');
    if (hrefAttribute !== null && !hrefAttribute.value.startsWith('#')) {
      $anchor.target = '_blank';
    }
    $anchor.rel = 'noopener noreferrer';
    $anchor.className = 'regular';
  });
  Array.from($parent.getElementsByTagName('code')).forEach($code => $code.addEventListener('dblclick', () => {
    if (!($code.parentNode instanceof HTMLPreElement)) {
      selectNode($code);
    }
  }));
  Array.from($parent.getElementsByTagName('img')).forEach($image => {
    const src = $image.src;
    const fullResolutionPath = src.replace(/\.([^.]+)$/, '.full.$1');
    $image.style.cursor = 'zoom-in';
    $image.addEventListener('click', () => window.open(fullResolutionPath));
  });
}


code:
import { $e } from '../$e';
import { Chapter } from '../../Data';
import { enablePushHelper } from '../data/settings';
import { Content, ContentBlockSide } from './contentControl';
import { Modal } from './modalControl';

export function addPushHelperBlock(content: Content, chapter: Chapter) {
  if (!enablePushHelper.getValue()) {
    return;
  }
  content.addBlock({
    initElement: (

推送助手

 {
            openPushHelper(chapter);
          }}>打开推送助手

推送助手是给《可穿戴科技》的编辑们使用的工具。如果你不是《可穿戴科技》的编辑，你可以前往设置并禁用推送助手。

    ),
    side: ContentBlockSide.LEFT,
  });
}

function openPushHelper(chapter: Chapter) {
  const tags = [...new Set(chapter.tags?.map(tagVariant => `#${tagVariant.split('（')[0]}`) ?? [])];
  const modal = new Modal(

推送助手

字数：{chapter.charsCount}

标签：

        {tags.join(' ')}

 modal.close()}>关闭

 as HTMLDivElement
  );
  modal.setDismissible();
  modal.open();
}