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,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>
);
}