first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
import {Extension} from '@tiptap/core';
import '@tiptap/extension-text-style';
export type ColorOptions = {
types: string[];
};
declare module '@tiptap/core' {
interface Commands<ReturnType> {
bgColor: {
setBackgroundColor: (color: string) => ReturnType;
unsetBackgroundColor: () => ReturnType;
};
}
}
export const BackgroundColor = Extension.create<ColorOptions>({
name: 'backgroundColor',
addOptions() {
return {
types: ['textStyle'],
};
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
backgroundColor: {
default: null,
parseHTML: element =>
element.style.backgroundColor.replace(/['"]+/g, ''),
renderHTML: attributes => {
if (!attributes.backgroundColor) {
return {};
}
return {
style: `background-color: ${attributes.backgroundColor}`,
};
},
},
},
},
];
},
addCommands() {
return {
setBackgroundColor:
backgroundColor =>
({chain}) => {
return chain().setMark('textStyle', {backgroundColor}).run();
},
unsetBackgroundColor:
() =>
({chain}) => {
return chain()
.setMark('textStyle', {backgroundColor: null})
.removeEmptyTextStyle()
.run();
},
};
},
});

View File

@@ -0,0 +1,57 @@
import {mergeAttributes, Node} from '@tiptap/react';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
embed: {
setEmbed: (options: {src: string}) => ReturnType;
};
}
}
export const Embed = Node.create({
name: 'embed',
group: 'block',
atom: true,
addAttributes() {
return {
allowfullscreen: {
default: null,
},
allow: {
default: 'autoplay; fullscreen; picture-in-picture',
},
src: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: 'iframe',
},
];
},
renderHTML({HTMLAttributes}) {
return [
'iframe',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
];
},
addCommands() {
return {
setEmbed:
options =>
({commands}) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
});

View File

@@ -0,0 +1,134 @@
import {Command, Extension} from '@tiptap/core';
import {AllSelection, TextSelection, Transaction} from 'prosemirror-state';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
indent: {
indent: () => ReturnType;
outdent: () => ReturnType;
};
}
}
export const Indent = Extension.create({
name: 'indent',
addOptions: () => {
return {
types: ['listItem', 'paragraph'],
minLevel: 0,
maxLevel: 6,
};
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
indent: {
renderHTML: attributes => {
return attributes?.indent > this.options.minLevel
? {'data-indent': attributes.indent}
: null;
},
parseHTML: element => {
const level = Number(element.getAttribute('data-indent'));
return level && level > this.options.minLevel ? level : null;
},
},
},
},
];
},
addCommands() {
const setNodeIndentMarkup = (
tr: Transaction,
pos: number,
delta: number
): Transaction => {
const node = tr?.doc?.nodeAt(pos);
if (node) {
const nextLevel = (node.attrs.indent || 0) + delta;
const {minLevel, maxLevel} = this.options;
const indent =
// eslint-disable-next-line no-nested-ternary
nextLevel < minLevel
? minLevel
: nextLevel > maxLevel
? maxLevel
: nextLevel;
if (indent !== node.attrs.indent) {
const {indent: oldIndent, ...currentAttrs} = node.attrs;
const nodeAttrs =
indent > minLevel ? {...currentAttrs, indent} : currentAttrs;
return tr.setNodeMarkup(pos, node.type, nodeAttrs, node.marks);
}
}
return tr;
};
const updateIndentLevel = (tr: Transaction, delta: number): Transaction => {
const {doc, selection} = tr;
if (
doc &&
selection &&
(selection instanceof TextSelection ||
selection instanceof AllSelection)
) {
const {from, to} = selection;
doc.nodesBetween(from, to, (node, pos) => {
if (this.options.types.includes(node.type.name)) {
tr = setNodeIndentMarkup(tr, pos, delta);
return false;
}
return true;
});
}
return tr;
};
const applyIndent: (direction: number) => () => Command =
direction =>
() =>
({tr, state, dispatch}) => {
const {selection} = state;
tr = tr.setSelection(selection);
tr = updateIndentLevel(tr, direction);
if (tr.docChanged) {
dispatch?.(tr);
return true;
}
return false;
};
return {
indent: applyIndent(1),
outdent: applyIndent(-1),
};
},
addKeyboardShortcuts() {
return {
Tab: ({editor}) => {
if (editor.state.selection.to > editor.state.selection.from) {
return this.editor.commands.indent();
}
return false;
},
'Shift-Tab': ({editor}) => {
if (editor.state.selection.to > editor.state.selection.from) {
return this.editor.commands.outdent();
}
return false;
},
};
},
});

View File

@@ -0,0 +1,76 @@
import {mergeAttributes, Node} from '@tiptap/react';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
important: {
addInfo: (attributes: {
type: 'important' | 'warning' | 'success';
}) => ReturnType;
};
}
}
export const InfoBlock = Node.create({
name: 'be-info-block',
group: 'block',
content: 'inline*',
defining: true,
addOptions() {
return {
types: ['important', 'warning', 'success'],
defaultType: 'success',
};
},
addAttributes() {
return {
type: {
default: this.options.defaultType,
parseHTML: element => {
for (const type of this.options.types) {
if (element.classList.contains(type)) {
return type;
}
}
return this.options.defaultType;
},
renderHTML: attrs => {
return {class: attrs.type};
},
},
};
},
parseHTML() {
return [
{
tag: 'div',
contentElement: 'p',
getAttrs: node =>
(node as HTMLElement).classList.contains('info-block') && null,
},
];
},
renderHTML({HTMLAttributes}) {
return [
'div',
mergeAttributes(HTMLAttributes, {
class: 'info-block',
}),
['div', {class: 'title'}, 'Important:'],
['p', 0],
];
},
addCommands() {
return {
addInfo:
attributes =>
({commands}) => {
return commands.setNode(this.name, attributes);
},
};
},
});

