feat: migrate dashboard to svelte 5 (#577)
* chore: migration to SvelteKit 2 * stage 1: migrate to svelte 5 and fix rerendering issues * stage 2: fix more $: reactivity across dashboard * fix merge conflicts * chore: migrate code in main to svelte 5 * chore: update preventDefault * chore: simplify tsconfig and remove unused mobileMenuRef in MobileMenu.svelte * chore: enforce .ts across dashboard * rename from classroomio-com to website * stage-3: Replace carbon icon svelte with lucide icon * update lock file * chore: finish icon replacement * fix: settings and setup page multiple rerender * stage 3: More icon and rerendering fixes * feat: checkpoint * feat: update editor * [not working] connect everything with ui build * storybook + ui + dashboard all working independently * add bg-black to +page.svelte * feat: add editor to storybook * chore: animate lucid icons (#590) * v1 animations * feat: change some course navigation icons & animate org navigation icons * chore: change settings icon * chore: use css instead of js for hover * rename animations --------- Co-authored-by: tunny17 <[email protected]> * chore: update path to node_modules types in api tsconfig * fix: update import path for DropdownMenu component * fix: add rollupOptions to build configuration in vite.config.ts * fix: remove rollupOptions from build configuration and add @floating-ui/dom dependency * install --------- Co-authored-by: tunny17 <[email protected]>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin } from '@tiptap/pm/state';
|
||||
import { findColors } from '../utils';
|
||||
|
||||
export const ColorHighlighter = Extension.create({
|
||||
name: 'colorHighlighter',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
state: {
|
||||
init(_, { doc }) {
|
||||
return findColors(doc);
|
||||
},
|
||||
apply(transaction, oldState) {
|
||||
return transaction.docChanged ? findColors(transaction.doc) : oldState;
|
||||
}
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state);
|
||||
}
|
||||
}
|
||||
})
|
||||
];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,408 @@
|
||||
// MIT License
|
||||
|
||||
// Copyright (c) 2023 - 2024 Jeet Mandaliya (Github Username: sereneinserenade)
|
||||
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { Extension, type Range, type Dispatch } from '@tiptap/core';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state';
|
||||
import { Node as PMNode } from '@tiptap/pm/model';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
search: {
|
||||
/**
|
||||
* @description Set search term in extension.
|
||||
*/
|
||||
setSearchTerm: (searchTerm: string) => ReturnType;
|
||||
/**
|
||||
* @description Set replace term in extension.
|
||||
*/
|
||||
setReplaceTerm: (replaceTerm: string) => ReturnType;
|
||||
/**
|
||||
* @description Set case sensitivity in extension.
|
||||
*/
|
||||
setCaseSensitive: (caseSensitive: boolean) => ReturnType;
|
||||
/**
|
||||
* @description Reset current search result to first instance.
|
||||
*/
|
||||
resetIndex: () => ReturnType;
|
||||
/**
|
||||
* @description Find next instance of search result.
|
||||
*/
|
||||
nextSearchResult: () => ReturnType;
|
||||
/**
|
||||
* @description Find previous instance of search result.
|
||||
*/
|
||||
previousSearchResult: () => ReturnType;
|
||||
/**
|
||||
* @description Replace first instance of search result with given replace term.
|
||||
*/
|
||||
replace: () => ReturnType;
|
||||
/**
|
||||
* @description Replace all instances of search result with given replace term.
|
||||
*/
|
||||
replaceAll: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface TextNodesWithPosition {
|
||||
text: string;
|
||||
pos: number;
|
||||
}
|
||||
|
||||
const getRegex = (s: string, disableRegex: boolean, caseSensitive: boolean): RegExp => {
|
||||
return RegExp(
|
||||
disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : s,
|
||||
caseSensitive ? 'gu' : 'gui'
|
||||
);
|
||||
};
|
||||
|
||||
interface ProcessedSearches {
|
||||
decorationsToReturn: DecorationSet;
|
||||
results: Range[];
|
||||
}
|
||||
|
||||
function processSearches(
|
||||
doc: PMNode,
|
||||
searchTerm: RegExp,
|
||||
searchResultClass: string,
|
||||
resultIndex: number
|
||||
): ProcessedSearches {
|
||||
const decorations: Decoration[] = [];
|
||||
const results: Range[] = [];
|
||||
|
||||
let textNodesWithPosition: TextNodesWithPosition[] = [];
|
||||
let index = 0;
|
||||
|
||||
if (!searchTerm) {
|
||||
return {
|
||||
decorationsToReturn: DecorationSet.empty,
|
||||
results: []
|
||||
};
|
||||
}
|
||||
|
||||
doc?.descendants((node, pos) => {
|
||||
if (node.isText) {
|
||||
if (textNodesWithPosition[index]) {
|
||||
textNodesWithPosition[index] = {
|
||||
text: textNodesWithPosition[index].text + node.text,
|
||||
pos: textNodesWithPosition[index].pos
|
||||
};
|
||||
} else {
|
||||
textNodesWithPosition[index] = {
|
||||
text: `${node.text}`,
|
||||
pos
|
||||
};
|
||||
}
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
});
|
||||
|
||||
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
|
||||
|
||||
for (const element of textNodesWithPosition) {
|
||||
const { text, pos } = element;
|
||||
const matches = Array.from(text.matchAll(searchTerm)).filter(([matchText]) => matchText.trim());
|
||||
|
||||
for (const m of matches) {
|
||||
if (m[0] === '') break;
|
||||
|
||||
if (m.index !== undefined) {
|
||||
results.push({
|
||||
from: pos + m.index,
|
||||
to: pos + m.index + m[0].length
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < results.length; i += 1) {
|
||||
const r = results[i];
|
||||
const className =
|
||||
i === resultIndex ? `${searchResultClass} ${searchResultClass}-current` : searchResultClass;
|
||||
const decoration: Decoration = Decoration.inline(r.from, r.to, {
|
||||
class: className
|
||||
});
|
||||
|
||||
decorations.push(decoration);
|
||||
}
|
||||
|
||||
return {
|
||||
decorationsToReturn: DecorationSet.create(doc, decorations),
|
||||
results
|
||||
};
|
||||
}
|
||||
|
||||
const replace = (
|
||||
replaceTerm: string,
|
||||
results: Range[],
|
||||
{ state, dispatch }: { state: EditorState; dispatch: Dispatch }
|
||||
) => {
|
||||
const firstResult = results[0];
|
||||
|
||||
if (!firstResult) return;
|
||||
|
||||
const { from, to } = results[0];
|
||||
|
||||
if (dispatch) dispatch(state.tr.insertText(replaceTerm, from, to));
|
||||
};
|
||||
|
||||
const rebaseNextResult = (
|
||||
replaceTerm: string,
|
||||
index: number,
|
||||
lastOffset: number,
|
||||
results: Range[]
|
||||
): [number, Range[]] | null => {
|
||||
const nextIndex = index + 1;
|
||||
|
||||
if (!results[nextIndex]) return null;
|
||||
|
||||
const { from: currentFrom, to: currentTo } = results[index];
|
||||
|
||||
const offset = currentTo - currentFrom - replaceTerm.length + lastOffset;
|
||||
|
||||
const { from, to } = results[nextIndex];
|
||||
|
||||
results[nextIndex] = {
|
||||
to: to - offset,
|
||||
from: from - offset
|
||||
};
|
||||
|
||||
return [offset, results];
|
||||
};
|
||||
|
||||
const replaceAll = (
|
||||
replaceTerm: string,
|
||||
results: Range[],
|
||||
{ tr, dispatch }: { tr: Transaction; dispatch: Dispatch }
|
||||
) => {
|
||||
let offset = 0;
|
||||
|
||||
let resultsCopy = results.slice();
|
||||
|
||||
if (!resultsCopy.length) return;
|
||||
|
||||
for (let i = 0; i < resultsCopy.length; i += 1) {
|
||||
const { from, to } = resultsCopy[i];
|
||||
|
||||
tr.insertText(replaceTerm, from, to);
|
||||
|
||||
const rebaseNextResultResponse = rebaseNextResult(replaceTerm, i, offset, resultsCopy);
|
||||
|
||||
if (!rebaseNextResultResponse) continue;
|
||||
|
||||
offset = rebaseNextResultResponse[0];
|
||||
resultsCopy = rebaseNextResultResponse[1];
|
||||
}
|
||||
|
||||
if (dispatch) {
|
||||
dispatch(tr);
|
||||
}
|
||||
};
|
||||
|
||||
export const searchAndReplacePluginKey = new PluginKey('searchAndReplacePlugin');
|
||||
|
||||
export interface SearchAndReplaceOptions {
|
||||
searchResultClass: string;
|
||||
disableRegex: boolean;
|
||||
}
|
||||
|
||||
export interface SearchAndReplaceStorage {
|
||||
searchTerm: string;
|
||||
replaceTerm: string;
|
||||
results: Range[];
|
||||
lastSearchTerm: string;
|
||||
caseSensitive: boolean;
|
||||
lastCaseSensitive: boolean;
|
||||
resultIndex: number;
|
||||
lastResultIndex: number;
|
||||
}
|
||||
|
||||
export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, SearchAndReplaceStorage>({
|
||||
name: 'searchAndReplace',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
searchResultClass: 'search-result',
|
||||
disableRegex: true
|
||||
};
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
searchTerm: '',
|
||||
replaceTerm: '',
|
||||
results: [],
|
||||
lastSearchTerm: '',
|
||||
caseSensitive: false,
|
||||
lastCaseSensitive: false,
|
||||
resultIndex: 0,
|
||||
lastResultIndex: 0
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setSearchTerm:
|
||||
(searchTerm: string) =>
|
||||
({ editor }) => {
|
||||
editor.storage.searchAndReplace.searchTerm = searchTerm;
|
||||
|
||||
return false;
|
||||
},
|
||||
setReplaceTerm:
|
||||
(replaceTerm: string) =>
|
||||
({ editor }) => {
|
||||
editor.storage.searchAndReplace.replaceTerm = replaceTerm;
|
||||
|
||||
return false;
|
||||
},
|
||||
setCaseSensitive:
|
||||
(caseSensitive: boolean) =>
|
||||
({ editor }) => {
|
||||
editor.storage.searchAndReplace.caseSensitive = caseSensitive;
|
||||
|
||||
return false;
|
||||
},
|
||||
resetIndex:
|
||||
() =>
|
||||
({ editor }) => {
|
||||
editor.storage.searchAndReplace.resultIndex = 0;
|
||||
|
||||
return false;
|
||||
},
|
||||
nextSearchResult:
|
||||
() =>
|
||||
({ editor }) => {
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||
|
||||
const nextIndex = resultIndex + 1;
|
||||
|
||||
if (results[nextIndex]) {
|
||||
editor.storage.searchAndReplace.resultIndex = nextIndex;
|
||||
} else {
|
||||
editor.storage.searchAndReplace.resultIndex = 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
previousSearchResult:
|
||||
() =>
|
||||
({ editor }) => {
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||
|
||||
const prevIndex = resultIndex - 1;
|
||||
|
||||
if (results[prevIndex]) {
|
||||
editor.storage.searchAndReplace.resultIndex = prevIndex;
|
||||
} else {
|
||||
editor.storage.searchAndReplace.resultIndex = results.length - 1;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
replace:
|
||||
() =>
|
||||
({ editor, state, dispatch }) => {
|
||||
const { replaceTerm, results } = editor.storage.searchAndReplace;
|
||||
|
||||
replace(replaceTerm, results, { state, dispatch });
|
||||
|
||||
return false;
|
||||
},
|
||||
replaceAll:
|
||||
() =>
|
||||
({ editor, tr, dispatch }) => {
|
||||
const { replaceTerm, results } = editor.storage.searchAndReplace;
|
||||
|
||||
replaceAll(replaceTerm, results, { tr, dispatch });
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const editor = this.editor;
|
||||
const { searchResultClass, disableRegex } = this.options;
|
||||
|
||||
const setLastSearchTerm = (t: string) => (editor.storage.searchAndReplace.lastSearchTerm = t);
|
||||
const setLastCaseSensitive = (t: boolean) =>
|
||||
(editor.storage.searchAndReplace.lastCaseSensitive = t);
|
||||
const setLastResultIndex = (t: number) => (editor.storage.searchAndReplace.lastResultIndex = t);
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: searchAndReplacePluginKey,
|
||||
state: {
|
||||
init: () => DecorationSet.empty,
|
||||
apply({ doc, docChanged }, oldState) {
|
||||
const {
|
||||
searchTerm,
|
||||
lastSearchTerm,
|
||||
caseSensitive,
|
||||
lastCaseSensitive,
|
||||
resultIndex,
|
||||
lastResultIndex
|
||||
} = editor.storage.searchAndReplace;
|
||||
|
||||
if (
|
||||
!docChanged &&
|
||||
lastSearchTerm === searchTerm &&
|
||||
lastCaseSensitive === caseSensitive &&
|
||||
lastResultIndex === resultIndex
|
||||
)
|
||||
return oldState;
|
||||
|
||||
setLastSearchTerm(searchTerm);
|
||||
setLastCaseSensitive(caseSensitive);
|
||||
setLastResultIndex(resultIndex);
|
||||
|
||||
if (!searchTerm) {
|
||||
editor.storage.searchAndReplace.results = [];
|
||||
return DecorationSet.empty;
|
||||
}
|
||||
|
||||
const { decorationsToReturn, results } = processSearches(
|
||||
doc,
|
||||
getRegex(searchTerm, disableRegex, caseSensitive),
|
||||
searchResultClass,
|
||||
resultIndex
|
||||
);
|
||||
|
||||
editor.storage.searchAndReplace.results = results;
|
||||
|
||||
return decorationsToReturn;
|
||||
}
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state);
|
||||
}
|
||||
}
|
||||
})
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
export default SearchAndReplace;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Audio } from './AudioExtension';
|
||||
import type { Component } from 'svelte';
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
|
||||
export const AudioExtended = (content: Component<NodeViewProps>) =>
|
||||
Audio.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null
|
||||
},
|
||||
alt: {
|
||||
default: null
|
||||
},
|
||||
title: {
|
||||
default: null
|
||||
},
|
||||
width: {
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
default: null
|
||||
},
|
||||
align: {
|
||||
default: 'left'
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView: () => {
|
||||
return SvelteNodeViewRenderer(content);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Node, nodeInputRule } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
|
||||
export interface AudioOptions {
|
||||
HTMLAttributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
audio: {
|
||||
/**
|
||||
* Set a audio node
|
||||
*/
|
||||
setAudio: (src: string) => ReturnType;
|
||||
/**
|
||||
* Toggle a audio
|
||||
*/
|
||||
toggleAudio: (src: string) => ReturnType;
|
||||
/**
|
||||
* Remove a audio
|
||||
*/
|
||||
removeAudio: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const AUDIO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/;
|
||||
|
||||
export const Audio = Node.create<AudioOptions>({
|
||||
name: 'audio',
|
||||
group: 'block',
|
||||
draggable: true,
|
||||
isolating: true,
|
||||
atom: true,
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {}
|
||||
};
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null,
|
||||
parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'),
|
||||
renderHTML: (attrs) => ({ src: attrs.src })
|
||||
}
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'audio',
|
||||
getAttrs: (el) => ({ src: (el as HTMLAudioElement).getAttribute('src') })
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'audio',
|
||||
{ controls: 'true', style: 'width: 100%;', ...HTMLAttributes },
|
||||
['source', HTMLAttributes]
|
||||
];
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
setAudio:
|
||||
(src: string) =>
|
||||
({ commands }) =>
|
||||
commands.insertContent(
|
||||
`<audio controls autoplay="false" style="width: 100%;" src="${src}"></audio>`
|
||||
),
|
||||
|
||||
toggleAudio:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.toggleNode(this.name, 'paragraph'),
|
||||
removeAudio:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.deleteNode(this.name)
|
||||
};
|
||||
},
|
||||
addInputRules() {
|
||||
return [
|
||||
nodeInputRule({
|
||||
find: AUDIO_INPUT_REGEX,
|
||||
type: this.type,
|
||||
getAttributes: (match) => {
|
||||
const [, , src] = match;
|
||||
|
||||
return { src };
|
||||
}
|
||||
})
|
||||
];
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('audioDropPlugin'),
|
||||
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
drop(view, event) {
|
||||
const {
|
||||
state: { schema, tr },
|
||||
dispatch
|
||||
} = view;
|
||||
const hasFiles =
|
||||
event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length;
|
||||
|
||||
if (!hasFiles) return false;
|
||||
|
||||
const audios = Array.from(event.dataTransfer.files).filter((file) =>
|
||||
/audio/i.test(file.type)
|
||||
);
|
||||
|
||||
if (audios.length === 0) return false;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
||||
|
||||
audios.forEach((audio) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (readerEvent) => {
|
||||
const node = schema.nodes.audio.create({ src: readerEvent.target?.result });
|
||||
|
||||
if (coordinates && typeof coordinates.pos === 'number') {
|
||||
const transaction = tr.insert(coordinates?.pos, node);
|
||||
|
||||
dispatch(transaction);
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsDataURL(audio);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
|
||||
import type { Component } from 'svelte';
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
|
||||
export interface AudioPlaceholderOptions {
|
||||
HTMLAttributes: Record<string, object>;
|
||||
onDrop: (files: File[], editor: Editor) => void;
|
||||
onDropRejected?: (files: File[], editor: Editor) => void;
|
||||
onEmbed: (url: string, editor: Editor) => void;
|
||||
allowedMimeTypes?: Record<string, string[]>;
|
||||
maxFiles?: number;
|
||||
maxSize?: number;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
audioPlaceholder: {
|
||||
/**
|
||||
* Inserts an audio placeholder
|
||||
*/
|
||||
insertAudioPlaceholder: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const AudioPlaceholder = (
|
||||
component: Component<NodeViewProps>
|
||||
): Node<AudioPlaceholderOptions> =>
|
||||
Node.create<AudioPlaceholderOptions>({
|
||||
name: 'audio-placeholder',
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
onDrop: () => {},
|
||||
onDropRejected: () => {},
|
||||
onEmbed: () => {}
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="${this.name}"]` }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
group: 'block',
|
||||
draggable: true,
|
||||
atom: true,
|
||||
content: 'inline*',
|
||||
isolating: true,
|
||||
|
||||
addNodeView() {
|
||||
return SvelteNodeViewRenderer(component);
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
insertAudioPlaceholder: () => (props: CommandProps) => {
|
||||
return props.commands.insertContent({
|
||||
type: 'audio-placeholder'
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Slice } from '@tiptap/pm/model';
|
||||
import { EditorView } from '@tiptap/pm/view';
|
||||
import * as pmView from '@tiptap/pm/view';
|
||||
|
||||
function getPmView() {
|
||||
try {
|
||||
return pmView;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeForClipboard(view: EditorView, slice: Slice) {
|
||||
// Newer Tiptap/ProseMirror
|
||||
if (view && typeof view.serializeForClipboard === 'function') {
|
||||
return view.serializeForClipboard(slice);
|
||||
}
|
||||
|
||||
// Older version fallback
|
||||
const proseMirrorView = getPmView();
|
||||
|
||||
if (proseMirrorView && typeof proseMirrorView?.__serializeForClipboard === 'function') {
|
||||
return proseMirrorView.__serializeForClipboard(view, slice);
|
||||
}
|
||||
|
||||
throw new Error('No supported clipboard serialization method found.');
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import { Fragment, Node, Slice } from '@tiptap/pm/model';
|
||||
import { NodeSelection, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
|
||||
|
||||
import { EditorView } from '@tiptap/pm/view';
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { serializeForClipboard } from './ClipboardSerializer';
|
||||
|
||||
export interface GlobalDragHandleOptions {
|
||||
/**
|
||||
* The width of the drag handle
|
||||
*/
|
||||
dragHandleWidth: number;
|
||||
|
||||
/**
|
||||
* The treshold for scrolling
|
||||
*/
|
||||
scrollTreshold: number;
|
||||
|
||||
/*
|
||||
* The css selector to query for the drag handle. (eg: '.custom-handle').
|
||||
* If handle element is found, that element will be used as drag handle. If not, a default handle will be created
|
||||
*/
|
||||
dragHandleSelector?: string;
|
||||
|
||||
/**
|
||||
* Tags to be excluded for drag handle
|
||||
*/
|
||||
excludedTags: string[];
|
||||
|
||||
/**
|
||||
* Custom nodes to be included for drag handle
|
||||
*/
|
||||
customNodes: string[];
|
||||
|
||||
/**
|
||||
* onNodeChange callback for drag handle
|
||||
* @param data
|
||||
* @returns
|
||||
*/
|
||||
onMouseMove?: (data: { node: Node; pos: number }) => void;
|
||||
}
|
||||
function absoluteRect(node: Element) {
|
||||
const data = node.getBoundingClientRect();
|
||||
const modal = node.closest('[role="dialog"]');
|
||||
|
||||
if (modal && window.getComputedStyle(modal).transform !== 'none') {
|
||||
const modalRect = modal.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
top: data.top - modalRect.top,
|
||||
left: data.left - modalRect.left,
|
||||
width: data.width
|
||||
};
|
||||
}
|
||||
return {
|
||||
top: data.top,
|
||||
left: data.left,
|
||||
width: data.width
|
||||
};
|
||||
}
|
||||
|
||||
function nodeDOMAtCoords(coords: { x: number; y: number }, options: GlobalDragHandleOptions) {
|
||||
const selectors = [
|
||||
'li',
|
||||
'p:not(:first-child)',
|
||||
'pre',
|
||||
'blockquote',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
...options.customNodes.map((node) => `[data-type=${node}]`)
|
||||
].join(', ');
|
||||
return document
|
||||
.elementsFromPoint(coords.x, coords.y)
|
||||
.find((elem: Element) => elem.parentElement?.matches?.('.ProseMirror') || elem.matches(selectors));
|
||||
}
|
||||
function nodePosAtDOM(node: Element, view: EditorView, options: GlobalDragHandleOptions) {
|
||||
const boundingRect = node.getBoundingClientRect();
|
||||
|
||||
return view.posAtCoords({
|
||||
left: boundingRect.left + 50 + options.dragHandleWidth,
|
||||
top: boundingRect.top + 1
|
||||
})?.inside;
|
||||
}
|
||||
|
||||
function calcNodePos(pos: number, view: EditorView) {
|
||||
const $pos = view.state.doc.resolve(pos);
|
||||
if ($pos.depth > 1) return $pos.before($pos.depth);
|
||||
return pos;
|
||||
}
|
||||
|
||||
export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey: string }) {
|
||||
let listType = '';
|
||||
function handleDragStart(event: DragEvent, view: EditorView) {
|
||||
view.focus();
|
||||
|
||||
if (!event.dataTransfer) return;
|
||||
|
||||
const node = nodeDOMAtCoords(
|
||||
{
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
if (!(node instanceof Element)) return;
|
||||
|
||||
let draggedNodePos = nodePosAtDOM(node, view, options);
|
||||
if (draggedNodePos == null || draggedNodePos < 0) return;
|
||||
draggedNodePos = calcNodePos(draggedNodePos, view);
|
||||
|
||||
const { from, to } = view.state.selection;
|
||||
const diff = from - to;
|
||||
|
||||
const fromSelectionPos = calcNodePos(from, view);
|
||||
let differentNodeSelected = false;
|
||||
|
||||
const nodePos = view.state.doc.resolve(fromSelectionPos);
|
||||
|
||||
// Check if nodePos points to the top level node
|
||||
if (nodePos.node().type.name === 'doc') differentNodeSelected = true;
|
||||
else {
|
||||
const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before());
|
||||
|
||||
// Check if the node where the drag event started is part of the current selection
|
||||
differentNodeSelected = !(
|
||||
draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos
|
||||
);
|
||||
}
|
||||
let selection = view.state.selection;
|
||||
if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) {
|
||||
const endSelection = NodeSelection.create(view.state.doc, to - 1);
|
||||
selection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos);
|
||||
} else {
|
||||
selection = NodeSelection.create(view.state.doc, draggedNodePos);
|
||||
|
||||
// if inline node is selected, e.g mention -> go to the parent node to select the whole node
|
||||
// if table row is selected, go to the parent node to select the whole node
|
||||
if (
|
||||
(selection as NodeSelection).node.type.isInline ||
|
||||
(selection as NodeSelection).node.type.name === 'tableRow'
|
||||
) {
|
||||
const $pos = view.state.doc.resolve(selection.from);
|
||||
selection = NodeSelection.create(view.state.doc, $pos.before());
|
||||
}
|
||||
}
|
||||
view.dispatch(view.state.tr.setSelection(selection));
|
||||
|
||||
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
|
||||
if (view.state.selection instanceof NodeSelection && view.state.selection.node.type.name === 'listItem') {
|
||||
listType = node.parentElement!.tagName;
|
||||
}
|
||||
|
||||
const slice = view.state.selection.content();
|
||||
const { dom, text } = serializeForClipboard(view, slice);
|
||||
|
||||
event.dataTransfer.clearData();
|
||||
event.dataTransfer.setData('text/html', dom.innerHTML);
|
||||
event.dataTransfer.setData('text/plain', text);
|
||||
event.dataTransfer.effectAllowed = 'copyMove';
|
||||
|
||||
event.dataTransfer.setDragImage(node, 0, 0);
|
||||
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
|
||||
let dragHandleElement: HTMLElement | null = null;
|
||||
|
||||
function hideDragHandle() {
|
||||
if (dragHandleElement) {
|
||||
dragHandleElement.classList.add('hide');
|
||||
}
|
||||
}
|
||||
|
||||
function showDragHandle() {
|
||||
if (dragHandleElement) {
|
||||
dragHandleElement.classList.remove('hide');
|
||||
}
|
||||
}
|
||||
|
||||
function hideHandleOnEditorOut(event: MouseEvent) {
|
||||
if (event.target instanceof Element) {
|
||||
// Check if the relatedTarget class is still inside the editor
|
||||
const relatedTarget = event.relatedTarget as HTMLElement;
|
||||
const isInsideEditor =
|
||||
relatedTarget?.classList.contains('tiptap') || relatedTarget?.classList.contains('drag-handle');
|
||||
|
||||
if (isInsideEditor) return;
|
||||
}
|
||||
hideDragHandle();
|
||||
}
|
||||
|
||||
return new Plugin({
|
||||
key: new PluginKey(options.pluginKey),
|
||||
view: (view) => {
|
||||
const handleBySelector = options.dragHandleSelector
|
||||
? document.querySelector<HTMLElement>(options.dragHandleSelector)
|
||||
: null;
|
||||
dragHandleElement = handleBySelector ?? document.createElement('div');
|
||||
dragHandleElement.draggable = true;
|
||||
dragHandleElement.dataset.dragHandle = '';
|
||||
dragHandleElement.classList.add('drag-handle');
|
||||
|
||||
function onDragHandleDragStart(e: DragEvent) {
|
||||
handleDragStart(e, view);
|
||||
}
|
||||
|
||||
dragHandleElement.addEventListener('dragstart', onDragHandleDragStart);
|
||||
|
||||
function onDragHandleDrag(e: DragEvent) {
|
||||
hideDragHandle();
|
||||
const scrollY = window.scrollY;
|
||||
if (e.clientY < options.scrollTreshold) {
|
||||
window.scrollTo({ top: scrollY - 30, behavior: 'smooth' });
|
||||
} else if (window.innerHeight - e.clientY < options.scrollTreshold) {
|
||||
window.scrollTo({ top: scrollY + 30, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
dragHandleElement.addEventListener('drag', onDragHandleDrag);
|
||||
|
||||
hideDragHandle();
|
||||
|
||||
if (!handleBySelector) {
|
||||
view?.dom?.parentElement?.appendChild(dragHandleElement);
|
||||
}
|
||||
view?.dom?.parentElement?.addEventListener('mouseout', hideHandleOnEditorOut);
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
if (!handleBySelector) {
|
||||
dragHandleElement?.remove?.();
|
||||
}
|
||||
dragHandleElement?.removeEventListener('drag', onDragHandleDrag);
|
||||
dragHandleElement?.removeEventListener('dragstart', onDragHandleDragStart);
|
||||
dragHandleElement = null;
|
||||
view?.dom?.parentElement?.removeEventListener('mouseout', hideHandleOnEditorOut);
|
||||
}
|
||||
};
|
||||
},
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
mousemove: (view, event) => {
|
||||
if (!view.editable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = nodeDOMAtCoords(
|
||||
{
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
const notDragging = node?.closest('.not-draggable');
|
||||
const excludedTagList = options.excludedTags.concat(['ol', 'ul']).join(', ');
|
||||
|
||||
if (!(node instanceof Element) || node.matches(excludedTagList) || notDragging) {
|
||||
hideDragHandle();
|
||||
return;
|
||||
}
|
||||
|
||||
const nodePos = nodePosAtDOM(node, view, options);
|
||||
if (nodePos !== undefined) {
|
||||
const currentNode = view.state.doc.nodeAt(nodePos);
|
||||
if (currentNode !== null) {
|
||||
options.onMouseMove?.({ node: currentNode, pos: nodePos });
|
||||
}
|
||||
}
|
||||
|
||||
const compStyle = window.getComputedStyle(node);
|
||||
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
|
||||
const lineHeight = isNaN(parsedLineHeight) ? parseInt(compStyle.fontSize) * 1.2 : parsedLineHeight;
|
||||
const paddingTop = parseInt(compStyle.paddingTop, 10);
|
||||
|
||||
const rect = absoluteRect(node);
|
||||
|
||||
rect.top += (lineHeight - 24) / 2;
|
||||
rect.top += paddingTop;
|
||||
// Li markers
|
||||
if (node.matches('ul:not([data-type=taskList]) li, ol li')) {
|
||||
rect.left -= options.dragHandleWidth;
|
||||
}
|
||||
rect.width = options.dragHandleWidth;
|
||||
|
||||
if (!dragHandleElement) return;
|
||||
|
||||
dragHandleElement.style.left = `${rect.left - rect.width}px`;
|
||||
dragHandleElement.style.top = `${rect.top}px`;
|
||||
showDragHandle();
|
||||
},
|
||||
keydown: () => {
|
||||
hideDragHandle();
|
||||
},
|
||||
mousewheel: () => {
|
||||
hideDragHandle();
|
||||
},
|
||||
// dragging class is used for CSS
|
||||
dragstart: (view) => {
|
||||
view.dom.classList.add('dragging');
|
||||
},
|
||||
drop: (view, event) => {
|
||||
view.dom.classList.remove('dragging');
|
||||
hideDragHandle();
|
||||
let droppedNode: Node | null = null;
|
||||
const dropPos = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY
|
||||
});
|
||||
|
||||
if (!dropPos) return;
|
||||
|
||||
if (view.state.selection instanceof NodeSelection) {
|
||||
droppedNode = view.state.selection.node;
|
||||
}
|
||||
if (!droppedNode) return;
|
||||
|
||||
const resolvedPos = view.state.doc.resolve(dropPos.pos);
|
||||
|
||||
const isDroppedInsideList = resolvedPos.parent.type.name === 'listItem';
|
||||
|
||||
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
|
||||
if (
|
||||
view.state.selection instanceof NodeSelection &&
|
||||
view.state.selection.node.type.name === 'listItem' &&
|
||||
!isDroppedInsideList &&
|
||||
listType == 'OL'
|
||||
) {
|
||||
const newList = view.state.schema.nodes.orderedList?.createAndFill(null, droppedNode);
|
||||
const slice = new Slice(Fragment.from(newList), 0, 0);
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
},
|
||||
dragend: (view) => {
|
||||
view.dom.classList.remove('dragging');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const GlobalDragHandle = Extension.create({
|
||||
name: 'globalDragHandle',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
dragHandleWidth: 20,
|
||||
scrollTreshold: 100,
|
||||
excludedTags: [],
|
||||
customNodes: []
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
DragHandlePlugin({
|
||||
pluginKey: 'globalDragHandle',
|
||||
dragHandleWidth: this.options.dragHandleWidth,
|
||||
scrollTreshold: this.options.scrollTreshold,
|
||||
dragHandleSelector: this.options.dragHandleSelector,
|
||||
excludedTags: this.options.excludedTags,
|
||||
customNodes: this.options.customNodes,
|
||||
onMouseMove: this.options.onMouseMove
|
||||
})
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
export default GlobalDragHandle;
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Node } from '@tiptap/core';
|
||||
|
||||
export interface IframeOptions {
|
||||
allowFullscreen: boolean;
|
||||
HTMLAttributes: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
iframe: {
|
||||
/**
|
||||
* Add an iframe with src
|
||||
*/
|
||||
setIframe: (options: { src: string }) => ReturnType;
|
||||
removeIframe: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default Node.create<IframeOptions>({
|
||||
name: 'iframe',
|
||||
|
||||
group: 'block',
|
||||
|
||||
atom: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
allowFullscreen: true,
|
||||
HTMLAttributes: {
|
||||
class: 'iframe-wrapper'
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null
|
||||
},
|
||||
frameborder: {
|
||||
default: 0
|
||||
},
|
||||
allowfullscreen: {
|
||||
default: this.options.allowFullscreen,
|
||||
parseHTML: () => this.options.allowFullscreen
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'iframe'
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', this.options.HTMLAttributes, ['iframe', HTMLAttributes]];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setIframe:
|
||||
(options: { src: string }) =>
|
||||
({ tr, dispatch }) => {
|
||||
const { selection } = tr;
|
||||
const node = this.type.create(options);
|
||||
|
||||
if (dispatch) {
|
||||
tr.replaceRangeWith(selection.from, selection.to, node);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
removeIframe:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.deleteNode(this.name)
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Component } from 'svelte';
|
||||
import IFrame from './IFrame';
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
|
||||
export const IFrameExtended = (content: Component<NodeViewProps>) =>
|
||||
IFrame.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null
|
||||
},
|
||||
alt: {
|
||||
default: null
|
||||
},
|
||||
title: {
|
||||
default: null
|
||||
},
|
||||
width: {
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
default: null
|
||||
},
|
||||
align: {
|
||||
default: 'left'
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView: () => {
|
||||
return SvelteNodeViewRenderer(content);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
|
||||
import type { Component } from 'svelte';
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
|
||||
export interface IFramePlaceholderOptions {
|
||||
HTMLAttributes: Record<string, object>;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
iframePlaceholder: {
|
||||
/**
|
||||
* Inserts a IFrame placeholder
|
||||
*/
|
||||
insertIFramePlaceholder: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const IFramePlaceholder = (content: Component<NodeViewProps>) =>
|
||||
Node.create<IFramePlaceholderOptions>({
|
||||
name: 'iframe-placeholder',
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
onDrop: () => {},
|
||||
onDropRejected: () => {},
|
||||
onEmbed: () => {}
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="${this.name}"]` }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
group: 'block',
|
||||
draggable: true,
|
||||
atom: true,
|
||||
content: 'inline*',
|
||||
isolating: true,
|
||||
|
||||
addNodeView() {
|
||||
return SvelteNodeViewRenderer(content);
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
insertIFramePlaceholder: () => (props: CommandProps) => {
|
||||
return props.commands.insertContent({
|
||||
type: 'iframe-placeholder'
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
import Image, { type ImageOptions } from '@tiptap/extension-image';
|
||||
import type { Component } from 'svelte';
|
||||
import type { NodeViewProps, Node } from '@tiptap/core';
|
||||
|
||||
export const ImageExtended = (component: Component<NodeViewProps>): Node<ImageOptions, unknown> => {
|
||||
return Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null
|
||||
},
|
||||
alt: {
|
||||
default: null
|
||||
},
|
||||
title: {
|
||||
default: null
|
||||
},
|
||||
width: {
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
default: null
|
||||
},
|
||||
align: {
|
||||
default: 'left'
|
||||
}
|
||||
};
|
||||
},
|
||||
addNodeView: () => {
|
||||
return SvelteNodeViewRenderer(component);
|
||||
}
|
||||
}).configure({
|
||||
allowBase64: true
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
|
||||
import type { Component } from 'svelte';
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
|
||||
export interface ImagePlaceholderOptions {
|
||||
HTMLAttributes: Record<string, object>;
|
||||
onDrop: (files: File[], editor: Editor) => void;
|
||||
onDropRejected?: (files: File[], editor: Editor) => void;
|
||||
onEmbed: (url: string, editor: Editor) => void;
|
||||
allowedMimeTypes?: Record<string, string[]>;
|
||||
maxFiles?: number;
|
||||
maxSize?: number;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
imagePlaceholder: {
|
||||
/**
|
||||
* Inserts an image placeholder
|
||||
*/
|
||||
insertImagePlaceholder: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const ImagePlaceholder = (
|
||||
component: Component<NodeViewProps>
|
||||
): Node<ImagePlaceholderOptions> =>
|
||||
Node.create<ImagePlaceholderOptions>({
|
||||
name: 'image-placeholder',
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
onDrop: () => {},
|
||||
onDropRejected: () => {},
|
||||
onEmbed: () => {}
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="${this.name}"]` }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
group: 'block',
|
||||
draggable: true,
|
||||
atom: true,
|
||||
content: 'inline*',
|
||||
isolating: true,
|
||||
|
||||
addNodeView() {
|
||||
return SvelteNodeViewRenderer(component);
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
insertImagePlaceholder: () => (props: CommandProps) => {
|
||||
return props.commands.insertContent({
|
||||
type: 'image-placeholder'
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type { EdraToolBarCommands } from '../../commands/types';
|
||||
import Minus from '@lucide/svelte/icons/minus';
|
||||
import Quote from '@lucide/svelte/icons/quote';
|
||||
import SquareCode from '@lucide/svelte/icons/square-code';
|
||||
import commands from '../../commands/toolbar-commands';
|
||||
|
||||
export interface Group {
|
||||
name: string;
|
||||
title: string;
|
||||
actions: EdraToolBarCommands[];
|
||||
}
|
||||
|
||||
export const GROUPS: Group[] = [
|
||||
{
|
||||
name: 'format',
|
||||
title: 'Format',
|
||||
actions: [
|
||||
...commands.headings,
|
||||
{
|
||||
icon: Quote,
|
||||
name: 'blockquote',
|
||||
tooltip: 'Blockquote',
|
||||
onClick: (editor: Editor) => {
|
||||
editor.chain().focus().setBlockquote().run();
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: SquareCode,
|
||||
name: 'codeBlock',
|
||||
tooltip: 'Code Block',
|
||||
onClick: (editor: Editor) => {
|
||||
editor.chain().focus().setCodeBlock().run();
|
||||
}
|
||||
},
|
||||
...commands.lists
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'insert',
|
||||
title: 'Insert',
|
||||
actions: [
|
||||
...commands.media,
|
||||
...commands.table,
|
||||
{
|
||||
icon: Minus,
|
||||
name: 'horizontalRule',
|
||||
tooltip: 'Horizontal Rule',
|
||||
onClick: (editor: Editor) => {
|
||||
editor.chain().focus().setHorizontalRule().run();
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export default GROUPS;
|
||||
@@ -0,0 +1,255 @@
|
||||
import { Editor, Extension } from '@tiptap/core';
|
||||
import Suggestion, { type SuggestionProps, type SuggestionKeyDownProps } from '@tiptap/suggestion';
|
||||
import { PluginKey } from '@tiptap/pm/state';
|
||||
import { computePosition, flip, offset, autoUpdate, type Placement } from '@floating-ui/dom';
|
||||
|
||||
import { GROUPS } from './groups';
|
||||
import SvelteRenderer from '../../svelte-renderer';
|
||||
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
const extensionName = 'slashCommand';
|
||||
|
||||
interface PopupState {
|
||||
element: HTMLElement | null;
|
||||
cleanup: (() => void) | null;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
const popup: PopupState = {
|
||||
element: null,
|
||||
cleanup: null,
|
||||
isVisible: false
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export default (menuList: Component<any, any, ''>): Extension =>
|
||||
Extension.create({
|
||||
name: extensionName,
|
||||
|
||||
priority: 200,
|
||||
|
||||
onCreate() {
|
||||
// Create popup container
|
||||
popup.element = document.createElement('div');
|
||||
popup.element.style.position = 'fixed';
|
||||
popup.element.style.zIndex = '9999';
|
||||
popup.element.style.maxWidth = '16rem';
|
||||
popup.element.style.visibility = 'hidden';
|
||||
popup.element.style.pointerEvents = 'none';
|
||||
popup.element.className = 'slash-command-popup';
|
||||
document.body.appendChild(popup.element);
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
char: '/',
|
||||
allowSpaces: true,
|
||||
pluginKey: new PluginKey(extensionName),
|
||||
allow: ({ state, range }) => {
|
||||
const $from = state.doc.resolve(range.from);
|
||||
const afterContent = $from.parent.textContent?.substring($from.parent.textContent?.indexOf('/'));
|
||||
const isValidAfterContent = !afterContent?.endsWith(' ');
|
||||
|
||||
return isValidAfterContent;
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
command: ({ editor, props }: { editor: Editor; props: any }) => {
|
||||
const { view, state } = editor;
|
||||
const { $head, $from } = view.state.selection;
|
||||
|
||||
try {
|
||||
const end = $from.pos;
|
||||
const from = $head?.nodeBefore
|
||||
? end - ($head.nodeBefore.text?.substring($head.nodeBefore.text?.indexOf('/')).length ?? 0)
|
||||
: $from.start();
|
||||
|
||||
const tr = state.tr.deleteRange(from, end);
|
||||
view.dispatch(tr);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
props.onClick(editor);
|
||||
view.focus();
|
||||
},
|
||||
items: ({ query }: { query: string }) => {
|
||||
const withFilteredCommands = GROUPS.map((group) => ({
|
||||
...group,
|
||||
commands: group.actions.filter((item) => {
|
||||
const labelNormalized = item.tooltip!.toLowerCase().trim();
|
||||
const queryNormalized = query.toLowerCase().trim();
|
||||
return labelNormalized.includes(queryNormalized);
|
||||
})
|
||||
}));
|
||||
|
||||
const withoutEmptyGroups = withFilteredCommands.filter((group) => {
|
||||
if (group.commands.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const withEnabledSettings = withoutEmptyGroups.map((group) => ({
|
||||
...group,
|
||||
commands: group.commands.map((command) => ({
|
||||
...command,
|
||||
isEnabled: true
|
||||
}))
|
||||
}));
|
||||
|
||||
return withEnabledSettings;
|
||||
},
|
||||
render: () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let component: any;
|
||||
|
||||
let scrollHandler: (() => void) | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: SuggestionProps) => {
|
||||
component = new SvelteRenderer(menuList, {
|
||||
props,
|
||||
editor: props.editor
|
||||
});
|
||||
|
||||
const { view } = props.editor;
|
||||
|
||||
if (popup.element) {
|
||||
popup.element.appendChild(component.element);
|
||||
popup.element.style.visibility = 'visible';
|
||||
popup.element.style.pointerEvents = 'auto';
|
||||
popup.isVisible = true;
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!popup.element || !props.clientRect) return;
|
||||
|
||||
const rect = props.clientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const referenceElement = {
|
||||
getBoundingClientRect: () => rect
|
||||
};
|
||||
|
||||
computePosition(referenceElement, popup.element, {
|
||||
placement: 'bottom-start' as Placement,
|
||||
middleware: [
|
||||
offset({ mainAxis: 8, crossAxis: 16 }),
|
||||
flip({ fallbackPlacements: ['top-start', 'bottom-start'] })
|
||||
]
|
||||
}).then(({ x, y }) => {
|
||||
if (popup.element) {
|
||||
popup.element.style.left = `${x}px`;
|
||||
popup.element.style.top = `${y}px`;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
|
||||
// Set up auto-update for scroll events
|
||||
if (props.clientRect) {
|
||||
const referenceElement = {
|
||||
getBoundingClientRect: () => props.clientRect?.() || new DOMRect()
|
||||
};
|
||||
popup.cleanup = autoUpdate(referenceElement, popup.element, updatePosition);
|
||||
}
|
||||
|
||||
scrollHandler = updatePosition;
|
||||
view.dom.parentElement?.addEventListener('scroll', scrollHandler);
|
||||
}
|
||||
},
|
||||
|
||||
onUpdate(props: SuggestionProps) {
|
||||
component.updateProps(props);
|
||||
|
||||
if (popup.element && popup.isVisible && props.clientRect) {
|
||||
const rect = props.clientRect();
|
||||
if (rect) {
|
||||
const referenceElement = {
|
||||
getBoundingClientRect: () => rect
|
||||
};
|
||||
|
||||
computePosition(referenceElement, popup.element, {
|
||||
placement: 'bottom-start' as Placement,
|
||||
middleware: [
|
||||
offset({ mainAxis: 8, crossAxis: 16 }),
|
||||
flip({ fallbackPlacements: ['top-start', 'bottom-start'] })
|
||||
]
|
||||
}).then(({ x, y }) => {
|
||||
if (popup.element) {
|
||||
popup.element.style.left = `${x}px`;
|
||||
popup.element.style.top = `${y}px`;
|
||||
}
|
||||
});
|
||||
|
||||
props.editor.storage[extensionName].rect = rect;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onKeyDown(props: SuggestionKeyDownProps) {
|
||||
if (props.event.key === 'Escape') {
|
||||
if (popup.element) {
|
||||
popup.element.style.visibility = 'hidden';
|
||||
popup.element.style.pointerEvents = 'none';
|
||||
popup.isVisible = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!popup.isVisible && popup.element) {
|
||||
popup.element.style.visibility = 'visible';
|
||||
popup.element.style.pointerEvents = 'auto';
|
||||
popup.isVisible = true;
|
||||
}
|
||||
|
||||
if (props.event.key === 'Enter') return true;
|
||||
|
||||
// return component.ref?.onKeyDown(props);
|
||||
return false;
|
||||
},
|
||||
|
||||
onExit(props) {
|
||||
if (popup.element) {
|
||||
popup.element.style.visibility = 'hidden';
|
||||
popup.element.style.pointerEvents = 'none';
|
||||
popup.element.innerHTML = '';
|
||||
popup.isVisible = false;
|
||||
}
|
||||
|
||||
if (popup.cleanup) {
|
||||
popup.cleanup();
|
||||
popup.cleanup = null;
|
||||
}
|
||||
|
||||
if (scrollHandler) {
|
||||
const { view } = props.editor;
|
||||
view.dom.parentElement?.removeEventListener('scroll', scrollHandler);
|
||||
scrollHandler = null;
|
||||
}
|
||||
|
||||
component.destroy();
|
||||
}
|
||||
};
|
||||
}
|
||||
})
|
||||
];
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
rect: {
|
||||
width: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export { Table } from './table';
|
||||
export { TableCell } from './table-cell';
|
||||
export { TableRow } from './table-row';
|
||||
export { TableHeader } from './table-header';
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import { getCellsInColumn, isRowSelected, selectRow } from './utils';
|
||||
|
||||
import { Plugin } from '@tiptap/pm/state';
|
||||
|
||||
export interface TableCellOptions {
|
||||
HTMLAttributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const TableCell = Node.create<TableCellOptions>({
|
||||
name: 'tableCell',
|
||||
|
||||
content: 'block+',
|
||||
tableRole: 'cell',
|
||||
|
||||
isolating: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {}
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'td' }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
colspan: {
|
||||
default: 1,
|
||||
parseHTML: (element) => {
|
||||
const colspan = element.getAttribute('colspan');
|
||||
const value = colspan ? parseInt(colspan, 10) : 1;
|
||||
|
||||
return value;
|
||||
}
|
||||
},
|
||||
rowspan: {
|
||||
default: 1,
|
||||
parseHTML: (element) => {
|
||||
const rowspan = element.getAttribute('rowspan');
|
||||
const value = rowspan ? parseInt(rowspan, 10) : 1;
|
||||
|
||||
return value;
|
||||
}
|
||||
},
|
||||
colwidth: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const colwidth = element.getAttribute('colwidth');
|
||||
const value = colwidth ? [parseInt(colwidth, 10)] : null;
|
||||
|
||||
return value;
|
||||
}
|
||||
},
|
||||
style: {
|
||||
default: null
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { isEditable } = this.editor;
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
if (!isEditable) {
|
||||
return DecorationSet.empty;
|
||||
}
|
||||
|
||||
const { doc, selection } = state;
|
||||
const decorations: Decoration[] = [];
|
||||
const cells = getCellsInColumn(0)(selection);
|
||||
|
||||
if (cells) {
|
||||
cells.forEach(({ pos }: { pos: number }, index: number) => {
|
||||
decorations.push(
|
||||
Decoration.widget(pos + 1, () => {
|
||||
const rowSelected = isRowSelected(index)(selection);
|
||||
let className = 'grip-row';
|
||||
|
||||
if (rowSelected) {
|
||||
className += ' selected';
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
className += ' first';
|
||||
}
|
||||
|
||||
if (index === cells.length - 1) {
|
||||
className += ' last';
|
||||
}
|
||||
|
||||
const grip = document.createElement('a');
|
||||
|
||||
grip.className = className;
|
||||
grip.addEventListener('mousedown', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
this.editor.view.dispatch(selectRow(index)(this.editor.state.tr));
|
||||
});
|
||||
|
||||
return grip;
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
}
|
||||
}
|
||||
})
|
||||
];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import { getCellsInRow, isColumnSelected, selectColumn } from './utils';
|
||||
|
||||
import { Plugin } from '@tiptap/pm/state';
|
||||
import { TableHeader as TiptapTableHeader } from '@tiptap/extension-table';
|
||||
|
||||
export const TableHeader = TiptapTableHeader.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
colspan: {
|
||||
default: 1
|
||||
},
|
||||
rowspan: {
|
||||
default: 1
|
||||
},
|
||||
colwidth: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const colwidth = element.getAttribute('colwidth');
|
||||
const value = colwidth ? colwidth.split(',').map((item) => parseInt(item, 10)) : null;
|
||||
|
||||
return value;
|
||||
}
|
||||
},
|
||||
style: {
|
||||
default: null
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { isEditable } = this.editor;
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
if (!isEditable) {
|
||||
return DecorationSet.empty;
|
||||
}
|
||||
|
||||
const { doc, selection } = state;
|
||||
const decorations: Decoration[] = [];
|
||||
const cells = getCellsInRow(0)(selection);
|
||||
|
||||
if (cells) {
|
||||
cells.forEach(({ pos }: { pos: number }, index: number) => {
|
||||
decorations.push(
|
||||
Decoration.widget(pos + 1, () => {
|
||||
const colSelected = isColumnSelected(index)(selection);
|
||||
let className = 'grip-column';
|
||||
|
||||
if (colSelected) {
|
||||
className += ' selected';
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
className += ' first';
|
||||
}
|
||||
|
||||
if (index === cells.length - 1) {
|
||||
className += ' last';
|
||||
}
|
||||
|
||||
const grip = document.createElement('a');
|
||||
|
||||
grip.className = className;
|
||||
grip.addEventListener('mousedown', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
this.editor.view.dispatch(selectColumn(index)(this.editor.state.tr));
|
||||
});
|
||||
|
||||
return grip;
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
}
|
||||
}
|
||||
})
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
export default TableHeader;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { TableRow as TiptapTableRow } from '@tiptap/extension-table';
|
||||
|
||||
export const TableRow = TiptapTableRow.extend({
|
||||
allowGapCursor: false,
|
||||
content: '(tableCell | tableHeader)*'
|
||||
});
|
||||
|
||||
export default TableRow;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Table as TiptapTable } from '@tiptap/extension-table';
|
||||
|
||||
export const Table = TiptapTable.configure({
|
||||
resizable: true,
|
||||
lastColumnResizable: true,
|
||||
allowTableNodeSelection: true
|
||||
});
|
||||
|
||||
export default Table;
|
||||
@@ -0,0 +1,313 @@
|
||||
import { Editor, findParentNode } from '@tiptap/core';
|
||||
import { EditorState, Selection, Transaction } from '@tiptap/pm/state';
|
||||
import { CellSelection, type Rect, TableMap } from '@tiptap/pm/tables';
|
||||
import { Node, ResolvedPos } from '@tiptap/pm/model';
|
||||
import type { EditorView } from '@tiptap/pm/view';
|
||||
import Table from './table';
|
||||
|
||||
export const isRectSelected = (rect: Rect) => (selection: CellSelection) => {
|
||||
const map = TableMap.get(selection.$anchorCell.node(-1));
|
||||
const start = selection.$anchorCell.start(-1);
|
||||
const cells = map.cellsInRect(rect);
|
||||
const selectedCells = map.cellsInRect(
|
||||
map.rectBetween(selection.$anchorCell.pos - start, selection.$headCell.pos - start)
|
||||
);
|
||||
|
||||
for (let i = 0, count = cells.length; i < count; i += 1) {
|
||||
if (selectedCells.indexOf(cells[i]) === -1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const findTable = (selection: Selection) =>
|
||||
findParentNode((node) => node.type.spec.tableRole && node.type.spec.tableRole === 'table')(selection);
|
||||
|
||||
export const isCellSelection = (selection: Selection): selection is CellSelection => selection instanceof CellSelection;
|
||||
|
||||
export const isColumnSelected = (columnIndex: number) => (selection: Selection) => {
|
||||
if (isCellSelection(selection)) {
|
||||
const map = TableMap.get(selection.$anchorCell.node(-1));
|
||||
|
||||
return isRectSelected({
|
||||
left: columnIndex,
|
||||
right: columnIndex + 1,
|
||||
top: 0,
|
||||
bottom: map.height
|
||||
})(selection);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isRowSelected = (rowIndex: number) => (selection: Selection) => {
|
||||
if (isCellSelection(selection)) {
|
||||
const map = TableMap.get(selection.$anchorCell.node(-1));
|
||||
|
||||
return isRectSelected({
|
||||
left: 0,
|
||||
right: map.width,
|
||||
top: rowIndex,
|
||||
bottom: rowIndex + 1
|
||||
})(selection);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isTableSelected = (selection: Selection) => {
|
||||
if (isCellSelection(selection)) {
|
||||
const map = TableMap.get(selection.$anchorCell.node(-1));
|
||||
|
||||
return isRectSelected({
|
||||
left: 0,
|
||||
right: map.width,
|
||||
top: 0,
|
||||
bottom: map.height
|
||||
})(selection);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getCellsInColumn = (columnIndex: number | number[]) => (selection: Selection) => {
|
||||
const table = findTable(selection);
|
||||
if (table) {
|
||||
const map = TableMap.get(table.node);
|
||||
const indexes = Array.isArray(columnIndex) ? columnIndex : Array.from([columnIndex]);
|
||||
|
||||
return indexes.reduce(
|
||||
(acc, index) => {
|
||||
if (index >= 0 && index <= map.width - 1) {
|
||||
const cells = map.cellsInRect({
|
||||
left: index,
|
||||
right: index + 1,
|
||||
top: 0,
|
||||
bottom: map.height
|
||||
});
|
||||
|
||||
return acc.concat(
|
||||
cells.map((nodePos) => {
|
||||
const node = table.node.nodeAt(nodePos);
|
||||
const pos = nodePos + table.start;
|
||||
|
||||
return { pos, start: pos + 1, node };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
[] as { pos: number; start: number; node: Node | null | undefined }[]
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getCellsInRow = (rowIndex: number | number[]) => (selection: Selection) => {
|
||||
const table = findTable(selection);
|
||||
|
||||
if (table) {
|
||||
const map = TableMap.get(table.node);
|
||||
const indexes = Array.isArray(rowIndex) ? rowIndex : Array.from([rowIndex]);
|
||||
|
||||
return indexes.reduce(
|
||||
(acc, index) => {
|
||||
if (index >= 0 && index <= map.height - 1) {
|
||||
const cells = map.cellsInRect({
|
||||
left: 0,
|
||||
right: map.width,
|
||||
top: index,
|
||||
bottom: index + 1
|
||||
});
|
||||
|
||||
return acc.concat(
|
||||
cells.map((nodePos) => {
|
||||
const node = table.node.nodeAt(nodePos);
|
||||
const pos = nodePos + table.start;
|
||||
return { pos, start: pos + 1, node };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
[] as { pos: number; start: number; node: Node | null | undefined }[]
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getCellsInTable = (selection: Selection) => {
|
||||
const table = findTable(selection);
|
||||
|
||||
if (table) {
|
||||
const map = TableMap.get(table.node);
|
||||
const cells = map.cellsInRect({
|
||||
left: 0,
|
||||
right: map.width,
|
||||
top: 0,
|
||||
bottom: map.height
|
||||
});
|
||||
|
||||
return cells.map((nodePos) => {
|
||||
const node = table.node.nodeAt(nodePos);
|
||||
const pos = nodePos + table.start;
|
||||
|
||||
return { pos, start: pos + 1, node };
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const findParentNodeClosestToPos = ($pos: ResolvedPos, predicate: (node: Node) => boolean) => {
|
||||
for (let i = $pos.depth; i > 0; i -= 1) {
|
||||
const node = $pos.node(i);
|
||||
|
||||
if (predicate(node)) {
|
||||
return {
|
||||
pos: i > 0 ? $pos.before(i) : 0,
|
||||
start: $pos.start(i),
|
||||
depth: i,
|
||||
node
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const findCellClosestToPos = ($pos: ResolvedPos) => {
|
||||
const predicate = (node: Node) => node.type.spec.tableRole && /cell/i.test(node.type.spec.tableRole);
|
||||
|
||||
return findParentNodeClosestToPos($pos, predicate);
|
||||
};
|
||||
|
||||
const select = (type: 'row' | 'column') => (index: number) => (tr: Transaction) => {
|
||||
const table = findTable(tr.selection);
|
||||
const isRowSelection = type === 'row';
|
||||
|
||||
if (table) {
|
||||
const map = TableMap.get(table.node);
|
||||
|
||||
// Check if the index is valid
|
||||
if (index >= 0 && index < (isRowSelection ? map.height : map.width)) {
|
||||
const left = isRowSelection ? 0 : index;
|
||||
const top = isRowSelection ? index : 0;
|
||||
const right = isRowSelection ? map.width : index + 1;
|
||||
const bottom = isRowSelection ? index + 1 : map.height;
|
||||
|
||||
const cellsInFirstRow = map.cellsInRect({
|
||||
left,
|
||||
top,
|
||||
right: isRowSelection ? right : left + 1,
|
||||
bottom: isRowSelection ? top + 1 : bottom
|
||||
});
|
||||
|
||||
const cellsInLastRow =
|
||||
bottom - top === 1
|
||||
? cellsInFirstRow
|
||||
: map.cellsInRect({
|
||||
left: isRowSelection ? left : right - 1,
|
||||
top: isRowSelection ? bottom - 1 : top,
|
||||
right,
|
||||
bottom
|
||||
});
|
||||
|
||||
const head = table.start + cellsInFirstRow[0];
|
||||
const anchor = table.start + cellsInLastRow[cellsInLastRow.length - 1];
|
||||
const $head = tr.doc.resolve(head);
|
||||
const $anchor = tr.doc.resolve(anchor);
|
||||
|
||||
return tr.setSelection(new CellSelection($anchor, $head));
|
||||
}
|
||||
}
|
||||
return tr;
|
||||
};
|
||||
|
||||
export const selectColumn = select('column');
|
||||
|
||||
export const selectRow = select('row');
|
||||
|
||||
export const selectTable = (tr: Transaction) => {
|
||||
const table = findTable(tr.selection);
|
||||
|
||||
if (table) {
|
||||
const { map } = TableMap.get(table.node);
|
||||
|
||||
if (map && map.length) {
|
||||
const head = table.start + map[0];
|
||||
const anchor = table.start + map[map.length - 1];
|
||||
const $head = tr.doc.resolve(head);
|
||||
const $anchor = tr.doc.resolve(anchor);
|
||||
|
||||
return tr.setSelection(new CellSelection($anchor, $head));
|
||||
}
|
||||
}
|
||||
|
||||
return tr;
|
||||
};
|
||||
|
||||
export const isColumnGripSelected = ({
|
||||
editor,
|
||||
view,
|
||||
state,
|
||||
from
|
||||
}: {
|
||||
editor: Editor;
|
||||
view: EditorView;
|
||||
state: EditorState;
|
||||
from: number;
|
||||
}) => {
|
||||
const domAtPos = view.domAtPos(from).node as HTMLElement;
|
||||
const nodeDOM = view.nodeDOM(from) as HTMLElement;
|
||||
const node = nodeDOM || domAtPos;
|
||||
|
||||
if (!editor.isActive(Table.name) || !node || isTableSelected(state.selection)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let container = node;
|
||||
|
||||
while (container && !['TD', 'TH'].includes(container.tagName)) {
|
||||
container = container.parentElement!;
|
||||
}
|
||||
|
||||
const gripColumn = container && container.querySelector && container.querySelector('a.grip-column.selected');
|
||||
|
||||
return !!gripColumn;
|
||||
};
|
||||
|
||||
export const isRowGripSelected = ({
|
||||
editor,
|
||||
view,
|
||||
state,
|
||||
from
|
||||
}: {
|
||||
editor: Editor;
|
||||
view: EditorView;
|
||||
state: EditorState;
|
||||
from: number;
|
||||
}) => {
|
||||
const domAtPos = view.domAtPos(from).node as HTMLElement;
|
||||
const nodeDOM = view.nodeDOM(from) as HTMLElement;
|
||||
const node = nodeDOM || domAtPos;
|
||||
|
||||
if (!editor.isActive(Table.name) || !node || isTableSelected(state.selection)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let container = node;
|
||||
|
||||
while (container && !['TD', 'TH'].includes(container.tagName)) {
|
||||
container = container.parentElement!;
|
||||
}
|
||||
|
||||
const gripRow = container && container.querySelector && container.querySelector('a.grip-row.selected');
|
||||
|
||||
return !!gripRow;
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Component } from 'svelte';
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
import { Video } from './VideoExtension';
|
||||
|
||||
export const VideoExtended = (content: Component<NodeViewProps>) =>
|
||||
Video.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null
|
||||
},
|
||||
alt: {
|
||||
default: null
|
||||
},
|
||||
title: {
|
||||
default: null
|
||||
},
|
||||
width: {
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
default: null
|
||||
},
|
||||
align: {
|
||||
default: 'left'
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView: () => {
|
||||
return SvelteNodeViewRenderer(content);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Node, nodeInputRule } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
|
||||
export interface VideoOptions {
|
||||
HTMLAttributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
video: {
|
||||
/**
|
||||
* Set a video node
|
||||
*/
|
||||
setVideo: (src: string) => ReturnType;
|
||||
/**
|
||||
* Toggle a video
|
||||
*/
|
||||
toggleVideo: (src: string) => ReturnType;
|
||||
/**
|
||||
* Remove a video
|
||||
*/
|
||||
removeVideo: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const VIDEO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/;
|
||||
|
||||
export const Video = Node.create<VideoOptions>({
|
||||
name: 'video',
|
||||
group: 'block',
|
||||
content: 'inline*',
|
||||
draggable: true,
|
||||
isolating: true,
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {}
|
||||
};
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null,
|
||||
parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'),
|
||||
renderHTML: (attrs) => ({ src: attrs.src })
|
||||
}
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'video',
|
||||
getAttrs: (el) => ({ src: (el as HTMLVideoElement).getAttribute('src') })
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'video',
|
||||
{ controls: 'true', style: 'width: fit-content;', ...HTMLAttributes },
|
||||
['source', HTMLAttributes]
|
||||
];
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
setVideo:
|
||||
(src: string) =>
|
||||
({ commands }) =>
|
||||
commands.insertContent(
|
||||
`<video controls="true" autoplay="false" style="width: fit-content" src="${src}" />`
|
||||
),
|
||||
|
||||
toggleVideo:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.toggleNode(this.name, 'paragraph'),
|
||||
removeVideo:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.deleteNode(this.name)
|
||||
};
|
||||
},
|
||||
addInputRules() {
|
||||
return [
|
||||
nodeInputRule({
|
||||
find: VIDEO_INPUT_REGEX,
|
||||
type: this.type,
|
||||
getAttributes: (match) => {
|
||||
const [, , src] = match;
|
||||
|
||||
return { src };
|
||||
}
|
||||
})
|
||||
];
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('videoDropPlugin'),
|
||||
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
drop(view, event) {
|
||||
const {
|
||||
state: { schema, tr },
|
||||
dispatch
|
||||
} = view;
|
||||
const hasFiles =
|
||||
event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length;
|
||||
|
||||
if (!hasFiles) return false;
|
||||
|
||||
const videos = Array.from(event.dataTransfer.files).filter((file) =>
|
||||
/video/i.test(file.type)
|
||||
);
|
||||
|
||||
if (videos.length === 0) return false;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
||||
|
||||
videos.forEach((video) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (readerEvent) => {
|
||||
const node = schema.nodes.video.create({ src: readerEvent.target?.result });
|
||||
|
||||
if (coordinates && typeof coordinates.pos === 'number') {
|
||||
const transaction = tr.insert(coordinates?.pos, node);
|
||||
|
||||
dispatch(transaction);
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsDataURL(video);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
|
||||
import type { Component } from 'svelte';
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
|
||||
export interface VideoPlaceholderOptions {
|
||||
HTMLAttributes: Record<string, object>;
|
||||
onDrop: (files: File[], editor: Editor) => void;
|
||||
onDropRejected?: (files: File[], editor: Editor) => void;
|
||||
onEmbed: (url: string, editor: Editor) => void;
|
||||
allowedMimeTypes?: Record<string, string[]>;
|
||||
maxFiles?: number;
|
||||
maxSize?: number;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
videoPlaceholder: {
|
||||
/**
|
||||
* Inserts a video placeholder
|
||||
*/
|
||||
insertVideoPlaceholder: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const VideoPlaceholder = (content: Component<NodeViewProps>) =>
|
||||
Node.create<VideoPlaceholderOptions>({
|
||||
name: 'video-placeholder',
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
onDrop: () => {},
|
||||
onDropRejected: () => {},
|
||||
onEmbed: () => {}
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="${this.name}"]` }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
group: 'block',
|
||||
draggable: true,
|
||||
atom: true,
|
||||
content: 'inline*',
|
||||
isolating: true,
|
||||
|
||||
addNodeView() {
|
||||
return SvelteNodeViewRenderer(content);
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
insertVideoPlaceholder: () => (props: CommandProps) => {
|
||||
return props.commands.insertContent({
|
||||
type: 'video-placeholder'
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user