Port modal / popover context.

This commit is contained in:
Paul
2021-06-19 18:46:05 +01:00
parent 5b77ed439f
commit 9706dd75f3
57 changed files with 2562 additions and 140 deletions

View File

@@ -0,0 +1,41 @@
import twemoji from 'twemoji';
var EMOJI_PACK = 'mutant';
const REVISION = 3;
/*export function setEmojiPack(pack: EmojiPacks) {
EMOJI_PACK = pack;
}*/
// Taken from Twemoji source code.
// scripts/build.js#344
// grabTheRightIcon(rawText);
const UFE0Fg = /\uFE0F/g;
const U200D = String.fromCharCode(0x200D);
function toCodePoint(emoji: string) {
return twemoji.convert.toCodePoint(emoji.indexOf(U200D) < 0 ?
emoji.replace(UFE0Fg, '') :
emoji
);
}
function parseEmoji(emoji: string) {
let codepoint = toCodePoint(emoji);
return `https://static.revolt.chat/emoji/${EMOJI_PACK}/${codepoint}.svg?rev=${REVISION}`;
}
export function Emoji({ emoji, size }: { emoji: string, size?: number }) {
return (
<img
alt={emoji}
className="emoji"
draggable={false}
src={parseEmoji(emoji)}
style={size ? { width: `${size}px`, height: `${size}px` } : undefined}
/>
)
}
export function generateEmoji(emoji: string) {
return `<img class="emoji" draggable="false" alt="${emoji}" src="${parseEmoji(emoji)}" />`;
}

View File

@@ -0,0 +1,202 @@
@import "@fontsource/fira-mono/400.css";
.markdown {
:global(.emoji) {
height: 1.25em;
width: 1.25em;
margin: 0 0.05em 0 0.1em;
vertical-align: -0.2em;
}
&[data-large-emojis="true"] :global(.emoji) {
width: 3rem;
height: 3rem;
margin-bottom: 0;
margin-top: 1px;
margin-right: 2px;
vertical-align: -.3em;
}
p,
pre {
margin: 0;
}
a {
text-decoration: none;
&[data-type="mention"] {
padding: 0 6px;
font-weight: 600;
border-radius: 12px;
display: inline-block;
background: var(--secondary-background);
&:hover {
text-decoration: none;
}
}
&:hover {
text-decoration: underline;
}
}
h1,
h2,
h3,
h4,
h5,
h6,
ul,
ol,
blockquote {
margin: 0;
}
ul,
ol {
list-style-position: inside;
padding-left: 10px;
}
blockquote {
margin: 2px 0;
padding: 2px 0;
border-radius: 4px;
background: var(--hover);
border-inline-start: 4px solid var(--tertiary-background);
> * {
margin: 0 8px;
}
}
pre {
padding: 1em;
border-radius: 4px;
overflow-x: scroll;
border-radius: 3px;
background: var(--block) !important;
}
p > code {
padding: 1px 4px;
}
code {
color: white;
font-size: 90%;
border-radius: 4px;
background: var(--block);
font-family: "Fira Mono", monospace;
}
input[type="checkbox"] {
margin-right: 4px;
pointer-events: none;
}
table {
border-collapse: collapse;
th,
td {
padding: 6px;
border: 1px solid var(--tertiary-foreground);
}
}
:global(.katex-block) {
overflow-x: auto;
}
:global(.spoiler) {
padding: 0 2px;
cursor: pointer;
user-select: none;
color: transparent;
border-radius: 4px;
background: #151515;
&:global(.shown) {
cursor: auto;
user-select: all;
color: var(--foreground);
background: var(--secondary-background);
}
}
:global(.code) {
font-family: "Fira Mono", monospace;
:global(.lang) {
// height: 8px;
// position: relative;
div {
// margin-left: -5px;
// margin-top: -16px;
// position: absolute;
color: #111;
cursor: pointer;
padding: 2px 6px;
font-weight: 600;
user-select: none;
display: inline-block;
background: var(--accent);
font-size: 10px;
border-radius: 2px;
text-transform: uppercase;
box-shadow: 0 2px #787676;
&:active {
transform: translateY(1px);
box-shadow: 0 1px #787676;
}
}
// ! FIXME: had to change this temporarily due to overflow
width: fit-content;
padding-bottom: 8px;
}
}
input[type="checkbox"] {
width: 0;
opacity: 0;
pointer-events: none;
}
label {
pointer-events: none;
}
input[type="checkbox"] + label:before {
width: 12px;
height: 12px;
content: 'a';
font-size: 10px;
margin-right: 6px;
line-height: 12px;
position: relative;
border-radius: 4px;
background: white;
display: inline-block;
}
input[type="checkbox"][checked="true"] + label:before {
content: '';
align-items: center;
display: inline-flex;
justify-content: center;
background: var(--accent);
}
input[type="checkbox"] + label {
line-height: 12px;
position: relative;
}
}

View File

