mirror of
https://github.com/laurent22/joplin.git
synced 2024-11-24 08:12:24 +02:00
Chore: Set up repository for testing/preparation for mobile markdown toolbar PR (#6650)
This commit is contained in:
parent
11a1e1cb6b
commit
0e532fbaf0
@ -46,7 +46,7 @@ packages/app-desktop/packageInfo.js
|
||||
packages/app-desktop/services/electron-context-menu.js
|
||||
packages/app-desktop/vendor/lib/
|
||||
packages/app-mobile/android
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror.bundle.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.bundle.js
|
||||
packages/app-mobile/ios
|
||||
packages/app-mobile/lib/rnInjectedJs/
|
||||
packages/app-mobile/locales
|
||||
@ -853,9 +853,18 @@ packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js.ma
|
||||
packages/app-mobile/components/NoteBodyViewer/hooks/useSource.d.ts
|
||||
packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js
|
||||
packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/theme.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/theme.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/theme.js.map
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.d.ts
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.js
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.js.map
|
||||
|
15
.gitignore
vendored
15
.gitignore
vendored
@ -843,9 +843,18 @@ packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js.ma
|
||||
packages/app-mobile/components/NoteBodyViewer/hooks/useSource.d.ts
|
||||
packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js
|
||||
packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/theme.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/theme.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/theme.js.map
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.d.ts
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.js
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.js.map
|
||||
|
19
packages/app-clipper/popup/package-lock.json
generated
19
packages/app-clipper/popup/package-lock.json
generated
@ -20253,6 +20253,19 @@
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c="
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "3.9.10",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.10.tgz",
|
||||
"integrity": "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz",
|
||||
@ -37995,6 +38008,12 @@
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c="
|
||||
},
|
||||
"typescript": {
|
||||
"version": "3.9.10",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.10.tgz",
|
||||
"integrity": "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==",
|
||||
"peer": true
|
||||
},
|
||||
"unicode-canonical-property-names-ecmascript": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz",
|
||||
|
4
packages/app-mobile/.gitignore
vendored
4
packages/app-mobile/.gitignore
vendored
@ -63,7 +63,7 @@ buck-out/
|
||||
lib/csstojs/
|
||||
lib/rnInjectedJs/
|
||||
dist/
|
||||
components/NoteEditor/CodeMirror.bundle.js
|
||||
components/NoteEditor/CodeMirror.bundle.min.js
|
||||
components/NoteEditor/CodeMirror/CodeMirror.bundle.js
|
||||
components/NoteEditor/CodeMirror/CodeMirror.bundle.min.js
|
||||
|
||||
utils/fs-driver-android.js
|
||||
|
@ -9,11 +9,11 @@
|
||||
// wrapper to access CodeMirror functionalities. Anything else should be done
|
||||
// from NoteEditor.tsx.
|
||||
|
||||
import { EditorState, Extension } from '@codemirror/state';
|
||||
import createTheme from './theme';
|
||||
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { highlightSelectionMatches, search } from '@codemirror/search';
|
||||
import { defaultHighlightStyle, syntaxHighlighting, HighlightStyle } from '@codemirror/language';
|
||||
import { tags } from '@lezer/highlight';
|
||||
import { EditorView, drawSelection, highlightSpecialChars, ViewUpdate } from '@codemirror/view';
|
||||
import { undo, redo, history, undoDepth, redoDepth } from '@codemirror/commands';
|
||||
|
||||
@ -21,6 +21,8 @@ import { keymap } from '@codemirror/view';
|
||||
import { indentOnInput } from '@codemirror/language';
|
||||
import { searchKeymap } from '@codemirror/search';
|
||||
import { historyKeymap, defaultKeymap } from '@codemirror/commands';
|
||||
import { MarkdownMathExtension } from './markdownMathParser';
|
||||
import { GFM as GitHubFlavoredMarkdownExtension } from '@lezer/markdown';
|
||||
|
||||
interface CodeMirrorResult {
|
||||
editor: EditorView;
|
||||
@ -42,120 +44,6 @@ function logMessage(...msg: any[]) {
|
||||
postMessage('onLog', { value: msg });
|
||||
}
|
||||
|
||||
// For an example on how to customize the theme, see:
|
||||
//
|
||||
// https://github.com/codemirror/theme-one-dark/blob/main/src/one-dark.ts
|
||||
//
|
||||
// For a tutorial, see:
|
||||
//
|
||||
// https://codemirror.net/6/examples/styling/#themes
|
||||
//
|
||||
// Use Safari developer tools to view the content of the CodeMirror iframe while
|
||||
// the app is running. It seems that what appears as ".ͼ1" in the CSS is the
|
||||
// equivalent of "&" in the theme object. So to target ".ͼ1.cm-focused", you'd
|
||||
// use '&.cm-focused' in the theme.
|
||||
const createTheme = (theme: any): Extension[] => {
|
||||
const isDarkTheme = theme.appearance === 'dark';
|
||||
|
||||
const baseGlobalStyle: Record<string, string> = {
|
||||
color: theme.color,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
fontFamily: theme.fontFamily,
|
||||
fontSize: `${theme.fontSize}px`,
|
||||
};
|
||||
const baseCursorStyle: Record<string, string> = { };
|
||||
const baseContentStyle: Record<string, string> = { };
|
||||
const baseSelectionStyle: Record<string, string> = { };
|
||||
|
||||
// If we're in dark mode, the caret and selection are difficult to see.
|
||||
// Adjust them appropriately
|
||||
if (isDarkTheme) {
|
||||
// Styling the caret requires styling both the caret itself
|
||||
// and the CodeMirror caret.
|
||||
// See https://codemirror.net/6/examples/styling/#themes
|
||||
baseContentStyle.caretColor = 'white';
|
||||
baseCursorStyle.borderLeftColor = 'white';
|
||||
|
||||
baseSelectionStyle.backgroundColor = '#6b6b6b';
|
||||
}
|
||||
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
'&': baseGlobalStyle,
|
||||
|
||||
// These must be !important or more specific than CodeMirror's built-ins
|
||||
'.cm-content': baseContentStyle,
|
||||
'&.cm-focused .cm-cursor': baseCursorStyle,
|
||||
'&.cm-focused .cm-selectionBackground, ::selection': baseSelectionStyle,
|
||||
|
||||
'&.cm-focused': {
|
||||
outline: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
const appearanceTheme = EditorView.theme({}, { dark: isDarkTheme });
|
||||
|
||||
const baseHeadingStyle = {
|
||||
fontWeight: 'bold',
|
||||
fontFamily: theme.fontFamily,
|
||||
};
|
||||
|
||||
const highlightingStyle = HighlightStyle.define([
|
||||
{
|
||||
tag: tags.strong,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
{
|
||||
tag: tags.emphasis,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
{
|
||||
...baseHeadingStyle,
|
||||
tag: tags.heading1,
|
||||
fontSize: '1.6em',
|
||||
borderBottom: `1px solid ${theme.dividerColor}`,
|
||||
},
|
||||
{
|
||||
...baseHeadingStyle,
|
||||
tag: tags.heading2,
|
||||
fontSize: '1.4em',
|
||||
},
|
||||
{
|
||||
...baseHeadingStyle,
|
||||
tag: tags.heading3,
|
||||
fontSize: '1.3em',
|
||||
},
|
||||
{
|
||||
...baseHeadingStyle,
|
||||
tag: tags.heading4,
|
||||
fontSize: '1.2em',
|
||||
},
|
||||
{
|
||||
...baseHeadingStyle,
|
||||
tag: tags.heading5,
|
||||
fontSize: '1.1em',
|
||||
},
|
||||
{
|
||||
...baseHeadingStyle,
|
||||
tag: tags.heading6,
|
||||
fontSize: '1.0em',
|
||||
},
|
||||
{
|
||||
tag: tags.list,
|
||||
fontFamily: theme.fontFamily,
|
||||
},
|
||||
]);
|
||||
|
||||
return [
|
||||
baseTheme,
|
||||
appearanceTheme,
|
||||
syntaxHighlighting(highlightingStyle),
|
||||
|
||||
// If we haven't defined highlighting for tags, fall back
|
||||
// to the default.
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
];
|
||||
};
|
||||
|
||||
export function initCodeMirror(parentElement: any, initialText: string, theme: any): CodeMirrorResult {
|
||||
logMessage('Initializing CodeMirror...');
|
||||
|
||||
@ -183,7 +71,12 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a
|
||||
// See https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts
|
||||
// for a sample configuration.
|
||||
extensions: [
|
||||
markdown(),
|
||||
markdown({
|
||||
extensions: [
|
||||
MarkdownMathExtension,
|
||||
GitHubFlavoredMarkdownExtension,
|
||||
],
|
||||
}),
|
||||
...createTheme(theme),
|
||||
history(),
|
||||
search(),
|
@ -0,0 +1,152 @@
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { ensureSyntaxTree } from '@codemirror/language';
|
||||
import { SyntaxNode } from '@lezer/common';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { blockMathTagName, inlineMathContentTagName, inlineMathTagName, MarkdownMathExtension } from './markdownMathParser';
|
||||
import { GFM as GithubFlavoredMarkdownExt } from '@lezer/markdown';
|
||||
|
||||
const syntaxTreeCreateTimeout = 100; // ms
|
||||
|
||||
/** Create an EditorState with markdown extensions */
|
||||
const createEditorState = (initialText: string): EditorState => {
|
||||
return EditorState.create({
|
||||
doc: initialText,
|
||||
extensions: [
|
||||
markdown({
|
||||
extensions: [MarkdownMathExtension, GithubFlavoredMarkdownExt],
|
||||
}),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a list of all nodes with the given name in the given editor's syntax tree.
|
||||
* Attempts to create the syntax tree if it doesn't exist.
|
||||
*/
|
||||
const findNodesWithName = (editor: EditorState, nodeName: string) => {
|
||||
const result: SyntaxNode[] = [];
|
||||
ensureSyntaxTree(editor, syntaxTreeCreateTimeout)?.iterate({
|
||||
enter: (node) => {
|
||||
if (node.name === nodeName) {
|
||||
result.push(node.node);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
describe('Inline parsing', () => {
|
||||
it('Document with just a math region', () => {
|
||||
const documentText = '$3 + 3$';
|
||||
const editor = createEditorState(documentText);
|
||||
const inlineMathNodes = findNodesWithName(editor, inlineMathTagName);
|
||||
const inlineMathContentNodes = findNodesWithName(editor, inlineMathContentTagName);
|
||||
|
||||
// There should only be one inline node
|
||||
expect(inlineMathNodes.length).toBe(1);
|
||||
|
||||
expect(inlineMathNodes[0].from).toBe(0);
|
||||
expect(inlineMathNodes[0].to).toBe(documentText.length);
|
||||
|
||||
// The content tag should be replaced by the internal sTeX parser
|
||||
expect(inlineMathContentNodes.length).toBe(0);
|
||||
});
|
||||
|
||||
it('Inline math mixed with text', () => {
|
||||
const beforeMath = '# Testing!\n\nThis is a test of ';
|
||||
const mathRegion = '$\\TeX % TeX Comment!$';
|
||||
const afterMath = ' formatting.';
|
||||
const documentText = `${beforeMath}${mathRegion}${afterMath}`;
|
||||
|
||||
const editor = createEditorState(documentText);
|
||||
const inlineMathNodes = findNodesWithName(editor, inlineMathTagName);
|
||||
const blockMathNodes = findNodesWithName(editor, blockMathTagName);
|
||||
const commentNodes = findNodesWithName(editor, 'comment');
|
||||
|
||||
expect(inlineMathNodes.length).toBe(1);
|
||||
expect(blockMathNodes.length).toBe(0);
|
||||
expect(commentNodes.length).toBe(1);
|
||||
|
||||
expect(inlineMathNodes[0].from).toBe(beforeMath.length);
|
||||
expect(inlineMathNodes[0].to).toBe(beforeMath.length + mathRegion.length);
|
||||
});
|
||||
|
||||
it('Inline math with no ending $ in a block', () => {
|
||||
const documentText = 'This is a $test\n\nof inline math$...';
|
||||
const editor = createEditorState(documentText);
|
||||
const inlineMathNodes = findNodesWithName(editor, inlineMathTagName);
|
||||
|
||||
// Math should end if there is no matching '$'.
|
||||
expect(inlineMathNodes.length).toBe(0);
|
||||
});
|
||||
|
||||
it('Shouldn\'t start if block would have spaces just inside', () => {
|
||||
const documentText = 'This is a $ test of inline math$...\n\n$Testing... $...';
|
||||
const editor = createEditorState(documentText);
|
||||
expect(findNodesWithName(editor, inlineMathTagName).length).toBe(0);
|
||||
});
|
||||
|
||||
it('Shouldn\'t start if $ is escaped', () => {
|
||||
const documentText = 'This is a \\$test of inline math$...';
|
||||
const editor = createEditorState(documentText);
|
||||
expect(findNodesWithName(editor, inlineMathTagName).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Block math tests', () => {
|
||||
it('Document with just block math', () => {
|
||||
const documentText = '$$\n\t\\{ 1, 1, 2, 3, 5, ... \\}\n$$';
|
||||
const editor = createEditorState(documentText);
|
||||
const inlineMathNodes = findNodesWithName(editor, inlineMathTagName);
|
||||
const blockMathNodes = findNodesWithName(editor, blockMathTagName);
|
||||
|
||||
expect(inlineMathNodes.length).toBe(0);
|
||||
expect(blockMathNodes.length).toBe(1);
|
||||
|
||||
expect(blockMathNodes[0].from).toBe(0);
|
||||
expect(blockMathNodes[0].to).toBe(documentText.length);
|
||||
});
|
||||
|
||||
it('Block math with comment', () => {
|
||||
const startingText = '$$ % Testing...\n\t\\text{Test.}\n$$';
|
||||
const afterMath = '\nTest.';
|
||||
const editor = createEditorState(startingText + afterMath);
|
||||
const inlineMathNodes = findNodesWithName(editor, inlineMathTagName);
|
||||
const blockMathNodes = findNodesWithName(editor, blockMathTagName);
|
||||
const texParserComments = findNodesWithName(editor, 'comment');
|
||||
|
||||
expect(inlineMathNodes.length).toBe(0);
|
||||
expect(blockMathNodes.length).toBe(1);
|
||||
expect(texParserComments.length).toBe(1);
|
||||
|
||||
expect(blockMathNodes[0].from).toBe(0);
|
||||
expect(blockMathNodes[0].to).toBe(startingText.length);
|
||||
|
||||
expect(texParserComments[0]).toMatchObject({
|
||||
from: '$$ '.length,
|
||||
to: '$$ % Testing...'.length,
|
||||
});
|
||||
});
|
||||
|
||||
it('Block math without an ending tag', () => {
|
||||
const beforeMath = '# Testing...\n\n';
|
||||
const documentText = `${beforeMath}$$\n\t\\text{Testing...}\n\n\t3 + 3 = 6`;
|
||||
const editor = createEditorState(documentText);
|
||||
const blockMathNodes = findNodesWithName(editor, blockMathTagName);
|
||||
|
||||
expect(blockMathNodes.length).toBe(1);
|
||||
expect(blockMathNodes[0].from).toBe(beforeMath.length);
|
||||
expect(blockMathNodes[0].to).toBe(documentText.length);
|
||||
});
|
||||
|
||||
it('Single-line declaration of block math', () => {
|
||||
const documentText = '$$ Test. $$';
|
||||
const editor = createEditorState(documentText);
|
||||
const blockMathNodes = findNodesWithName(editor, blockMathTagName);
|
||||
|
||||
expect(blockMathNodes.length).toBe(1);
|
||||
expect(blockMathNodes[0].from).toBe(0);
|
||||
expect(blockMathNodes[0].to).toBe(documentText.length);
|
||||
});
|
||||
});
|
@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Search for $s and $$s in markdown and mark the regions between them as math.
|
||||
*
|
||||
* Text between single $s is marked as InlineMath and text between $$s is marked
|
||||
* as BlockMath.
|
||||
*/
|
||||
|
||||
import { tags, Tag } from '@lezer/highlight';
|
||||
import { parseMixed, SyntaxNodeRef, Input, NestedParse, ParseWrapper } from '@lezer/common';
|
||||
|
||||
// Extend the existing markdown parser
|
||||
import {
|
||||
MarkdownConfig, InlineContext,
|
||||
BlockContext, Line, LeafBlock,
|
||||
} from '@lezer/markdown';
|
||||
|
||||
// The existing stexMath parser is used to parse the text between the $s
|
||||
import { stexMath } from '@codemirror/legacy-modes/mode/stex';
|
||||
import { StreamLanguage } from '@codemirror/language';
|
||||
|
||||
const dollarSignCharcode = 36;
|
||||
const backslashCharcode = 92;
|
||||
|
||||
// (?:[>]\s*)?: Optionally allow block math lines to start with '> '
|
||||
const mathBlockStartRegex = /^(?:\s*[>]\s*)?\$\$/;
|
||||
const mathBlockEndRegex = /\$\$\s*$/;
|
||||
|
||||
const texLanguage = StreamLanguage.define(stexMath);
|
||||
export const blockMathTagName = 'BlockMath';
|
||||
export const blockMathContentTagName = 'BlockMathContent';
|
||||
export const inlineMathTagName = 'InlineMath';
|
||||
export const inlineMathContentTagName = 'InlineMathContent';
|
||||
|
||||
export const mathTag = Tag.define(tags.monospace);
|
||||
export const inlineMathTag = Tag.define(mathTag);
|
||||
|
||||
/**
|
||||
* Wraps a TeX math-mode parser. This removes [nodeTag] from the syntax tree
|
||||
* and replaces it with a region handled by the sTeXMath parser.
|
||||
*
|
||||
* @param nodeTag Name of the nodes to replace with regions parsed by the sTeX parser.
|
||||
* @returns a wrapped sTeX parser.
|
||||
*/
|
||||
const wrappedTeXParser = (nodeTag: string): ParseWrapper => {
|
||||
return parseMixed((node: SyntaxNodeRef, _input: Input): NestedParse => {
|
||||
if (node.name !== nodeTag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
parser: texLanguage.parser,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Markdown extension for recognizing inline code
|
||||
const InlineMathConfig: MarkdownConfig = {
|
||||
defineNodes: [
|
||||
{
|
||||
name: inlineMathTagName,
|
||||
style: inlineMathTag,
|
||||
},
|
||||
{
|
||||
name: inlineMathContentTagName,
|
||||
},
|
||||
],
|
||||
parseInline: [{
|
||||
name: inlineMathTagName,
|
||||
after: 'InlineCode',
|
||||
|
||||
parse(cx: InlineContext, current: number, pos: number): number {
|
||||
const prevCharCode = pos - 1 >= 0 ? cx.char(pos - 1) : -1;
|
||||
const nextCharCode = cx.char(pos + 1);
|
||||
if (current !== dollarSignCharcode
|
||||
|| prevCharCode === dollarSignCharcode
|
||||
|| nextCharCode === dollarSignCharcode) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Don't match if there's a space directly after the '$'
|
||||
if (/\s/.exec(String.fromCharCode(nextCharCode))) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const start = pos;
|
||||
const end = cx.end;
|
||||
let escaped = false;
|
||||
|
||||
pos ++;
|
||||
|
||||
// Scan ahead for the next '$' symbol
|
||||
for (; pos < end && (escaped || cx.char(pos) !== dollarSignCharcode); pos++) {
|
||||
if (!escaped && cx.char(pos) === backslashCharcode) {
|
||||
escaped = true;
|
||||
} else {
|
||||
escaped = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't match if the ending '$' is preceded by a space.
|
||||
const prevChar = String.fromCharCode(cx.char(pos - 1));
|
||||
if (/\s/.exec(prevChar)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// It isn't a math region if there is no ending '$'
|
||||
if (pos === end) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Advance to just after the ending '$'
|
||||
pos ++;
|
||||
|
||||
// Add a wraping inlineMathTagName node that contains an inlineMathContentTagName.
|
||||
// The inlineMathContentTagName node can thus be safely removed and the region
|
||||
// will still be marked as a math region.
|
||||
const contentElem = cx.elt(inlineMathContentTagName, start + 1, pos - 1);
|
||||
cx.addElement(cx.elt(inlineMathTagName, start, pos, [contentElem]));
|
||||
|
||||
return pos + 1;
|
||||
},
|
||||
}],
|
||||
wrap: wrappedTeXParser(inlineMathContentTagName),
|
||||
};
|
||||
|
||||
// Extension for recognising block code
|
||||
const BlockMathConfig: MarkdownConfig = {
|
||||
defineNodes: [
|
||||
{
|
||||
name: blockMathTagName,
|
||||
style: mathTag,
|
||||
},
|
||||
{
|
||||
name: blockMathContentTagName,
|
||||
},
|
||||
],
|
||||
parseBlock: [{
|
||||
name: blockMathTagName,
|
||||
before: 'Blockquote',
|
||||
parse(cx: BlockContext, line: Line): boolean {
|
||||
const delimLen = 2;
|
||||
|
||||
// $$ delimiter? Start math!
|
||||
const mathStartMatch = mathBlockStartRegex.exec(line.text);
|
||||
if (mathStartMatch) {
|
||||
const start = cx.lineStart + mathStartMatch[0].length;
|
||||
let stop;
|
||||
|
||||
let endMatch = mathBlockEndRegex.exec(
|
||||
line.text.substring(mathStartMatch[0].length)
|
||||
);
|
||||
|
||||
// If the math region ends immediately (on the same line),
|
||||
if (endMatch) {
|
||||
const lineLength = line.text.length;
|
||||
stop = cx.lineStart + lineLength - endMatch[0].length;
|
||||
} else {
|
||||
let hadNextLine = false;
|
||||
|
||||
// Otherwise, it's a multi-line block display.
|
||||
// Consume lines until we reach the end.
|
||||
do {
|
||||
hadNextLine = cx.nextLine();
|
||||
endMatch = hadNextLine ? mathBlockEndRegex.exec(line.text) : null;
|
||||
}
|
||||
while (hadNextLine && endMatch === null);
|
||||
|
||||
if (hadNextLine && endMatch) {
|
||||
const lineLength = line.text.length;
|
||||
|
||||
// Remove the ending delimiter
|
||||
stop = cx.lineStart + lineLength - endMatch[0].length;
|
||||
} else {
|
||||
stop = cx.lineStart;
|
||||
}
|
||||
}
|
||||
const lineEnd = cx.lineStart + line.text.length;
|
||||
|
||||
// Label the region. Add two labels so that one can be removed.
|
||||
const contentElem = cx.elt(blockMathContentTagName, start, stop);
|
||||
const containerElement = cx.elt(
|
||||
blockMathTagName,
|
||||
start - delimLen,
|
||||
|
||||
// Math blocks don't need ending delimiters, so ensure we don't
|
||||
// include text that doesn't exist.
|
||||
Math.min(lineEnd, stop + delimLen),
|
||||
|
||||
// The child of the container element should be the content element
|
||||
[contentElem]
|
||||
);
|
||||
cx.addElement(containerElement);
|
||||
|
||||
// Don't re-process the ending delimiter (it may look the same
|
||||
// as the starting delimiter).
|
||||
cx.nextLine();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
// End paragraph-like blocks
|
||||
endLeaf(_cx: BlockContext, line: Line, _leaf: LeafBlock): boolean {
|
||||
// Leaf blocks (e.g. block quotes) end early if math starts.
|
||||
return mathBlockStartRegex.exec(line.text) !== null;
|
||||
},
|
||||
}],
|
||||
wrap: wrappedTeXParser(blockMathContentTagName),
|
||||
};
|
||||
|
||||
/** Markdown configuration for block and inline math support. */
|
||||
export const MarkdownMathExtension: MarkdownConfig[] = [
|
||||
InlineMathConfig,
|
||||
BlockMathConfig,
|
||||
];
|
126
packages/app-mobile/components/NoteEditor/CodeMirror/theme.ts
vendored
Normal file
126
packages/app-mobile/components/NoteEditor/CodeMirror/theme.ts
vendored
Normal file
@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Create a set of Extensions that provide syntax highlighting.
|
||||
*/
|
||||
|
||||
|
||||
import { defaultHighlightStyle, syntaxHighlighting, HighlightStyle } from '@codemirror/language';
|
||||
import { tags } from '@lezer/highlight';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { Extension } from '@codemirror/state';
|
||||
|
||||
// For an example on how to customize the theme, see:
|
||||
//
|
||||
// https://github.com/codemirror/theme-one-dark/blob/main/src/one-dark.ts
|
||||
//
|
||||
// For a tutorial, see:
|
||||
//
|
||||
// https://codemirror.net/6/examples/styling/#themes
|
||||
//
|
||||
// Use Safari developer tools to view the content of the CodeMirror iframe while
|
||||
// the app is running. It seems that what appears as ".ͼ1" in the CSS is the
|
||||
// equivalent of "&" in the theme object. So to target ".ͼ1.cm-focused", you'd
|
||||
// use '&.cm-focused' in the theme.
|
||||
const createTheme = (theme: any): Extension[] => {
|
||||
const isDarkTheme = theme.appearance === 'dark';
|
||||
|
||||
const baseGlobalStyle: Record<string, string> = {
|
||||
color: theme.color,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
fontFamily: theme.fontFamily,
|
||||
fontSize: `${theme.fontSize}px`,
|
||||
};
|
||||
const baseCursorStyle: Record<string, string> = { };
|
||||
const baseContentStyle: Record<string, string> = { };
|
||||
const baseSelectionStyle: Record<string, string> = { };
|
||||
|
||||
// If we're in dark mode, the caret and selection are difficult to see.
|
||||
// Adjust them appropriately
|
||||
if (isDarkTheme) {
|
||||
// Styling the caret requires styling both the caret itself
|
||||
// and the CodeMirror caret.
|
||||
// See https://codemirror.net/6/examples/styling/#themes
|
||||
baseContentStyle.caretColor = 'white';
|
||||
baseCursorStyle.borderLeftColor = 'white';
|
||||
|
||||
baseSelectionStyle.backgroundColor = '#6b6b6b';
|
||||
}
|
||||
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
'&': baseGlobalStyle,
|
||||
|
||||
// These must be !important or more specific than CodeMirror's built-ins
|
||||
'.cm-content': baseContentStyle,
|
||||
'&.cm-focused .cm-cursor': baseCursorStyle,
|
||||
'&.cm-focused .cm-selectionBackground, ::selection': baseSelectionStyle,
|
||||
|
||||
'&.cm-focused': {
|
||||
outline: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
const appearanceTheme = EditorView.theme({}, { dark: isDarkTheme });
|
||||
|
||||
const baseHeadingStyle = {
|
||||
fontWeight: 'bold',
|
||||
fontFamily: theme.fontFamily,
|
||||
};
|
||||
|
||||
const highlightingStyle = HighlightStyle.define([
|
||||
{
|
||||
tag: tags.strong,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
{
|
||||
tag: tags.emphasis,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
{
|
||||
...baseHeadingStyle,
|
||||
tag: tags.heading1,
|
||||
fontSize: '1.6em',
|
||||
borderBottom: `1px solid ${theme.dividerColor}`,
|
||||
},
|
||||
{
|
||||
...baseHeadingStyle,
|
||||
tag: tags.heading2,
|
||||
fontSize: '1.4em',
|
||||
},
|
||||
{
|
||||
...baseHeadingStyle,
|
||||
tag: tags.heading3,
|
||||
fontSize: '1.3em',
|
||||
},
|
||||
{
|
||||
...baseHeadingStyle,
|
||||
tag: tags.heading4,
|
||||
fontSize: '1.2em',
|
||||
},
|
||||
{
|
||||
...baseHeadingStyle,
|
||||
tag: tags.heading5,
|
||||
fontSize: '1.1em',
|
||||
},
|
||||
{
|
||||
...baseHeadingStyle,
|
||||
tag: tags.heading6,
|
||||
fontSize: '1.0em',
|
||||
},
|
||||
{
|
||||
tag: tags.list,
|
||||
fontFamily: theme.fontFamily,
|
||||
},
|
||||
]);
|
||||
|
||||
return [
|
||||
baseTheme,
|
||||
appearanceTheme,
|
||||
syntaxHighlighting(highlightingStyle),
|
||||
|
||||
// If we haven't defined highlighting for tags, fall back
|
||||
// to the default.
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
export default createTheme;
|
@ -44,145 +44,6 @@ function fontFamilyFromSettings() {
|
||||
return [f, 'sans-serif'].join(', ');
|
||||
}
|
||||
|
||||
// Obsolete with CodeMirror 6. See ./CodeMirror.ts for styling.
|
||||
// function useCss(themeId:number):string {
|
||||
// const [css, setCss] = useState('');
|
||||
|
||||
// // useEffect(() => {
|
||||
// // const theme = themeStyle(themeId);
|
||||
|
||||
// // // Selection in dark mode is hard to see so make it brighter.
|
||||
// // // https://discourse.joplinapp.org/t/dragging-in-dark-theme/12433/4?u=laurent
|
||||
// // const selectionColorCss = theme.appearance === ThemeAppearance.Dark ?
|
||||
// // `.CodeMirror-selected {
|
||||
// // background: #6b6b6b !important;
|
||||
// // }` : '';
|
||||
// // const monospaceFonts = [];
|
||||
// // // if (Setting.value('style.editor.monospaceFontFamily')) monospaceFonts.push(`"${Setting.value('style.editor.monospaceFontFamily')}"`);
|
||||
// // monospaceFonts.push('monospace');
|
||||
|
||||
// // const fontSize = 15;
|
||||
// // const fontFamily = fontFamilyFromSettings();
|
||||
|
||||
// // // BUG: caret-color seems to be ignored for some reason
|
||||
// // const caretColor = theme.appearance === ThemeAppearance.Dark ? "white" : 'black';
|
||||
|
||||
// // setCss(`
|
||||
// // /* These must be important to prevent the codemirror defaults from taking over*/
|
||||
// // .CodeMirror {
|
||||
// // font-family: ${fontFamily};
|
||||
// // font-size: ${fontSize}px;
|
||||
// // height: 100% !important;
|
||||
// // width: 100% !important;
|
||||
// // color: ${theme.color};
|
||||
// // background-color: ${theme.backgroundColor};
|
||||
// // position: absolute !important;
|
||||
// // -webkit-box-shadow: none !important; // Some themes add a box shadow for some reason
|
||||
// // }
|
||||
|
||||
// // .CodeMirror-lines {
|
||||
// // /* This is used to enable the scroll-past end behaviour. The same height should */
|
||||
// // /* be applied to the viewer. */
|
||||
// // padding-bottom: 400px !important;
|
||||
// // }
|
||||
|
||||
// // /* Left padding is applied at the editor component level, so we should remove it from the lines */
|
||||
// // .CodeMirror pre.CodeMirror-line,
|
||||
// // .CodeMirror pre.CodeMirror-line-like {
|
||||
// // padding-left: 0;
|
||||
// // }
|
||||
|
||||
// // .CodeMirror-sizer {
|
||||
// // /* Add a fixed right padding to account for the appearance (and disappearance) */
|
||||
// // /* of the sidebar */
|
||||
// // padding-right: 10px !important;
|
||||
// // }
|
||||
|
||||
// // /* This enforces monospace for certain elements (code, tables, etc.) */
|
||||
// // .cm-jn-monospace {
|
||||
// // font-family: ${monospaceFonts.join(', ')} !important;
|
||||
// // }
|
||||
|
||||
// // .cm-header-1 {
|
||||
// // font-size: 1.5em;
|
||||
// // }
|
||||
|
||||
// // .cm-header-2 {
|
||||
// // font-size: 1.3em;
|
||||
// // }
|
||||
|
||||
// // .cm-header-3 {
|
||||
// // font-size: 1.1em;
|
||||
// // }
|
||||
|
||||
// // .cm-header-4, .cm-header-5, .cm-header-6 {
|
||||
// // font-size: 1em;
|
||||
// // }
|
||||
|
||||
// // .cm-header-1, .cm-header-2, .cm-header-3, .cm-header-4, .cm-header-5, .cm-header-6 {
|
||||
// // line-height: 1.5em;
|
||||
// // }
|
||||
|
||||
// // .cm-search-marker {
|
||||
// // background: ${theme.searchMarkerBackgroundColor};
|
||||
// // color: ${theme.searchMarkerColor} !important;
|
||||
// // }
|
||||
|
||||
// // .cm-search-marker-selected {
|
||||
// // background: ${theme.selectedColor2};
|
||||
// // color: ${theme.color2} !important;
|
||||
// // }
|
||||
|
||||
// // .cm-search-marker-scrollbar {
|
||||
// // background: ${theme.searchMarkerBackgroundColor};
|
||||
// // -moz-box-sizing: border-box;
|
||||
// // box-sizing: border-box;
|
||||
// // opacity: .5;
|
||||
// // }
|
||||
|
||||
// // /* We need to use important to override theme specific values */
|
||||
// // .cm-error {
|
||||
// // color: inherit !important;
|
||||
// // background-color: inherit !important;
|
||||
// // border-bottom: 1px dotted #dc322f;
|
||||
// // }
|
||||
|
||||
// // /* The default dark theme colors don't have enough contrast with the background */
|
||||
// // .cm-s-nord span.cm-comment {
|
||||
// // color: #9aa4b6 !important;
|
||||
// // }
|
||||
|
||||
// // .cm-s-dracula span.cm-comment {
|
||||
// // color: #a1abc9 !important;
|
||||
// // }
|
||||
|
||||
// // .cm-s-monokai span.cm-comment {
|
||||
// // color: #908b74 !important;
|
||||
// // }
|
||||
|
||||
// // .cm-s-material-darker span.cm-comment {
|
||||
// // color: #878787 !important;
|
||||
// // }
|
||||
|
||||
// // .cm-s-solarized.cm-s-dark span.cm-comment {
|
||||
// // color: #8ba1a7 !important;
|
||||
// // }
|
||||
|
||||
// // /* MOBILE SPECIFIC */
|
||||
|
||||
// // .CodeMirror .cm-scroller,
|
||||
// // .CodeMirror .cm-line {
|
||||
// // font-family: ${fontFamily};
|
||||
// // caret-color: ${caretColor};
|
||||
// // }
|
||||
|
||||
// // ${selectionColorCss}
|
||||
// // `);
|
||||
// // }, [themeId]);
|
||||
|
||||
// return css;
|
||||
// }
|
||||
|
||||
function useCss(themeId: number): string {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
@ -274,7 +135,6 @@ function NoteEditor(props: Props, ref: any) {
|
||||
} catch (e) {
|
||||
window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
|
||||
}
|
||||
|
||||
true;
|
||||
`;
|
||||
|
||||
|
25
packages/app-mobile/injectedJS.config.js
Normal file
25
packages/app-mobile/injectedJS.config.js
Normal file
@ -0,0 +1,25 @@
|
||||
// Configuration file for rollup
|
||||
|
||||
const { dirname } = require('path');
|
||||
import typescript from '@rollup/plugin-typescript';
|
||||
import { nodeResolve } from '@rollup/plugin-node-resolve';
|
||||
|
||||
|
||||
const rootDir = dirname(dirname(dirname(__dirname)));
|
||||
const mobileDir = `${rootDir}/packages/app-mobile`;
|
||||
const codeMirrorDir = `${mobileDir}/components/NoteEditor/CodeMirror`;
|
||||
const outputFile = `${codeMirrorDir}/CodeMirror.bundle.js`;
|
||||
|
||||
export default {
|
||||
output: outputFile,
|
||||
plugins: [
|
||||
typescript({
|
||||
// Exclude all .js files. Rollup will attempt to import a .js
|
||||
// file if both a .ts and .js file are present, conflicting
|
||||
// with our build setup. See
|
||||
// https://discourse.joplinapp.org/t/importing-a-ts-file-from-a-rollup-bundled-ts-file/
|
||||
exclude: `${codeMirrorDir}/*.js`,
|
||||
}),
|
||||
nodeResolve(),
|
||||
],
|
||||
};
|
17
packages/app-mobile/jest.config.js
Normal file
17
packages/app-mobile/jest.config.js
Normal file
@ -0,0 +1,17 @@
|
||||
// Test configuration
|
||||
// See https://jestjs.io/docs/configuration#testenvironment-string
|
||||
|
||||
const config = {
|
||||
preset: 'ts-jest',
|
||||
|
||||
// File extensions for imports, in order of precedence:
|
||||
// prefer importing from .ts or .tsx to importing from .js
|
||||
// files.
|
||||
moduleFileExtensions: [
|
||||
'ts',
|
||||
'tsx',
|
||||
'js',
|
||||
],
|
||||
};
|
||||
|
||||
module.exports = config;
|
@ -9,6 +9,8 @@
|
||||
"android": "react-native run-android",
|
||||
"build": "gulp build",
|
||||
"tsc": "tsc --project tsconfig.json",
|
||||
"test": "jest",
|
||||
"test-ci": "yarn test",
|
||||
"watch": "tsc --watch --preserveWatchOutput --project tsconfig.json",
|
||||
"clean": "node tools/clean.js",
|
||||
"buildInjectedJs": "gulp buildInjectedJs",
|
||||
@ -75,6 +77,7 @@
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/lang-markdown": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/legacy-modes": "^6.1.0",
|
||||
"@codemirror/search": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
@ -82,6 +85,7 @@
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@rollup/plugin-node-resolve": "^13.0.0",
|
||||
"@rollup/plugin-typescript": "^8.2.1",
|
||||
"@types/jest": "^28.1.3",
|
||||
"@types/node": "^14.14.6",
|
||||
"@types/react": "^16.9.55",
|
||||
"@types/react-native": "^0.64.4",
|
||||
@ -89,10 +93,13 @@
|
||||
"execa": "^4.0.0",
|
||||
"fs-extra": "^8.1.0",
|
||||
"gulp": "^4.0.2",
|
||||
"jest": "^28.1.1",
|
||||
"jest-environment-jsdom": "^28.1.1",
|
||||
"jetifier": "^1.6.5",
|
||||
"metro-react-native-babel-preset": "^0.66.2",
|
||||
"nodemon": "^2.0.12",
|
||||
"rollup": "^2.53.1",
|
||||
"ts-jest": "^28.0.5",
|
||||
"typescript": "^4.0.5",
|
||||
"uglify-js": "^3.13.10"
|
||||
}
|
||||
|
@ -10,7 +10,8 @@ const execa = require('execa');
|
||||
const rootDir = path.dirname(path.dirname(path.dirname(__dirname)));
|
||||
const mobileDir = `${rootDir}/packages/app-mobile`;
|
||||
const outputDir = `${mobileDir}/lib/rnInjectedJs`;
|
||||
const codeMirrorBundleFile = `${mobileDir}/components/NoteEditor/CodeMirror.bundle.min.js`;
|
||||
const codeMirrorDir = `${mobileDir}/components/NoteEditor/CodeMirror`;
|
||||
const codeMirrorBundleFile = `${codeMirrorDir}/CodeMirror.bundle.min.js`;
|
||||
|
||||
async function copyJs(name, filePath) {
|
||||
const outputPath = `${outputDir}/${name}.js`;
|
||||
@ -23,17 +24,16 @@ async function copyJs(name, filePath) {
|
||||
async function buildCodeMirrorBundle() {
|
||||
console.info('Building CodeMirror bundle...');
|
||||
|
||||
const sourceFile = `${mobileDir}/components/NoteEditor/CodeMirror.ts`;
|
||||
const fullBundleFile = `${mobileDir}/components/NoteEditor/CodeMirror.bundle.js`;
|
||||
const sourceFile = `${codeMirrorDir}/CodeMirror.ts`;
|
||||
const fullBundleFile = `${codeMirrorDir}/CodeMirror.bundle.js`;
|
||||
|
||||
await execa('yarn', [
|
||||
'run', 'rollup',
|
||||
sourceFile,
|
||||
'--name', 'codeMirrorBundle',
|
||||
'--config', `${mobileDir}/injectedJS.config.js`,
|
||||
'-f', 'iife',
|
||||
'-o', fullBundleFile,
|
||||
'-p', '@rollup/plugin-node-resolve',
|
||||
'-p', '@rollup/plugin-typescript',
|
||||
]);
|
||||
|
||||
// await execa('./node_modules/uglify-js/bin/uglifyjs', [
|
||||
@ -49,7 +49,7 @@ async function main() {
|
||||
await fs.mkdirp(outputDir);
|
||||
await buildCodeMirrorBundle();
|
||||
await copyJs('webviewLib', `${mobileDir}/../lib/renderers/webviewLib.js`);
|
||||
await copyJs('CodeMirror.bundle', `${mobileDir}/components/NoteEditor/CodeMirror.bundle.min.js`);
|
||||
await copyJs('CodeMirror.bundle', `${codeMirrorDir}/CodeMirror.bundle.min.js`);
|
||||
}
|
||||
|
||||
module.exports = main;
|
||||
|
@ -6,5 +6,7 @@
|
||||
],
|
||||
"exclude": [
|
||||
"**/node_modules",
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
],
|
||||
}
|
Loading…
Reference in New Issue
Block a user