View File

@@ -0,0 +1,51 @@
.ProseMirror {
@apply outline-none min-h-inherit;
}
.ProseMirror img.ProseMirror-selectednode,
.ProseMirror iframe.ProseMirror-selectednode {
@apply ring-primary-light rounded;
}
.prose [data-indent='1'] {
@apply pl-20;
}
.prose [data-indent='2'] {
@apply pl-40;
}
.prose [data-indent='3'] {
@apply pl-60;
}
.prose [data-indent='4'] {
@apply pl-80;
}
.prose [data-indent='5'] {
@apply pl-100;
}
.prose [data-indent='6'] {
@apply pl-128;
}
.prose iframe {
@apply w-full aspect-video;
}
.info-block {
@apply text-base bg-positive/hover border-l border-l-4 border-l-positive/20 break-words rounded my-32 p-14 max-w-full border-l-4 border-l-positive;
}
.info-block .title {
@apply font-medium text-base text-[#43484d];
}
.info-block.important {
@apply bg-[#f3a432]/hover border-l-[#f3a432];
}
.info-block.warning {
@apply bg-danger/hover border-l-danger;
}
.info-block p {
@apply mt-6 mb-0;
}

View File

@@ -0,0 +1,127 @@
/*!
Theme: GitHub Dark
Description: Dark theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-dark
Current colors taken from GitHub's CSS
*/
.hljs-dark {
.hljs {
color: #c9d1d9;
background: #0d1117;
}
.hljs-doctag,
.hljs-keyword,
.hljs-meta .hljs-keyword,
.hljs-template-tag,
.hljs-template-variable,
.hljs-type,
.hljs-variable.language_ {
/* prettylights-syntax-keyword */
color: #ff7b72;
}
.hljs-title,
.hljs-title.class_,
.hljs-title.class_.inherited__,
.hljs-title.function_ {
/* prettylights-syntax-entity */
color: #d2a8ff;
}
.hljs-attr,
.hljs-attribute,
.hljs-literal,
.hljs-meta,
.hljs-number,
.hljs-operator,
.hljs-variable,
.hljs-selector-attr,
.hljs-selector-class,
.hljs-selector-id {
/* prettylights-syntax-constant */
color: #79c0ff;
}
.hljs-regexp,
.hljs-string,
.hljs-meta .hljs-string {
/* prettylights-syntax-string */
color: #a5d6ff;
}
.hljs-built_in,
.hljs-symbol {
/* prettylights-syntax-variable */
color: #ffa657;
}
.hljs-comment,
.hljs-code,
.hljs-formula {
/* prettylights-syntax-comment */
color: #8b949e;
}
.hljs-name,
.hljs-quote,
.hljs-selector-tag,
.hljs-selector-pseudo {
/* prettylights-syntax-entity-tag */
color: #7ee787;
}
.hljs-subst {
/* prettylights-syntax-storage-modifier-import */
color: #c9d1d9;
}
.hljs-section {
/* prettylights-syntax-markup-heading */
color: #1f6feb;
font-weight: bold;
}
.hljs-bullet {
/* prettylights-syntax-markup-list */
color: #f2cc60;
}
.hljs-emphasis {
/* prettylights-syntax-markup-italic */
color: #c9d1d9;
font-style: italic;
}
.hljs-strong {
/* prettylights-syntax-markup-bold */
color: #c9d1d9;
font-weight: bold;
}
.hljs-addition {
/* prettylights-syntax-markup-inserted */
color: #aff5b4;
background-color: #033a16;
}
.hljs-deletion {
/* prettylights-syntax-markup-deleted */
color: #ffdcd7;
background-color: #67060c;
}
.hljs-char.escape_,
.hljs-link,
.hljs-params,
.hljs-property,
.hljs-punctuation,
.hljs-tag {
/* purposely ignored */
}
}

View File

@@ -0,0 +1,127 @@
/*!
Theme: GitHub
Description: Light theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-light
Current colors taken from GitHub's CSS
*/
.hljs-light {
.hljs {
color: #24292e;
background: #ffffff;
}
.hljs-doctag,
.hljs-keyword,
.hljs-meta .hljs-keyword,
.hljs-template-tag,
.hljs-template-variable,
.hljs-type,
.hljs-variable.language_ {
/* prettylights-syntax-keyword */
color: #d73a49;
}
.hljs-title,
.hljs-title.class_,
.hljs-title.class_.inherited__,
.hljs-title.function_ {
/* prettylights-syntax-entity */
color: #6f42c1;
}
.hljs-attr,
.hljs-attribute,
.hljs-literal,
.hljs-meta,
.hljs-number,
.hljs-operator,
.hljs-variable,
.hljs-selector-attr,
.hljs-selector-class,
.hljs-selector-id {
/* prettylights-syntax-constant */
color: #005cc5;
}
.hljs-regexp,
.hljs-string,
.hljs-meta .hljs-string {
/* prettylights-syntax-string */
color: #032f62;
}
.hljs-built_in,
.hljs-symbol {
/* prettylights-syntax-variable */
color: #e36209;
}
.hljs-comment,
.hljs-code,
.hljs-formula {
/* prettylights-syntax-comment */
color: #6a737d;
}
.hljs-name,
.hljs-quote,
.hljs-selector-tag,
.hljs-selector-pseudo {
/* prettylights-syntax-entity-tag */
color: #22863a;
}
.hljs-subst {
/* prettylights-syntax-storage-modifier-import */
color: #24292e;
}
.hljs-section {
/* prettylights-syntax-markup-heading */
color: #005cc5;
font-weight: bold;
}
.hljs-bullet {
/* prettylights-syntax-markup-list */
color: #735c0f;
}
.hljs-emphasis {
/* prettylights-syntax-markup-italic */
color: #24292e;
font-style: italic;
}
.hljs-strong {
/* prettylights-syntax-markup-bold */
color: #24292e;
font-weight: bold;
}
.hljs-addition {
/* prettylights-syntax-markup-inserted */
color: #22863a;
background-color: #f0fff4;
}
.hljs-deletion {
/* prettylights-syntax-markup-deleted */
color: #b31d28;
background-color: #ffeef0;
}
.hljs-char.escape_,
.hljs-link,
.hljs-params,
.hljs-property,
.hljs-punctuation,
.hljs-tag {
/* purposely ignored */
}
}

View File

@@ -0,0 +1,19 @@
export function highlightAllCode(
el: HTMLElement,
themeMode: 'light' | 'dark' = 'dark',
) {
el.querySelectorAll('pre code').forEach(e => {
highlightCode(e as HTMLElement, themeMode);
});
}
export async function highlightCode(
el: HTMLElement,
themeMode: 'light' | 'dark' = 'dark',
) {
const {hljs} = await import('@common/text-editor/highlight/highlight');
if (!el.dataset.highlighted) {
el.classList.add(themeMode === 'dark' ? 'hljs-dark' : 'hljs-light');
hljs.highlightElement(el);
}
}

View File

@@ -0,0 +1,110 @@
.hljs::selection,
.hljs ::selection {
background-color: #32374D;
color: #959DCB
}
/* purposely do not highlight these things */
.hljs-formula,
.hljs-params,
.hljs-property {
}
/* base03 - #676E95 - Comments, Invisibles, Line Highlighting */
.hljs-comment {
color: #676E95
}
/* base04 - #8796B0 - Dark Foreground (Used for status bars) */
.hljs-tag {
color: #8796B0
}
/* base05 - #959DCB - Default Foreground, Caret, Delimiters, Operators */
.hljs-subst,
.hljs-punctuation,
.hljs-operator {
color: #959DCB
}
.hljs-operator {
opacity: 0.7
}
/* base08 - Variables, XML Tags, Markup Link Text, Markup Lists, Diff Deleted */
.hljs-bullet,
.hljs-variable,
.hljs-template-variable,
.hljs-selector-tag,
.hljs-name,
.hljs-deletion {
color: #F07178
}
/* base09 - Integers, Boolean, Constants, XML Attributes, Markup Link Url */
.hljs-symbol,
.hljs-number,
.hljs-link,
.hljs-attr,
.hljs-variable.constant_,
.hljs-literal {
color: #F78C6C
}
/* base0A - Classes, Markup Bold, Search Text Background */
.hljs-title,
.hljs-class .hljs-title,
.hljs-title.class_ {
color: #FFCB6B
}
.hljs-strong {
font-weight: bold;
color: #FFCB6B
}
/* base0B - Strings, Inherited Class, Markup Code, Diff Inserted */
.hljs-code,
.hljs-addition,
.hljs-title.class_.inherited__,
.hljs-string {
color: #C3E88D
}
/* base0C - Support, Regular Expressions, Escape Characters, Markup Quotes */
/* guessing */
.hljs-built_in,
.hljs-doctag,
.hljs-quote,
.hljs-keyword.hljs-atrule,
.hljs-regexp {
color: #89DDFF
}
/* base0D - Functions, Methods, Attribute IDs, Headings */
.hljs-function .hljs-title,
.hljs-attribute,
.ruby .hljs-property,
.hljs-title.function_,
.hljs-section {
color: #82AAFF
}
/* base0E - Keywords, Storage, Selector, Markup Italic, Diff Changed */
/* .hljs-selector-id, */
/* .hljs-selector-class, */
/* .hljs-selector-attr, */
/* .hljs-selector-pseudo, */
.hljs-type,
.hljs-template-tag,
.diff .hljs-meta,
.hljs-keyword {
color: #C792EA
}
.hljs-emphasis {
color: #C792EA;
font-style: italic
}
/* base0F - Deprecated, Opening/Closing Embedded Language Tags, e.g. <?php ?> */
/*
prevent top level .keyword and .string scopes
from leaking into meta by accident
*/
.hljs-meta,
.hljs-meta .hljs-keyword,
.hljs-meta .hljs-string {
color: #FF5370
}
/* for v10 compatible themes */
.hljs-meta .hljs-keyword,
.hljs-meta-keyword {
font-weight: bold
}

View File

@@ -0,0 +1,32 @@
import hljs from 'highlight.js/lib/core';
// load specific languages only
import javascript from 'highlight.js/lib/languages/javascript';
import typescript from 'highlight.js/lib/languages/typescript';
import html from 'highlight.js/lib/languages/xml';
import css from 'highlight.js/lib/languages/css';
import php from 'highlight.js/lib/languages/php';
import shell from 'highlight.js/lib/languages/shell';
import bash from 'highlight.js/lib/languages/bash';
import ruby from 'highlight.js/lib/languages/ruby';
import python from 'highlight.js/lib/languages/python';
import java from 'highlight.js/lib/languages/java';
import c from 'highlight.js/lib/languages/c';
// load css
import './github-theme.css';
import './github-dark-theme.css';
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('typescript', typescript);
hljs.registerLanguage('html', html);
hljs.registerLanguage('css', css);
hljs.registerLanguage('php', php);
hljs.registerLanguage('shell', shell);
hljs.registerLanguage('bash', bash);
hljs.registerLanguage('ruby', ruby);
hljs.registerLanguage('python', python);
hljs.registerLanguage('java', java);
hljs.registerLanguage('c', c);
export {hljs};

View File

@@ -0,0 +1,32 @@
import {createLowlight} from 'lowlight';
// load specific languages only
import javascript from 'highlight.js/lib/languages/javascript';
import typescript from 'highlight.js/lib/languages/typescript';
import html from 'highlight.js/lib/languages/xml';
import css from 'highlight.js/lib/languages/css';
import php from 'highlight.js/lib/languages/php';
import shell from 'highlight.js/lib/languages/shell';
import bash from 'highlight.js/lib/languages/bash';
import ruby from 'highlight.js/lib/languages/ruby';
import python from 'highlight.js/lib/languages/python';
import java from 'highlight.js/lib/languages/java';
import c from 'highlight.js/lib/languages/c';
// load css
import './highlight-material-palenight.css';
const lowlight = createLowlight();
lowlight.register('javascript', javascript);
lowlight.register('typescript', typescript);
lowlight.register('html', html);
lowlight.register('css', css);
lowlight.register('php', php);
lowlight.register('shell', shell);
lowlight.register('bash', bash);
lowlight.register('ruby', ruby);
lowlight.register('python', python);
lowlight.register('java', java);
lowlight.register('c', c);
export {lowlight};

View File

@@ -0,0 +1,32 @@
import {Editor} from '@tiptap/react';
interface Props {
href: string;
target?: string;
text?: string;
}
export function insertLinkIntoTextEditor(
editor: Editor,
{text, target, href}: Props
) {
// no selection, insert new link with specified text
if (editor.state.selection.empty && text) {
editor.commands.insertContent(
`<a href="${href}" target="${target}">${text}</a>`
);
} else if (!editor.state.selection.empty) {
// no href provided, remove link from selection
if (!href) {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
} else {
// add link to selection
editor
.chain()
.focus()
.extendMarkRange('link')
.setLink({href: href, target})
.run();
}
}
}

View File

@@ -0,0 +1,78 @@
import clsx from 'clsx';
import {ComponentType} from 'react';
import {FormatAlignLeftIcon} from '../../icons/material/FormatAlignLeft';
import {FormatAlignCenterIcon} from '../../icons/material/FormatAlignCenter';
import {FormatAlignRightIcon} from '../../icons/material/FormatAlignRight';
import {FormatAlignJustifyIcon} from '../../icons/material/FormatAlignJustify';
import {MenubarButtonProps} from './menubar-button-props';
import {IconButton} from '../../ui/buttons/icon-button';
import {
Menu,
MenuItem,
MenuTrigger,
} from '../../ui/navigation/menu/menu-trigger';
import {Trans} from '../../i18n/trans';
import {message} from '../../i18n/message';
const iconMap = {
left: {
icon: FormatAlignLeftIcon,
label: message('Align left'),
},
center: {
icon: FormatAlignCenterIcon,
label: message('Align center'),
},
right: {
icon: FormatAlignRightIcon,
label: message('Align right'),
},
justify: {
icon: FormatAlignJustifyIcon,
label: message('Justify'),
},
};
export function AlignButtons({editor, size}: MenubarButtonProps) {
const activeKey = (Object.keys(iconMap).find(key => {
return editor.isActive({textAlign: key});
}) || 'left') as keyof typeof iconMap;
const ActiveIcon: ComponentType = activeKey
? iconMap[activeKey].icon
: iconMap.left.icon;
return (
<MenuTrigger
floatingWidth="auto"
selectionMode="single"
selectedValue={activeKey}
onSelectionChange={key => {
editor.commands.focus();
editor.commands.setTextAlign(key as string);
}}
>
<IconButton
size={size}
color={activeKey ? 'primary' : null}
className={clsx('flex-shrink-0')}
>
<ActiveIcon />
</IconButton>
<Menu>
{Object.entries(iconMap).map(([name, config]) => {
const Icon = config.icon;
return (
<MenuItem
key={name}
value={name}
startIcon={<Icon size="md" />}
capitalizeFirst
>
<Trans message={config.label.message} />
</MenuItem>
);
})}
</Menu>
</MenuTrigger>
);
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
import clsx from 'clsx';
import {FormatClearIcon} from '../../icons/material/FormatClear';
import {IconButton} from '../../ui/buttons/icon-button';
import {MenubarButtonProps} from './menubar-button-props';
import {Tooltip} from '@common/ui/tooltip/tooltip';
import {Trans} from '@common/i18n/trans';
export function ClearFormatButton({editor, size}: MenubarButtonProps) {
return (
<Tooltip label={<Trans message="Clear formatting" />}>
<IconButton
className={clsx('flex-shrink-0')}
size={size}
onClick={() => {
editor.chain().focus().clearNodes().unsetAllMarks().run();
}}
>
<FormatClearIcon />
</IconButton>
</Tooltip>
);
}

View File

@@ -0,0 +1,47 @@
import React from 'react';
import clsx from 'clsx';
import {IconButton} from '../../ui/buttons/icon-button';
import {CodeIcon} from '../../icons/material/Code';
import {MenubarButtonProps} from './menubar-button-props';
import {
Menu,
MenuItem,
MenuTrigger,
} from '../../ui/navigation/menu/menu-trigger';
import {Tooltip} from '@common/ui/tooltip/tooltip';
import {Trans} from '@common/i18n/trans';
export function CodeBlockMenuTrigger({editor, size}: MenubarButtonProps) {
const language = editor.getAttributes('codeBlock').language || '';
return (
<MenuTrigger
selectionMode="single"
selectedValue={language}
onSelectionChange={key => {
editor.commands.toggleCodeBlock({language: key as string});
}}
>
<Tooltip label={<Trans message="Codeblock" />}>
<IconButton
className={clsx('flex-shrink-0')}
size={size}
color={language ? 'primary' : null}
>
<CodeIcon />
</IconButton>
</Tooltip>
<Menu>
<MenuItem value="html">HTML</MenuItem>
<MenuItem value="javascript">JavaScript</MenuItem>
<MenuItem value="css">CSS</MenuItem>
<MenuItem value="php">PHP</MenuItem>
<MenuItem value="shell">Shell</MenuItem>
<MenuItem value="bash">Bash</MenuItem>
<MenuItem value="ruby">Ruby</MenuItem>
<MenuItem value="python">Python</MenuItem>
<MenuItem value="java">Java</MenuItem>
<MenuItem value="c++">C++</MenuItem>
</Menu>
</MenuTrigger>
);
}

View File

@@ -0,0 +1,55 @@
import React, {Fragment, useState} from 'react';
import clsx from 'clsx';
import {FormatColorTextIcon} from '../../icons/material/FormatColorText';
import {ColorPickerDialog} from '../../ui/color-picker/color-picker-dialog';
import {MenubarButtonProps} from './menubar-button-props';
import {IconButton} from '../../ui/buttons/icon-button';
import {FormatColorFillIcon} from '../../icons/material/FormatColorFill';
import {DialogTrigger} from '../../ui/overlays/dialog/dialog-trigger';
export function ColorButtons({editor, size}: MenubarButtonProps) {
const [dialog, setDialog] = useState<'text' | 'bg' | false>(false);
const textActive = editor.getAttributes('textStyle').color;
const backgroundActive = editor.getAttributes('textStyle').backgroundColor;
return (
<Fragment>
<span className={clsx('flex-shrink-0 whitespace-nowrap')}>
<IconButton
size={size}
color={textActive ? 'primary' : null}
onClick={() => {
setDialog('text');
}}
>
<FormatColorTextIcon />
</IconButton>
<IconButton
size={size}
color={backgroundActive ? 'primary' : null}
onClick={() => {
setDialog('bg');
}}
>
<FormatColorFillIcon />
</IconButton>
</span>
<DialogTrigger
defaultValue={dialog === 'text' ? '#000000' : '#FFFFFF'}
type="modal"
isOpen={!!dialog}
onClose={newValue => {
if (newValue) {
if (dialog === 'text') {
editor.commands.setColor(newValue);
} else {
editor.commands.setBackgroundColor(newValue);
}
}
setDialog(false);
}}
>
<ColorPickerDialog />
</DialogTrigger>
</Fragment>
);
}

View File

@@ -0,0 +1,3 @@
export function Divider() {
return <div className="self-stretch mx-4 w-1 bg-divider flex-shrink-0" />;
}

View File

@@ -0,0 +1,52 @@
import React from 'react';
import clsx from 'clsx';
import {IconButton} from '../../ui/buttons/icon-button';
import {FormatBoldIcon} from '../../icons/material/FormatBold';
import {FormatItalicIcon} from '../../icons/material/FormatItalic';
import {FormatUnderlinedIcon} from '../../icons/material/FormatUnderlined';
import {MenubarButtonProps} from './menubar-button-props';
import {Tooltip} from '@common/ui/tooltip/tooltip';
import {Trans} from '@common/i18n/trans';
export function FontStyleButtons({editor, size}: MenubarButtonProps) {
return (
<span className={clsx('flex-shrink-0 whitespace-nowrap')}>
<Tooltip label={<Trans message="Bold" />}>
<IconButton
size={size}
color={editor.isActive('bold') ? 'primary' : null}
onClick={() => {
editor.commands.focus();
editor.commands.toggleBold();
}}
>
<FormatBoldIcon />
</IconButton>
</Tooltip>
<Tooltip label={<Trans message="Italic" />}>
<IconButton
size={size}
color={editor.isActive('italic') ? 'primary' : null}
onClick={() => {
editor.commands.focus();
editor.commands.toggleItalic();
}}
>
<FormatItalicIcon />
</IconButton>
</Tooltip>
<Tooltip label={<Trans message="Underline" />}>
<IconButton
size={size}
color={editor.isActive('underline') ? 'primary' : null}
onClick={() => {
editor.commands.focus();
editor.commands.toggleUnderline();
}}
>
<FormatUnderlinedIcon />
</IconButton>
</Tooltip>
</span>
);
}

View File

@@ -0,0 +1,106 @@
import React from 'react';
import clsx from 'clsx';
import {Button} from '../../ui/buttons/button';
import {KeyboardArrowDownIcon} from '../../icons/material/KeyboardArrowDown';
import {Keyboard} from '../../ui/keyboard/keyboard';
import {MenubarButtonProps} from './menubar-button-props';
import {
Menu,
MenuItem,
MenuTrigger,
} from '../../ui/navigation/menu/menu-trigger';
import {Trans} from '../../i18n/trans';
type Level = 1 | 2 | 3 | 4;
export function FormatMenuTrigger({editor, size}: MenubarButtonProps) {
return (
<MenuTrigger
floatingMinWidth="w-256"
onItemSelected={key => {
editor.commands.focus();
if (typeof key === 'string' && key.startsWith('h')) {
editor.commands.toggleHeading({
level: parseInt(key.replace('h', '')) as Level,
});
} else if (key === 'code') {
editor.commands.toggleCode();
} else if (key === 'strike') {
editor.commands.toggleStrike();
} else if (key === 'super') {
editor.commands.toggleSuperscript();
} else if (key === 'sub') {
editor.commands.toggleSubscript();
} else if (key === 'blockquote') {
editor.commands.toggleBlockquote();
} else if (key === 'paragraph') {
editor.commands.setParagraph();
}
}}
>
<Button
className={clsx('flex-shrink-0')}
variant="text"
size={size}
endIcon={<KeyboardArrowDownIcon />}
>
<Trans message="Format" />
</Button>
<Menu>
<MenuItem value="h1" endSection={<Keyboard modifier>Alt+1</Keyboard>}>
<Trans message="Heading :number" values={{number: 1}} />
</MenuItem>
<MenuItem value="h2" endSection={<Keyboard modifier>Alt+2</Keyboard>}>
<Trans message="Heading :number" values={{number: 2}} />
</MenuItem>
<MenuItem value="h3" endSection={<Keyboard modifier>Alt+3</Keyboard>}>
<Trans message="Heading :number" values={{number: 3}} />
</MenuItem>
<MenuItem value="h4" endSection={<Keyboard modifier>Alt+4</Keyboard>}>
<Trans message="Heading :number" values={{number: 4}} />
</MenuItem>
<MenuItem value="code" endSection={<Keyboard modifier>E</Keyboard>}>
<Trans message="Code" />
</MenuItem>
<MenuItem
value="strike"
endSection={<Keyboard modifier>Shift+X</Keyboard>}
>
<Trans message="Strikethrough" />
</MenuItem>
<MenuItem
value="super"
endSection={
<Keyboard modifier separator=" ">
.
</Keyboard>
}
>
<Trans message="Superscript" />
</MenuItem>
<MenuItem
value="sub"
endSection={
<Keyboard modifier separator=" ">
,
</Keyboard>
}
>
<Trans message="Subscript" />
</MenuItem>
<MenuItem
value="blockquote"
endSection={<Keyboard modifier>Shift+B</Keyboard>}
>
<Trans message="Blockquote" />
</MenuItem>
<MenuItem
value="paragraph"
endSection={<Keyboard modifier>Alt+0</Keyboard>}
>
<Trans message="Paragraph" />
</MenuItem>
</Menu>
</MenuTrigger>
);
}

View File

@@ -0,0 +1,32 @@
import React from 'react';
import {IconButton} from '../../ui/buttons/icon-button';
import {UndoIcon} from '../../icons/material/Undo';
import {RedoIcon} from '../../icons/material/Redo';
import {MenubarButtonProps} from './menubar-button-props';
export function HistoryButtons({editor}: MenubarButtonProps) {
return (
<span>
<IconButton
size="md"
disabled={!editor.can().undo()}
onClick={() => {
editor.commands.focus();
editor.commands.undo();
}}
>
<UndoIcon />
</IconButton>
<IconButton
size="md"
disabled={!editor.can().redo()}
onClick={() => {
editor.commands.focus();
editor.commands.redo();
}}
>
<RedoIcon />
</IconButton>
</span>
);
}

View File

@@ -0,0 +1,52 @@
import React from 'react';
import clsx from 'clsx';
import {IconButton} from '../../ui/buttons/icon-button';
import {ImageIcon} from '../../icons/material/Image';
import {MenubarButtonProps} from './menubar-button-props';
import {useActiveUpload} from '../../uploads/uploader/use-active-upload';
import {UploadInputType} from '../../uploads/types/upload-input-config';
import {Disk} from '../../uploads/types/backend-metadata';
import {Tooltip} from '@common/ui/tooltip/tooltip';
import {Trans} from '@common/i18n/trans';
const TwoMB = 2097152;
interface Props extends MenubarButtonProps {
diskPrefix?: string;
}
export function ImageButton({editor, size, diskPrefix = 'page_media'}: Props) {
const {selectAndUploadFile} = useActiveUpload();
const handleUpload = () => {
selectAndUploadFile({
showToastOnRestrictionFail: true,
restrictions: {
allowedFileTypes: [UploadInputType.image],
maxFileSize: TwoMB,
},
metadata: {
diskPrefix: diskPrefix,
disk: Disk.public,
},
onSuccess: entry => {
editor.commands.focus();
editor.commands.setImage({
src: entry.url,
});
},
});
};
return (
<Tooltip label={<Trans message="Insert image" />}>
<IconButton
size={size}
onClick={handleUpload}
className={clsx('flex-shrink-0')}
>
<ImageIcon />
</IconButton>
</Tooltip>
);
}

View File

@@ -0,0 +1,37 @@
import React from 'react';
import clsx from 'clsx';
import {IconButton} from '../../ui/buttons/icon-button';
import {FormatIndentDecreaseIcon} from '../../icons/material/FormatIndentDecrease';
import {FormatIndentIncreaseIcon} from '../../icons/material/FormatIndentIncrease';
import {MenubarButtonProps} from './menubar-button-props';
import {Tooltip} from '@common/ui/tooltip/tooltip';
import {Trans} from '@common/i18n/trans';
export function IndentButtons({editor, size}: MenubarButtonProps) {
return (
<span className={clsx('flex-shrink-0', 'whitespace-nowrap')}>
<Tooltip label={<Trans message="Decrease indent" />}>
<IconButton
size={size}
onClick={() => {
editor.commands.focus();
editor.commands.outdent();
}}
>
<FormatIndentDecreaseIcon />
</IconButton>
</Tooltip>
<Tooltip label={<Trans message="Increase indent" />}>
<IconButton
size={size}
onClick={() => {
editor.commands.focus();
editor.commands.indent();
}}
>
<FormatIndentIncreaseIcon />
</IconButton>
</Tooltip>
</span>
);
}

View File

@@ -0,0 +1,128 @@
import React, {useState} from 'react';
import {useForm} from 'react-hook-form';
import clsx from 'clsx';
import {HorizontalRuleIcon} from '../../icons/material/HorizontalRule';
import {PriorityHighIcon} from '../../icons/material/PriorityHigh';
import {WarningIcon} from '../../icons/material/Warning';
import {NoteIcon} from '../../icons/material/Note';
import {MenubarButtonProps} from './menubar-button-props';
import {IconButton} from '../../ui/buttons/icon-button';
import {MoreVertIcon} from '../../icons/material/MoreVert';
import {SmartDisplayIcon} from '../../icons/material/SmartDisplay';
import {Form} from '../../ui/forms/form';
import {FormTextField} from '../../ui/forms/input-field/text-field/text-field';
import {DialogFooter} from '../../ui/overlays/dialog/dialog-footer';
import {Button} from '../../ui/buttons/button';
import {
Menu,
MenuItem,
MenuTrigger,
} from '../../ui/navigation/menu/menu-trigger';
import {DialogTrigger} from '../../ui/overlays/dialog/dialog-trigger';
import {useDialogContext} from '../../ui/overlays/dialog/dialog-context';
import {Dialog} from '../../ui/overlays/dialog/dialog';
import {DialogHeader} from '../../ui/overlays/dialog/dialog-header';
import {DialogBody} from '../../ui/overlays/dialog/dialog-body';
import {Trans} from '../../i18n/trans';
export function InsertMenuTrigger({editor, size}: MenubarButtonProps) {
const [dialog, setDialog] = useState<'embed' | false>(false);
return (
<>
<MenuTrigger
onItemSelected={key => {
if (key === 'hr') {
editor.commands.focus();
editor.commands.setHorizontalRule();
} else if (key === 'embed') {
setDialog('embed');
} else {
editor.commands.focus();
editor.commands.addInfo({type: key as any});
}
}}
>
<IconButton
variant="text"
size={size}
className={clsx('flex-shrink-0')}
>
<MoreVertIcon />
</IconButton>
<Menu>
<MenuItem value="hr" startIcon={<HorizontalRuleIcon />}>
<Trans message="Horizontal rule" />
</MenuItem>
<MenuItem value="embed" startIcon={<SmartDisplayIcon />}>
<Trans message="Embed" />
</MenuItem>
<MenuItem value="important" startIcon={<PriorityHighIcon />}>
<Trans message="Important" />
</MenuItem>
<MenuItem value="warning" startIcon={<WarningIcon />}>
<Trans message="Warning" />
</MenuItem>
<MenuItem value="success" startIcon={<NoteIcon />}>
<Trans message="Note" />
</MenuItem>
</Menu>
</MenuTrigger>
<DialogTrigger
type="modal"
isOpen={!!dialog}
onClose={() => {
setDialog(false);
}}
>
<EmbedDialog editor={editor} />
</DialogTrigger>
</>
);
}
function EmbedDialog({editor}: MenubarButtonProps) {
const previousSrc = editor.getAttributes('embed').src;
const form = useForm<{src: string}>({
defaultValues: {src: previousSrc},
});
const {formId, close} = useDialogContext();
return (
<Dialog>
<DialogHeader>
<Trans message="Insert link" />
</DialogHeader>
<DialogBody>
<Form
form={form}
id={formId}
onSubmit={value => {
editor.commands.setEmbed(value);
close();
}}
>
<FormTextField
name="src"
label={<Trans message="Embed URL" />}
autoFocus
type="url"
required
/>
</Form>
</DialogBody>
<DialogFooter>
<Button onClick={close} variant="text">
<Trans message="Cancel" />
</Button>
<Button
type="submit"
form={formId}
disabled={!form.formState.isValid}
variant="flat"
color="primary"
>
<Trans message="Add" />
</Button>
</DialogFooter>
</Dialog>
);
}

View File

@@ -0,0 +1,102 @@
import {useForm} from 'react-hook-form';
import React from 'react';
import clsx from 'clsx';
import {Form} from '../../ui/forms/form';
import {FormTextField} from '../../ui/forms/input-field/text-field/text-field';
import {DialogFooter} from '../../ui/overlays/dialog/dialog-footer';
import {Button} from '../../ui/buttons/button';
import {IconButton} from '../../ui/buttons/icon-button';
import {LinkIcon} from '../../icons/material/Link';
import {MenubarButtonProps} from './menubar-button-props';
import {DialogTrigger} from '../../ui/overlays/dialog/dialog-trigger';
import {FormSelect, Option} from '../../ui/forms/select/select';
import {useDialogContext} from '../../ui/overlays/dialog/dialog-context';
import {Dialog} from '../../ui/overlays/dialog/dialog';
import {DialogHeader} from '../../ui/overlays/dialog/dialog-header';
import {DialogBody} from '../../ui/overlays/dialog/dialog-body';
import {Trans} from '../../i18n/trans';
import {Tooltip} from '@common/ui/tooltip/tooltip';
import {insertLinkIntoTextEditor} from '@common/text-editor/insert-link-into-text-editor';
interface FormValue {
href: string;
target?: string;
text?: string;
}
export function LinkButton({editor, size}: MenubarButtonProps) {
return (
<DialogTrigger type="modal">
<Tooltip label={<Trans message="Insert link" />}>
<IconButton size={size} className={clsx('flex-shrink-0')}>
<LinkIcon />
</IconButton>
</Tooltip>
<LinkDialog editor={editor} />
</DialogTrigger>
);
}
function LinkDialog({editor}: MenubarButtonProps) {
const previousUrl = editor.getAttributes('link').href;
const previousText = editor.state.doc.textBetween(
editor.state.selection.from,
editor.state.selection.to,
'',
);
const form = useForm<FormValue>({
defaultValues: {href: previousUrl, text: previousText, target: '_blank'},
});
const {formId, close} = useDialogContext();
return (
<Dialog>
<DialogHeader>
<Trans message="Insert link" />
</DialogHeader>
<DialogBody>
<Form
form={form}
id={formId}
onSubmit={value => {
insertLinkIntoTextEditor(editor, value);
close();
}}
>
<FormTextField
name="href"
label={<Trans message="URL" />}
autoFocus
type="url"
className="mb-20"
/>
<FormTextField
name="text"
label={<Trans message="Text to display" />}
className="mb-20"
/>
<FormSelect
selectionMode="single"
name="target"
label={<Trans message="Open link in..." />}
>
<Option value="_self">
<Trans message="Current window" />
</Option>
<Option value="_blank">
<Trans message="New window" />
</Option>
</FormSelect>
</Form>
</DialogBody>
<DialogFooter>
<Button onClick={close} variant="text">
<Trans message="Cancel" />
</Button>
<Button type="submit" form={formId} variant="flat" color="primary">
<Trans message="Save" />
</Button>
</DialogFooter>
</Dialog>
);
}

View File

@@ -0,0 +1,41 @@
import React from 'react';
import clsx from 'clsx';
import {IconButton} from '../../ui/buttons/icon-button';
import {FormatListBulletedIcon} from '../../icons/material/FormatListBulleted';
import {FormatListNumberedIcon} from '../../icons/material/FormatListNumbered';
import {MenubarButtonProps} from './menubar-button-props';
import {Tooltip} from '@common/ui/tooltip/tooltip';
import {Trans} from '@common/i18n/trans';
export function ListButtons({editor, size}: MenubarButtonProps) {
const bulletActive = editor.isActive('bulletList');
const orderedActive = editor.isActive('orderedList');
return (
<span className={clsx('flex-shrink-0', 'whitespace-nowrap')}>
<Tooltip label={<Trans message="Bulleted list" />}>
<IconButton
size={size}
color={bulletActive ? 'primary' : null}
onClick={() => {
editor.commands.focus();
editor.commands.toggleBulletList();
}}
>
<FormatListBulletedIcon />
</IconButton>
</Tooltip>
<Tooltip label={<Trans message="Numbered list" />}>
<IconButton
size={size}
color={orderedActive ? 'primary' : null}
onClick={() => {
editor.commands.focus();
editor.commands.toggleOrderedList();
}}
>
<FormatListNumberedIcon />
</IconButton>
</Tooltip>
</span>
);
}

View File

@@ -0,0 +1,7 @@
import type {Editor} from '@tiptap/react';
import {ButtonSize} from '../../ui/buttons/button-size';
export interface MenubarButtonProps {
editor: Editor;
size?: ButtonSize;
}

View File

@@ -0,0 +1,31 @@
import {Button} from '../../ui/buttons/button';
import {CodeIcon} from '../../icons/material/Code';
import {Trans} from '../../i18n/trans';
import {DialogTrigger} from '../../ui/overlays/dialog/dialog-trigger';
import {AceDialog} from '../../ace-editor/ace-dialog';
import {Editor} from '@tiptap/react';
import React from 'react';
interface ModeButtonProps {
editor: Editor;
}
export function ModeButton({editor}: ModeButtonProps) {
return (
<DialogTrigger
type="modal"
onClose={newValue => {
if (newValue != null) {
editor?.commands.setContent(newValue);
}
}}
>
<Button variant="text" startIcon={<CodeIcon />}>
<Trans message="Source" />
</Button>
<AceDialog
title={<Trans message="Source code" />}
defaultValue={editor.getHTML()}
/>
</DialogTrigger>
);
}