@@ -0,0 +1,17 @@
import { Suspense, lazy } from "preact/compat";
const Renderer = lazy(() => import('./Renderer'));
export interface MarkdownProps {
content?: string;
disallowBigEmoji?: boolean;
}
export default function Markdown(props: MarkdownProps) {
return (
// @ts-expect-error
<Suspense fallback="Getting ready to render Markdown...">
<Renderer {...props} />
</Suspense>
)
}

View File

@@ -0,0 +1,170 @@
import MarkdownIt from "markdown-it";
import { RE_MENTIONS } from "revolt.js";
import { generateEmoji } from "./Emoji";
import { useContext } from "preact/hooks";
import { MarkdownProps } from "./Markdown";
import styles from "./Markdown.module.scss";
import { AppContext } from "../../context/revoltjs/RevoltClient";
import Prism from "prismjs";
import "katex/dist/katex.min.css";
import "prismjs/themes/prism-tomorrow.css";
import MarkdownKatex from "@traptitech/markdown-it-katex";
import MarkdownSpoilers from "@traptitech/markdown-it-spoiler";
// @ts-ignore
import MarkdownEmoji from "markdown-it-emoji/dist/markdown-it-emoji-bare";
// @ts-ignore
import MarkdownSup from "markdown-it-sup";
// @ts-ignore
import MarkdownSub from "markdown-it-sub";
// Handler for code block copy.
if (typeof window !== "undefined") {
(window as any).copycode = function(element: HTMLDivElement) {
try {
let code = element.parentElement?.parentElement?.children[1];
if (code) {
navigator.clipboard.writeText((code as any).innerText.trim());
}
} catch (e) {}
};
}
export const md: MarkdownIt = MarkdownIt({
breaks: true,
linkify: true,
highlight: (str, lang) => {
let v = Prism.languages[lang];
if (v) {
let out = Prism.highlight(str, v, lang);
return `<pre class="code"><div class="lang"><div onclick="copycode(this)">${lang}</div></div><code class="language-${lang}">${out}</code></pre>`;
}
return `<pre class="code"><code>${md.utils.escapeHtml(str)}</code></pre>`;
}
})
.disable("image")
.use(MarkdownEmoji/*, { defs: emojiDictionary }*/)
.use(MarkdownSpoilers)
.use(MarkdownSup)
.use(MarkdownSub)
.use(MarkdownKatex, {
throwOnError: false,
maxExpand: 0
});
// ? Force links to open _blank.
// From: https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer
const defaultRender =
md.renderer.rules.link_open ||
function(tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options);
};
// Handler for internal links, pushes events to React using magic.
if (typeof window !== "undefined") {
(window as any).internalHandleURL = function(element: HTMLAnchorElement) {
const url = new URL(element.href, location as any);
const pathname = url.pathname;
if (pathname.startsWith("/@")) {
//InternalEventEmitter.emit("openProfile", pathname.substr(2));
} else {
//InternalEventEmitter.emit("navigate", pathname);
}
};
}
md.renderer.rules.link_open = function(tokens, idx, options, env, self) {
let internal;
const hIndex = tokens[idx].attrIndex("href");
if (hIndex >= 0) {
try {
// For internal links, we should use our own handler to use react-router history.
// @ts-ignore
const href = tokens[idx].attrs[hIndex][1];
const url = new URL(href, location as any);
if (url.hostname === location.hostname) {
internal = true;
// I'm sorry.
tokens[idx].attrPush([
"onclick",
"internalHandleURL(this); return false"
]);
if (url.pathname.startsWith("/@")) {
tokens[idx].attrPush(["data-type", "mention"]);
}
}
} catch (err) {
// Ignore the error, treat as normal link.
}
}
if (!internal) {
// Add target=_blank for external links.
const aIndex = tokens[idx].attrIndex("target");
if (aIndex < 0) {
tokens[idx].attrPush(["target", "_blank"]);
} else {
try {
// @ts-ignore
tokens[idx].attrs[aIndex][1] = "_blank";
} catch (_) {}
}
}
return defaultRender(tokens, idx, options, env, self);
};
md.renderer.rules.emoji = function(token, idx) {
return generateEmoji(token[idx].content);
};
const RE_TWEMOJI = /:(\w+):/g;
export default function Renderer({ content, disallowBigEmoji }: MarkdownProps) {
const client = useContext(AppContext);
if (typeof content === "undefined") return null;
if (content.length === 0) return null;
// We replace the message with the mention at the time of render.
// We don't care if the mention changes.
let newContent = content.replace(
RE_MENTIONS,
(sub: string, ...args: any[]) => {
const id = args[0],
user = client.users.get(id);
if (user) {
return `[@${user.username}](/@${id})`;
}
return sub;
}
);
const useLargeEmojis = disallowBigEmoji ? false : content.replace(RE_TWEMOJI, '').trim().length === 0;
return (
<span
className={styles.markdown}
dangerouslySetInnerHTML={{
__html: md.render(newContent)
}}
data-large-emojis={useLargeEmojis}
onClick={ev => {
if (ev.target) {
let element: Element = ev.target as any;
if (element.classList.contains("spoiler")) {
element.classList.add("shown");
}
}
}}
/>
);
}