diff --git a/packages/next.js/.env.example b/packages/next.js/.env.example new file mode 100644 index 000000000..e3754a217 --- /dev/null +++ b/packages/next.js/.env.example @@ -0,0 +1,2 @@ +NEXT_PUBLIC_PROVIDER_URL='ws://localhost:2300/collaboration' +NEXT_PUBLIC_RESTAPI_URL='http://localhost:2300/api' diff --git a/packages/next.js/.gitignore b/packages/next.js/.gitignore new file mode 100644 index 000000000..d435dcd9e --- /dev/null +++ b/packages/next.js/.gitignore @@ -0,0 +1,48 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local +.env*.production + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# next-pwa +/public/precache.*.*.js +/public/sw.js +/public/workbox-*.js +/public/worker-*.js +/public/fallback-*.js +/public/precache.*.*.js.map +/public/sw.js.map +/public/workbox-*.js.map +/public/worker-*.js.map +/public/fallback-*.js diff --git a/packages/next.js/README.md b/packages/next.js/README.md new file mode 100644 index 000000000..c2c18f499 --- /dev/null +++ b/packages/next.js/README.md @@ -0,0 +1,23 @@ +# Progressive Web App Example + +This example uses [`next-pwa`](https://github.com/shadowwalker/next-pwa) to create a progressive web app (PWA) powered by [Workbox](https://developers.google.com/web/tools/workbox/). + +## Deploy your own + +Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example): + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/vercel/next.js/tree/canary/examples/progressive-web-app&project-name=progressive-web-app&repository-name=progressive-web-app) + +## How to use + +Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init), [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/), or [pnpm](https://pnpm.io) to bootstrap the example: + +```bash +npx create-next-app --example progressive-web-app progressive-web-app +# or +yarn create next-app --example progressive-web-app progressive-web-app +# or +pnpm create next-app --example progressive-web-app progressive-web-app +``` + +Deploy it to the cloud with [Vercel](https://vercel.com/new?utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)). diff --git a/packages/next.js/components/Button.jsx b/packages/next.js/components/Button.jsx new file mode 100644 index 000000000..a5a5cd915 --- /dev/null +++ b/packages/next.js/components/Button.jsx @@ -0,0 +1,21 @@ +const Button = ({ children, style, onClick: click, loading, className }) => { + return ( + + ) +} + +export default Button diff --git a/packages/next.js/components/Counter.jsx b/packages/next.js/components/Counter.jsx new file mode 100644 index 000000000..8ae47ae5e --- /dev/null +++ b/packages/next.js/components/Counter.jsx @@ -0,0 +1,37 @@ + +import { useEffect, useRef, useState } from 'react' + +const Counter = ({ seconds, callback, stopTimer }) => { + const [timeLeft, setTimeLeft] = useState(+seconds) + const intervalRef = useRef() + + useEffect(() => { + intervalRef.current = setInterval(() => { + setTimeLeft((t) => t - 1) + }, 1000) + + return () => { + console.log('aklsdlaks;dlk close') + clearInterval(intervalRef.current) + } + }, []) + + // useEffect(() => { + // console.log("stopTimer changed", stopTimer) + // }, [stopTimer]) + + useEffect(() => { + if (timeLeft <= 0) { + clearInterval(intervalRef.current) + if (callback) callback() + } + }, [timeLeft]) + + return ( + <> +
{timeLeft}s
+ + ) +} + +export default Counter diff --git a/packages/next.js/components/ReloadPrompt.jsx b/packages/next.js/components/ReloadPrompt.jsx new file mode 100644 index 000000000..3bc7563c8 --- /dev/null +++ b/packages/next.js/components/ReloadPrompt.jsx @@ -0,0 +1,98 @@ +import React, { useState, useEffect } from 'react' +import Counter from './Counter' +const intervalMS = 1000 * 60 // 1min + +const PwaUpdater = () => { + const wb = window?.workbox + + const [isOpen, setIsOpen] = useState(false); + const onConfirmActivate = () => wb.messageSkipWaiting(); + + + if (typeof window !== 'undefined' && 'serviceWorker' in navigator && window.workbox !== undefined) { + + useEffect(() => { + + console.log("ReloadPrompt", wb) + // add event listeners to handle any of PWA lifecycle event + // https://developers.google.com/web/tools/workbox/reference-docs/latest/module-workbox-window.Workbox#events + wb.addEventListener('installed', event => { + console.log(`Event ${event.type} is triggered.`) + console.log(event) + }) + + wb.addEventListener('controlling', event => { + console.log(`Event ${event.type} is triggered.`) + console.log(event) + window.location.reload(); + }) + + wb.addEventListener('activated', event => { + console.log(`Event ${event.type} is triggered.`) + console.log(event) + }) + + // A common UX pattern for progressive web apps is to show a banner when a service worker has updated and waiting to install. + // NOTE: MUST set skipWaiting to false in next.config.js pwa object + // https://developers.google.com/web/tools/workbox/guides/advanced-recipes#offer_a_page_reload_for_users + const promptNewVersionAvailable = event => { + console.log("promptNewVersionAvailable", event) + // `event.wasWaitingBeforeRegister` will be false if this is the first time the updated service worker is waiting. + // When `event.wasWaitingBeforeRegister` is true, a previously updated service worker is still waiting. + // You may want to customize the UI prompt accordingly. + if (confirm('A newer version of this web app is available, reload to update?')) { + wb.addEventListener('controlling', event => { + // window.location.reload() + console.log("new version is available") + }) + + // Send a message to the waiting service worker, instructing it to activate. + // wb.messageSkipWaiting() + } else { + console.log( + 'User rejected to reload the web app, keep using old version. New version will be automatically load when user open the app next time.' + ) + } + } + + // wb.addEventListener('waiting', promptNewVersionAvailable) + wb.addEventListener('waiting', () => setIsOpen(true)); + wb.register(); + + // ISSUE - this is not working as expected, why? + // I could only make message event listenser work when I manually add this listenser into sw.js file + wb.addEventListener('message', event => { + console.log(`Event ${event.type} is triggered.`) + console.log(event) + }) + + }, []); + + } + + + return ( +
+

+ Hey, a new version is available! Please click below to update. +

+
+ + +
+ +
+ ); + } + + export default PwaUpdater; diff --git a/packages/next.js/components/TipTap/OnlineIndicator.jsx b/packages/next.js/components/TipTap/OnlineIndicator.jsx new file mode 100644 index 000000000..174fbb64e --- /dev/null +++ b/packages/next.js/components/TipTap/OnlineIndicator.jsx @@ -0,0 +1,56 @@ +import React, { useState, useEffect } from 'react' + +import { OfflineCloud, OnlineCloud } from '../icons/Icons' + +const OnlineIndicator = () => { + const [isOnline, setIsOnline] = useState(false) + const [showStatus, setShowStatus] = useState(true) + + + useEffect(() => { + const handleOnlineStatus = () => { + setIsOnline(true) + setShowStatus(true) + } + const handleOfflineStatus = () => { + setIsOnline(false) + setShowStatus(true) + } + + window.addEventListener('online', handleOnlineStatus) + window.addEventListener('offline', handleOfflineStatus) + + return () => { + window.removeEventListener('online', handleOnlineStatus) + window.removeEventListener('offline', handleOfflineStatus) + } + }, []) + + useEffect(() => { + if (showStatus) { + const timer = setTimeout(() => { + setShowStatus(false) + }, 5000) + + return () => clearTimeout(timer) + } + }, [showStatus]) + + return ( +
+ {showStatus && ( +
+ {isOnline + ? + Saved to docsplus + + : + Working offline + } +
+ )} +
+ ) +} + +export default OnlineIndicator diff --git a/packages/next.js/components/TipTap/PadTitle.jsx b/packages/next.js/components/TipTap/PadTitle.jsx new file mode 100644 index 000000000..2ff17377f --- /dev/null +++ b/packages/next.js/components/TipTap/PadTitle.jsx @@ -0,0 +1,72 @@ +import Link from 'next/link' + +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { + DocsPlus +} from '../icons/Icons' + +import OnlineIndicator from './OnlineIndicator' + +const PadTitle = ({ docTitle, docId,docSlug, provider }) => { + const queryClient = useQueryClient() + + const { isLoading, isSuccess, mutate } = useMutation({ + mutationKey: ['updateDocumentMetadata'], + mutationFn: ({ title, docId }) => { + // NOTE: This is a hack to get the correct URL in the build time + const url = `${process.env.NEXT_PUBLIC_RESTAPI_URL}/documents/${docId.split('.').at(-1)}` + + + return fetch(url, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ title }) + }) + .then(res => res.json()) + }, + onSuccess: (data) => { + queryClient.setQueryData(['getDocumentMetadataByDocName'], data) + } + }) + + const saveData = (e) => { + if (e.target.innerText === docTitle) return + mutate({ + title: e.target.innerText, + docId: docId.split('.').at(-1) + }) + } + + + return ( +
+
+ + + +
+
+ {isLoading + ? 'Loading...' + :
{ + if (event.key === 'Enter') { + e.preventDefault() + e.target.blur() + } + }} + > +
} +
+ +
+ ) +} + +export default PadTitle diff --git a/packages/next.js/components/TipTap/Placeholder.jsx b/packages/next.js/components/TipTap/Placeholder.jsx new file mode 100644 index 000000000..a451a8850 --- /dev/null +++ b/packages/next.js/components/TipTap/Placeholder.jsx @@ -0,0 +1,66 @@ +import { Extension } from '@tiptap/core' +import { Plugin } from 'prosemirror-state' +import { Decoration, DecorationSet } from 'prosemirror-view' + +const Placeholder = Extension.create({ + name: 'placeholder', + addOptions () { + return { + emptyEditorClass: 'is-editor-empty', + emptyNodeClass: 'is-empty', + placeholder: 'Write something …', + showOnlyWhenEditable: true, + showOnlyCurrent: true, + includeChildren: false + } + }, + addProseMirrorPlugins () { + return [ + new Plugin({ + props: { + decorations: ({ doc, selection }) => { + const active = this.editor.isEditable || !this.options.showOnlyWhenEditable + const { anchor } = selection + const decorations = [] + + if (!active) { + return null + } + doc.descendants((node, pos) => { + const hasAnchor = anchor >= pos && anchor <= (pos + node.nodeSize) + const isEmpty = !node.isLeaf && !node.childCount + + if ((hasAnchor || !this.options.showOnlyCurrent) && isEmpty) { + const classes = [this.options.emptyNodeClass] + + if (this.editor.isEmpty) { + classes.push(this.options.emptyEditorClass) + } + const decoration = Decoration.node(pos, pos + node.nodeSize, { + class: classes.join(' '), + 'data-placeholder': typeof this.options.placeholder === 'function' + ? this.options.placeholder({ + editor: this.editor, + node, + pos, + hasAnchor + }) + : this.options.placeholder + }) + + decorations.push(decoration) + } + + return this.options.includeChildren + }) + + return DecorationSet.create(doc, decorations) + } + } + }) + ] + } +}) + +export { Placeholder, Placeholder as default } +// # sourceMappingURL=tiptap-extension-placeholder.esm.js.map diff --git a/packages/next.js/components/TipTap/TableOfContents.jsx b/packages/next.js/components/TipTap/TableOfContents.jsx new file mode 100644 index 000000000..1677b3a28 --- /dev/null +++ b/packages/next.js/components/TipTap/TableOfContents.jsx @@ -0,0 +1,126 @@ +import React, { useCallback, useEffect, useState } from 'react' +import PubSub from 'pubsub-js' + +const TableOfcontent = ({ editor, className }) => { + const [items, setItems] = useState([]) + + const handleUpdate = useCallback((data) => { + const headings = [] + + // TODO: check the object id performance + // TODO: heading must be url frindly, so I have to map id with SLUGs + editor?.state?.doc?.descendants((node, _pos, _parent, _index) => { + if (node.type.name === 'contentHeading') { + // https://stackoverflow.com/questions/59829232/offsettop-return-0 + function getOffsetTop (element) { + return element ? (element.offsetTop + getOffsetTop(element.offsetParent)) : 0 + } + + const headingId = _parent.attrs?.id || node?.attrs.id || '1' + const headingSection = document.querySelector(`.ProseMirror .heading[data-id="${headingId}"]`) + + headings.push({ + level: node.attrs?.level, + text: node?.textContent, + id: headingId, + open: data && headingId === data.headingId ? data.crinkleOpen : headingSection?.classList.contains('opend'), + offsetTop: getOffsetTop(headingSection) + }) + } + }) + setItems(headings) + }, [editor]) + + useEffect(handleUpdate, []) + + useEffect(() => { + if (!editor) { + return null + } + editor?.on('update', handleUpdate) + + return () => { + editor?.off('update', handleUpdate) + } + }, [editor]) + + useEffect(() => { + PubSub.subscribe('toggleHeadingsContent', function (messag, data) { + handleUpdate(data) + }) + + return () => PubSub.unsubscribe('toggleHeadingsContent') + }, []) + + const scroll2Header = (e) => { + e.preventDefault() + let id = e.target.getAttribute('data-id') + const offsetParent = e.target.closest('.toc__item').getAttribute('data-offsettop') + + if (offsetParent === '0') id = '1' + + document.querySelector(`.heading[data-id="${id}"]`)?.scrollIntoView() + } + + // console.log(newItems) + + const toggleSection = (item) => { + document + .querySelector(`.ProseMirror .heading[data-id="${item.id}"] .buttonWrapper .btnFold`)?.click() + + setItems(x => items.map((i) => { + if (i.id === item.id) { + return { + ...i, + open: !i.open + } + } + + return i + })) + } + + function renderToc (items) { + const renderedItems = [] + let i = 0 + + while (i < items.length) { + const item = items[i] + const children = [] + let j = i + 1 + + while (j < items.length && items[j].level > item.level) { + children.push(items[j]) + j++ + } + renderedItems.push( +
+ + toggleSection(item)}> + + {item.text} + + + + {children.length > 0 &&
{renderToc(children)}
} +
+ ) + i = j + } + + return renderedItems + } + + return (
+
+ {renderToc(items)} +
+
) +} + +export default TableOfcontent diff --git a/packages/next.js/components/TipTap/TableOfContentss.js b/packages/next.js/components/TipTap/TableOfContentss.js new file mode 100644 index 000000000..7a1cb7fc2 --- /dev/null +++ b/packages/next.js/components/TipTap/TableOfContentss.js @@ -0,0 +1,90 @@ +import { mergeAttributes, Node } from '@tiptap/core' + +// import Component from './TableOfContents' + +export default Node.create({ + name: 'tableOfContents', + + group: 'block', + + atom: true, + + parseHTML() { + return [ + { + tag: 'toc' + } + ] + }, + + renderHTML({ HTMLAttributes }) { + return ['toc', mergeAttributes(HTMLAttributes)] + }, + + onCreate() { + onBeforeCreate + const { view, state } = this.editor + const { tr, doc } = state + const { types, attributeName, generateID } = this.options + + if (this.editor.extensionManager.extensions.find(extension => extension.name === 'collaboration')) { + return + } + console.log({ view, state, tr, doc, types, attributeName, generateID }) + }, + + addNodeView() { + return '' + } + // addGlobalAttributes() { + // return [ + // { + // types: this.options.types, + // attributes: { + // [this.options.attributeName]: { + // default: null, + // parseHTML: element => element.getAttribute(`data-${ this.options.attributeName }`), + // renderHTML: attributes => { + // if (!attributes[this.options.attributeName]) { + // return {}; + // } + // return { + // [`data-${ this.options.attributeName }`]: attributes[this.options.attributeName], + // }; + // }, + // }, + // }, + // }, + // ]; + // }, + + // addGlobalAttributes() { + // return [ + // { + // types: ['heading', 'paragraph'], + // parseHTML: element => element.getAttribute(`parent`), + // renderHTML: attributes => { + // // if (!attributes.level) { + // // return {}; + // // } + // return { + // parent: attributes.parent, + // style: `color: red`, + // }; + // }, + // attributes: { + // // id: { + // // default: "id", + // // }, + // style: { default: "" }, + // parent: { + // default: "default" + // }, + // level: { + // default: "default" + // } + // }, + // }, + // ] + // }, +}) diff --git a/packages/next.js/components/TipTap/TipTap.jsx b/packages/next.js/components/TipTap/TipTap.jsx new file mode 100644 index 000000000..5d5812438 --- /dev/null +++ b/packages/next.js/components/TipTap/TipTap.jsx @@ -0,0 +1,283 @@ +import React, { useEffect, useState } from 'react' +import { useEditor, EditorContent } from '@tiptap/react' +import Collaboration, { isChangeOrigin } from '@tiptap/extension-collaboration' +import CollaborationCursor from '@tiptap/extension-collaboration-cursor' +import Link from '@tiptap/extension-link' +import Image from '@tiptap/extension-image' +import TaskItem from '@tiptap/extension-task-item' +import TaskList from '@tiptap/extension-task-list' +import Highlight from '@tiptap/extension-highlight' +import Typography from '@tiptap/extension-typography' +import randomColor from 'randomcolor' +import Underline from '@tiptap/extension-underline' + +import Placeholder from '@tiptap/extension-placeholder' +import Gapcursor from '@tiptap/extension-gapcursor' +import { Node } from '@tiptap/core' + +import ListItem from '@tiptap/extension-list-item' +import OrderedList from '@tiptap/extension-ordered-list' +import HardBreak from '@tiptap/extension-hard-break' + +import Bold from '@tiptap/extension-bold' +import Italic from '@tiptap/extension-italic' +import BulletList from '@tiptap/extension-bullet-list' +import Strike from '@tiptap/extension-strike' +import Superscript from '@tiptap/extension-superscript' +import Subscript from '@tiptap/extension-subscript' +import Blockquote from '@tiptap/extension-blockquote' +import TextAlign from '@tiptap/extension-text-align' +import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight' +import css from 'highlight.js/lib/languages/css' +import js from 'highlight.js/lib/languages/javascript' +import ts from 'highlight.js/lib/languages/typescript' +import html from 'highlight.js/lib/languages/xml' +import md from 'highlight.js/lib/languages/markdown' +import yaml from 'highlight.js/lib/languages/yaml' +import python from 'highlight.js/lib/languages/python' +import json from 'highlight.js/lib/languages/json' +import bash from 'highlight.js/lib/languages/bash' +// load all highlight.js languages +import { lowlight } from 'lowlight' +import ShortUniqueId from 'short-unique-id' +// Table Node Block +import Table from '@tiptap/extension-table' +import TableCell from '@tiptap/extension-table-cell' +import TableHeader from '@tiptap/extension-table-header' +import TableRow from '@tiptap/extension-table-row' + +import Heading from './extentions/Heading' +import ContentHeading from './extentions/ContentHeading' +import UniqueID from './extentions/UniqueId' +import ContentWrapper from './extentions/ContentWrapper' + +lowlight.registerLanguage('html', html) +lowlight.registerLanguage('css', css) +lowlight.registerLanguage('js', js) +lowlight.registerLanguage('ts', ts) +lowlight.registerLanguage('markdown', md) +lowlight.registerLanguage('python', python) +lowlight.registerLanguage('yaml', yaml) +lowlight.registerLanguage('json', json) +lowlight.registerLanguage('bash', bash) + +const Document = Node.create({ + name: 'doc', + topNode: true, + content: 'heading+' +}) + +const Paragraph = Node.create({ + name: 'paragraph', + group: 'block', + content: 'inline*', + parseHTML () { + return [ + { tag: 'p' } + ] + }, + renderHTML ({ HTMLAttributes }) { + return ['p', HTMLAttributes, 0] + } +}) + +const Text = Node.create({ + name: 'text', + group: 'inline' +}) + +const scrollDown = () => { + const url = new URL(window.location) + const id = url.searchParams.get('id') + + if (!id) return + setTimeout(() => { + console.log({ + do: document.querySelector('.tipta__editor'), + param: url, + id: url.searchParams.get('id'), + nodeTarget: document.querySelector(`[data-id="${url.searchParams.get('id')}"]`) + }) + document.querySelector(`[data-id="${url.searchParams.get('id')}"]`)?.scrollIntoView() + }, 200) +} + +const Editor = ({ padName, provider, ydoc, defualtContent = '', spellcheck = false, children }) => { + if (!provider) { + return { + extensions: [ + Document, + Bold, + Italic, + BulletList, + Strike, + HardBreak, + Gapcursor, + Paragraph, + Text, + ListItem, + OrderedList, + Heading.configure(), + ContentHeading, + ContentWrapper + ] + } + } + + return { + onCreate: (editor) => { + // console.log("onCreate", editor) + scrollDown() + }, + onUpdate: (editor) => { + // console.log("onUpdate", editor) + }, + editorProps: { + attributes: { + spellcheck + } + }, + extensions: [ + UniqueID.configure({ + types: ['heading', 'link'], + filterTransaction: transaction => !isChangeOrigin(transaction), + generateID: () => { + const uid = new ShortUniqueId() + + return uid.stamp(16) + } + }), + Document, + Bold, + Italic, + BulletList, + Strike, + HardBreak, + Gapcursor, + Paragraph, + Text, + ListItem, + OrderedList, + Heading.configure(), + CodeBlockLowlight.configure({ + lowlight + }), + ContentHeading, + ContentWrapper, + Superscript, + Subscript, + Blockquote, + TextAlign, + Underline, + Link.configure({ + protocols: ['ftp', 'mailto'] + }), + Image.configure({ + inline: true, + allowBase64: true, + HTMLAttributes: { + class: 'image-class' + } + }), + TaskList, + TaskItem.configure({ + nested: true, + HTMLAttributes: { + class: 'tasks-class' + } + }), + Highlight, + Typography, + Table.configure({ + resizable: true + }), + TableRow, + TableHeader, + TableCell, + Collaboration.configure({ + document: provider.document + }), + CollaborationCursor.configure({ + provider, + user: { name: 'Adam Doe', color: randomColor() } + }), + Placeholder.configure({ + includeChildren: true, + placeholder: ({ node }) => { + const nodeType = node.type.name + + if (nodeType === 'contentHeading') { + const level = node.attrs.level + + return level - 1 === 0 ? 'Title' : `Heading ${level - 1}` + } else if (nodeType === 'heading') { + const level = node.attrs.level + + return level - 1 === 0 ? 'Title' : `Heading ${level - 1}` + } else if (nodeType === 'paragraph') { + const msg = [ + 'Type your thoughts here ...', + 'Start your writing here ...', + "What's on your mind? ...", + 'Let your words flow ...', + 'Unleash your creativity ...', + 'Your text here ...', + 'Express yourself ...', + 'Write your ideas down ...', + 'Share your thoughts ...', + 'Start typing ...', + 'Begin typing here ...', + 'Jot down your ideas here ...', + 'Let your imagination run wild ...', + 'Capture your thoughts ...', + 'Brainstorm your next masterpiece ...', + 'Put your ideas into words ...', + 'Express your creativity ...', + 'Create something amazing ...', + 'Write down your musings ...', + 'Craft your unique story ...', + 'Unleash your inner writer ...', + 'Share your innovative ideas ...', + 'Pen your next big idea ...', + 'Write from the heart ...', + 'Manifest your creativity here ...', + 'Share your brilliant insights ...', + 'Craft your vision ...', + 'Distill your thoughts into words ...', + 'Let your inspiration flow ...', + 'Pen your creative masterpiece ...', + 'Draft your imaginative ideas here ...', + 'Bring your imagination to life ...', + 'Write your way to clarity ...', + 'Craft your next big breakthrough ...', + 'Let your ideas take shape ...', + 'Create your own narrative ...', + 'Put your originality on paper ...', + 'Express your unique perspective ...', + 'Inscribe your original thoughts ...', + 'Unfold your creative potential ...', + 'Give voice to your ideas ...', + 'Paint a picture with your words ...', + 'Sculpt your ideas into sentences ...', + 'Design your masterpiece with words ...', + 'Shape your vision with language ...', + 'Craft your story with care ...', + 'Compose your narrative with passion ...', + 'Build your message from scratch ...', + 'Develop your concepts with clarity ...', + 'Forge your ideas into a cohesive whole ...', + 'Carve out your ideas with precision ...' + ] + + return msg[Math.floor(Math.random() * msg.length + 1)] + } + + return null + } + }) + ], + defualtContent: '' + } +} + +export default Editor diff --git a/packages/next.js/components/TipTap/Toolbar.jsx b/packages/next.js/components/TipTap/Toolbar.jsx new file mode 100644 index 000000000..799e1f320 --- /dev/null +++ b/packages/next.js/components/TipTap/Toolbar.jsx @@ -0,0 +1,345 @@ +import React, { useEffect, useState, useCallback } from 'react' +import Select from 'react-select' + +import { + Bold, + Italic, + Underline, + OrderList, + BulletList, + Link, + CheckList, + Image, + Gear, + ClearMark, + Stric, + HighlightMarker, + Undo, + Redo, + Printer +} from '../../components/icons/Icons' + +const GearModal = (props) => { + return ( +
+ {props.children} +
+ ) +} + +const Toolbar = ({ editor }) => { + if (!editor) { + return null + } + + const setLink = useCallback(() => { + const previousUrl = editor.getAttributes('link').href + const url = window.prompt('URL', previousUrl) + + if (editor.isActive('link')) { + return editor.chain().focus().unsetLink().run() + } + + // cancelled + if (url === null) { + return + } + + // empty + if (url === '') { + editor.chain().focus().extendMarkRange('link').unsetLink().run() + + return + } + + // update link + editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run() + }, [editor]) + + const addImage = useCallback(() => { + const url = window.prompt('URL') + + if (url) { + editor.chain().focus().setImage({ src: url }).run() + } + }, [editor]) + + const options = [ + { value: 0, label: 'Normal Text' }, + { value: 1, label: 'Heading 1' }, + { value: 2, label: 'Heading 2' }, + { value: 3, label: 'Heading 3' }, + { value: 4, label: 'Heading 4' }, + { value: 5, label: 'Heading 5' }, + { value: 6, label: 'Heading 6' }, + { value: 7, label: 'Heading 7' }, + { value: 8, label: 'Heading 8' }, + { value: 9, label: 'Heading 9' }, + { value: 10, label: 'Heading 10' } + ] + + const [selectedOption, setSelectedOption] = useState(options[0]) + const [selectValue, setSelectValue] = useState(options[0]) + + useEffect(() => { + if (editor.isActive('contentHeading', { level: 1 })) setSelectValue(options[1]) + else if (editor.isActive('contentHeading', { level: 2 })) setSelectValue(options[2]) + else if (editor.isActive('contentHeading', { level: 3 })) setSelectValue(options[3]) + else if (editor.isActive('contentHeading', { level: 4 })) setSelectValue(options[4]) + else if (editor.isActive('contentHeading', { level: 5 })) setSelectValue(options[5]) + else if (editor.isActive('contentHeading', { level: 6 })) setSelectValue(options[6]) + else if (editor.isActive('contentHeading', { level: 7 })) setSelectValue(options[7]) + else if (editor.isActive('contentHeading', { level: 8 })) setSelectValue(options[8]) + else if (editor.isActive('contentHeading', { level: 9 })) setSelectValue(options[9]) + else if (editor.isActive('contentHeading', { level: 10 })) setSelectValue(options[10]) + else setSelectValue(options[0]) + }, [ + editor.isActive('contentHeading', { level: 1 }), + editor.isActive('contentHeading', { level: 2 }), + editor.isActive('contentHeading', { level: 3 }), + editor.isActive('contentHeading', { level: 4 }), + editor.isActive('contentHeading', { level: 5 }), + editor.isActive('contentHeading', { level: 6 }), + editor.isActive('contentHeading', { level: 7 }), + editor.isActive('contentHeading', { level: 8 }), + editor.isActive('contentHeading', { level: 9 }), + editor.isActive('contentHeading', { level: 10 }), + editor.isActive('paragraph') + ]) + + const onchangeValue = (e) => { + setSelectedOption(e) + const value = e.value + + if (value === 0) editor.chain().focus().normalText().run() + else editor.chain().focus().wrapBlock({ level: +value }).run() + } + + let indentSetting = Boolean(localStorage.getItem('setting.indentHeading') || false) + let h1SectionBreakSetting = Boolean(localStorage.getItem('setting.h1SectionBreakSetting') || false) + + if (!indentSetting) { + localStorage.setItem('setting.indentHeading', '') + indentSetting = false + } + + if (!h1SectionBreakSetting) { + console.log('indentSetting', indentSetting) + localStorage.setItem('setting.h1SectionBreakSetting', 'true') + h1SectionBreakSetting = true + document.body.classList.add('h1SectionBreak') + } + + const [indented, setIndented] = React.useState(indentSetting) + const [h1SectionBreak, setH1SectionBreak] = React.useState(h1SectionBreakSetting) + + const toggleHeadingIndent = (e) => { + setIndented(preState => { + const newState = !preState + + if (newState) { document.body.classList.add('indentHeading') } else { document.body.classList.remove('indentHeading') } + + localStorage.setItem('setting.indentHeading', newState ? 'yes' : '') + + return newState + }) + } + + const toggleH1SectionBreak = (e) => { + setH1SectionBreak(preState => { + const newState = !preState + + if (newState) { document.body.classList.add('h1SectionBreak') } else { document.body.classList.remove('h1SectionBreak') } + + localStorage.setItem('setting.h1SectionBreakSetting', newState ? 'yes' : '') + + return newState + }) + } + + const toggleSettingModal = () => { + console.log('toggleSettingModal') + document.querySelector('.gearModal').classList.toggle('active') + } + + const hideModals = (e) => { + console.log() + if (e.target.closest('.btn_modal') || e.target.closest('.nd_modal')) return + document.querySelector('.gearModal').classList.remove('active') + } + + useEffect(() => { + const newIndent = Boolean(localStorage.getItem('setting.indentHeading')) + const newHsectionBreak = Boolean(localStorage.getItem('setting.indentHeading')) + + if (newIndent) document.body.classList.add('indentHeading') + else document.body.classList.remove('indentHeading') + + if (newHsectionBreak) document.body.classList.add('h1SectionBreak') + else document.body.classList.remove('h1SectionBreak') + }, []) + + return ( +
+ +
+ + + +
+
+ + toggleHeadingIndent(e.target)} /> +
+ Toggle heading indent + +
+
+ +
+ + + + ) +} + +export default Toolbar diff --git a/packages/next.js/components/TipTap/extentions/ContentHeading.jsx b/packages/next.js/components/TipTap/extentions/ContentHeading.jsx new file mode 100644 index 000000000..7a4505c56 --- /dev/null +++ b/packages/next.js/components/TipTap/extentions/ContentHeading.jsx @@ -0,0 +1,241 @@ +import { Selection, Plugin, TextSelection, PluginKey } from 'prosemirror-state' +import { Decoration, DecorationSet } from 'prosemirror-view' +import { Node, mergeAttributes } from '@tiptap/core' + +import { db } from '../../../db' + +import { copyToClipboard } from './helper' +import onHeading from './normalText/onHeading' + +function extractContentHeadingBlocks (doc) { + const result = [] + const record = (from, to, headingId, node) => { + result.push({ from, to, headingId, node }) + } + let lastHeadingId + + // For each node in the document + doc.descendants((node, pos) => { + if (node.type.name === 'heading') { + lastHeadingId = node.attrs.id + } + if (node.type.name === 'contentHeading') { + const nodeSize = node.nodeSize + + record(pos, pos + nodeSize, lastHeadingId, node) + } + }) + + return result +} + +const buttonWrapper = (editor, { headingId, from, node }) => { + const buttonWrapper = document.createElement('div') + const btnToggleHeading = document.createElement('button') + const btnChatBox = document.createElement('button') + + buttonWrapper.classList.add('buttonWrapper') + + btnChatBox.setAttribute('class', 'btn_openChatBox') + btnChatBox.setAttribute('type', 'button') + btnToggleHeading.classList.add('btnFold') + btnToggleHeading.setAttribute('type', 'button') + + buttonWrapper.append(btnToggleHeading) + buttonWrapper.append(btnChatBox) + + const toggleHeadingContent = (el) => { + console.log('coming heaidng', { + el + }) + const headingId = el.getAttribute('data-id') + const detailsContent = el.querySelector('div.contentWrapper') + const event = new CustomEvent('toggleHeadingsContent', { detail: { headingId, el: detailsContent } }) + + detailsContent === null || detailsContent === void 0 ? void 0 : detailsContent.dispatchEvent(event) + } + + const foldAndUnfold = (e) => { + const el = e.target + const headingNodeEl = el.closest('.heading') + const headingId = headingNodeEl.getAttribute('data-id') + + editor.commands.focus(from + node.nodeSize - 1) + if (editor.isEditable) { + const { tr } = editor.state + const pos = from + const currentNode = tr.doc.nodeAt(pos) + const headingNode = tr.doc.nodeAt(pos - 1) + + if (currentNode && currentNode.type.name === 'contentHeading') { + tr.setNodeMarkup(pos, undefined, { + ...currentNode.attrs, + level: currentNode.attrs.level, + id: headingNode.attrs.id + }) + } + + if (headingNode && headingNode.type.name === 'heading') { + tr.setNodeMarkup(pos - 1, undefined, { + ...headingNode.attrs, + level: currentNode.attrs.level, + id: headingNode.attrs.id + }) + } + + tr.setMeta('addToHistory', false) + + const documentId = localStorage.getItem('docId') + const headingMap = JSON.parse(localStorage.getItem('headingMap')) || [] + const nodeState = headingMap.find(h => h.headingId === headingId) || { crinkleOpen: true } + + db.meta + .put({ docId: documentId, headingId, crinkleOpen: !nodeState.crinkleOpen, level: currentNode.attrs.level }) + .then((data, ddd) => { + db.meta.where({ docId: documentId }).toArray().then((data) => { + localStorage.setItem('headingMap', JSON.stringify(data)) + }) + }) + + editor.view.dispatch(tr) + toggleHeadingContent(headingNodeEl) + } + } + + btnToggleHeading.addEventListener('click', foldAndUnfold) + + // copy the link to clipboard + const href = document.createElement('a') + + href.innerHTML = '#' + href.setAttribute('href', `#${headingId}`) + href.addEventListener('click', (e) => { + e.preventDefault() + const url = new URL(window.location) + + url.searchParams.set('id', headingId) + window.history.pushState({}, '', url) + copyToClipboard(url) + editor + .chain() + .focus(from + node.nodeSize - 1) + .run() + }) + href.contenteditable = false + buttonWrapper.append(href) + + return buttonWrapper +} + +const appendButtonsDec = (doc, editor) => { + const decos = [] + const contentWrappers = extractContentHeadingBlocks(doc) + + contentWrappers.forEach(prob => { + const decorationWidget = Decoration.widget(prob.to, buttonWrapper(editor, prob), { + side: -1, + key: prob.headingId + }) + + decos.push(decorationWidget) + }) + + return DecorationSet.create(doc, decos) +} + +const HeadingsTitle = Node.create({ + name: 'contentHeading', + content: 'inline*', + group: 'block', + defining: true, + // draggable: false, + // selectable: false, + // isolating: true, + allowGapCursor: false, + addOptions () { + return { + HTMLAttributes: { + class: 'title' + }, + levels: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + id: null + } + }, + addAttributes () { + return { + level: { + default: 1, + rendered: false + }, + id: { + default: null, + rendered: false + } + } + }, + parseHTML () { + return this.options.levels + .map((level) => ({ + tag: `h${level}`, + attrs: { level } + })) + }, + renderHTML (state) { + const { node, HTMLAttributes } = state + const hasLevel = this.options.levels.includes(node.attrs.level) + const level = hasLevel + ? node.attrs.level + : this.options.levels[0] + + return [`h${level}`, mergeAttributes(this.options.HTMLAttributes, { ...HTMLAttributes, level }), 0] + }, + addKeyboardShortcuts () { + return { + Backspace: ({ editor }) => { + const { schema, selection, doc } = this.editor.state + const { empty, $anchor, $head, $from, $to, from, to } = selection + const { start, end, depth } = $from.blockRange($to) + + // current node + const node = doc.nodeAt(from) + // parent node + const parent = $anchor.parent + + // if the current node is empty(means there is not content) and the parent is contentHeading + if ( + (node !== null || $anchor.parentOffset !== 0) || // this node block contains content + parent.type.name !== schema.nodes.contentHeading.name || + $anchor.pos === 2 // || // if the caret is in the first heading + // parent.attrs.open === false // if the heading is closed + ) return false + + console.info('[Heading]: remove the heading node') + + return onHeading({ + editor, + state: editor.state, + tr: editor.state.tr, + view: editor.view + }) + } + } + }, + addProseMirrorPlugins () { + return [ + new Plugin({ + key: new PluginKey('HeadingButtons'), + state: { + init: (_, { doc }) => appendButtonsDec(doc, this.editor), + apply: (tr, old) => tr.docChanged ? appendButtonsDec(tr.doc, this.editor) : old + }, + props: { + decorations (state) { + return this.getState(state) + } + } + }) + ] + } +}) + +export { HeadingsTitle, HeadingsTitle as default } diff --git a/packages/next.js/components/TipTap/extentions/ContentWrapper.jsx b/packages/next.js/components/TipTap/extentions/ContentWrapper.jsx new file mode 100644 index 000000000..0bbea8ff3 --- /dev/null +++ b/packages/next.js/components/TipTap/extentions/ContentWrapper.jsx @@ -0,0 +1,304 @@ +import { Node, mergeAttributes, findParentNode, defaultBlockAt } from '@tiptap/core' +import { Selection, Plugin, TextSelection, PluginKey } from 'prosemirror-state' +import { Decoration, DecorationSet } from 'prosemirror-view' +import PubSub from 'pubsub-js' + +import { getNodeState } from './helper' + +function extractContentWrapperBlocks (doc) { + const result = [] + const record = (from, to, nodeSize, childCount, headingId) => { + result.push({ from, to, nodeSize, childCount, headingId }) + } + let lastHeadingId + + // For each node in the document + doc.descendants((node, pos) => { + if (node.type.name === 'heading') { + lastHeadingId = node.attrs.id + } + if (node.type.name === 'contentWrapper') { + const nodeSize = node.content.size + const childCount = node.childCount + + record(pos, pos + nodeSize, nodeSize, childCount, lastHeadingId) + } + }) + + return result +} + +function crinkleNode (prob) { + const foldEl = document.createElement('div') + + foldEl.classList.add('foldWrapper') + const step = 1000 + const lines = 3 + Math.floor(prob.nodeSize / step) + const clampedLines = Math.min(Math.max(lines, 3), 10) + + for (let i = 0; i <= clampedLines; i++) { + const line = document.createElement('div') + + line.classList.add('fold') + line.classList.add(`l${i}`) + foldEl.append(line) + } + foldEl.setAttribute('data-clampedLines', clampedLines + 1) + foldEl.addEventListener('click', (e) => { + if (!e.target.closest('.heading').classList.contains('closed')) return + e.target.parentElement.parentElement.querySelector('.btnFold')?.click() + }) + + return foldEl +} + +function lintDeco (doc) { + const decos = [] + const contentWrappers = extractContentWrapperBlocks(doc) + + contentWrappers.forEach(prob => { + const decorationWidget = Decoration.widget(prob.from, crinkleNode(prob), { + side: -1, + key: prob.headingId + }) + + decos.push(decorationWidget) + }) + + return DecorationSet.create(doc, decos) +} + +function expandElement (elem, collapseClass, headingId, open) { + // debugger; + elem.style.height = '' + elem.style.transition = 'none' + elem.style.transitionTimingFunction = 'ease-in-out' + const startHeight = window.getComputedStyle(elem).height + const contentWrapper = document.querySelector(`.heading[data-id="${headingId}"]`) + + contentWrapper.classList.remove('opend') + contentWrapper.classList.remove('closed') + contentWrapper.classList.remove('closing') + contentWrapper.classList.remove('opening') + elem.classList.add('overflow-hidden') + + contentWrapper.classList.add(open ? 'opening' : 'closing') + + // Remove the collapse class, and force a layout calculation to get the final height + elem.classList.toggle(collapseClass) + const height = window.getComputedStyle(elem).height + + // Set the start height to begin the transition + elem.style.height = startHeight + + // wait until the next frame so that everything has time to update before starting the transition + requestAnimationFrame(() => { + elem.style.transition = '' + + requestAnimationFrame(() => { + elem.style.height = height + }) + }) + + function callback () { + elem.style.height = '' + if (open) { + contentWrapper.classList.remove('closed') + contentWrapper.classList.remove('closing') + contentWrapper.classList.add('opend') + elem.classList.remove('overflow-hidden') + } else { + contentWrapper.classList.remove('opening') + contentWrapper.classList.remove('opend') + contentWrapper.classList.add('closed') + elem.classList.add('overflow-hidden') + } + + elem.removeEventListener('transitionend', callback) + } + + // Clear the saved height values after the transition + elem.addEventListener('transitionend', callback) +} + +const HeadingsContent = Node.create({ + name: 'contentWrapper', + content: '(heading|paragraph|block)*', + defining: true, + selectable: false, + isolating: true, + draggable: false, + allowGapCursor: false, + addOptions () { + return { + persist: true, + id: null, + HTMLAttributes: {} + } + }, + parseHTML () { + return [ + { + tag: `div[data-type="${this.name}"]` + } + ] + }, + addAttributes () { + return { + id: { + default: null, + rendered: false + } + } + }, + renderHTML ({ HTMLAttributes }) { + return [ + 'div', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 'data-type': this.name }), + 0 + ] + }, + addNodeView () { + return ({ editor, getPos, node, HTMLAttributes }) => { + const dom = document.createElement('div') + + // get parent node + const upperNode = editor.state.doc.nodeAt(getPos() - 2) + const parentNode = editor.state.doc.nodeAt(getPos() - upperNode.nodeSize - 2) + const headingId = parentNode?.attrs.id + + const nodeState = getNodeState(headingId) + + dom.setAttribute('class', 'contentWrapper') + + const attrs = { + 'data-type': this.name + } + const attributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, attrs) + + if (!nodeState.crinkleOpen) { + dom.classList.add('overflow-hidden') + dom.classList.add('collapsed') + dom.classList.add('closed') + } else { + dom.classList.remove('overflow-hidden') + dom.classList.remove('collapsed') + dom.classList.remove('closed') + dom.classList.add('opend') + } + + const content = document.createElement('div') + + content.classList.add('contents') + dom.append(content) + + Object.entries(attributes).forEach(([key, value]) => dom.setAttribute(key, value)) + dom.addEventListener('toggleHeadingsContent', ({ detail }) => { + const section = detail.el + const headingMap = JSON.parse(localStorage.getItem('headingMap')) || [] + const nodeState = headingMap.find(h => h.headingId === detail.headingId) || { crinkleOpen: true } + + editor.commands.focus() + + if (editor.isEditable && typeof getPos === 'function') { + const { tr } = editor.state + + const pos = getPos() + const currentNode = tr.doc.nodeAt(pos) + + if ((currentNode === null || currentNode === void 0 ? void 0 : currentNode.type) !== this.type) { + return false + } + + if (nodeState.crinkleOpen) { + section.classList.add('overflow-hidden') + } + + tr.setMeta('addToHistory', false) + // for trigger table of contents + tr.setMeta('fold&unfold', true) + editor.view.dispatch(tr) + + PubSub.publish('toggleHeadingsContent', { headingId: detail.headingId, crinkleOpen: !nodeState.crinkleOpen }) + expandElement(section, 'collapsed', detail.headingId, !nodeState.crinkleOpen) + } + }) + + return { + dom, + contentDOM: dom, + ignoreMutation (mutation) { + if (mutation.type === 'selection') { + return false + } + + return !dom.contains(mutation.target) || dom === mutation.target + }, + update: updatedNode => { + if (updatedNode.type !== this.type) { + return false + } + + return true + } + } + } + }, + addKeyboardShortcuts () { + return { + Backspace: (data) => { + const { schema, selection } = this.editor.state + const { empty, $anchor, $head, $from, $to } = selection + const { start, end, depth } = $from.blockRange($to) + + // if backspace hit in the node that not have any content + if ($anchor.parentOffset !== 0) return false + const contentWrapper = $anchor.doc?.nodeAt($from?.before(depth)) + + // if Backspace is in the contentWrapper + if (contentWrapper.type.name !== schema.nodes.contentHeading.name) { + if (contentWrapper.type.name !== schema.nodes.contentWrapper.name) return + // INFO: if the contentWrapper block has one child just change textSelection + // Otherwise remove the current line and move the textSelection to the + + if (contentWrapper.childCount === 1) { + return this.editor.chain() + .setTextSelection(start - 2) + .scrollIntoView() + .run() + } else { + return this.editor.chain() + .deleteRange({ from: start, to: end }) + .setTextSelection(start - 2) + .scrollIntoView() + .run() + } + } + }, + // Escape node on double enter + Enter: ({ editor }) => { } + } + }, + addProseMirrorPlugins () { + return [ + new Plugin({ + key: new PluginKey('crinkle'), + state: { + init (_, { doc }) { + return lintDeco(doc) + }, + apply (tr, old) { + return tr.docChanged ? lintDeco(tr.doc) : old + } + }, + props: { + decorations (state) { + return this.getState(state) + } + } + }) + ] + } +}) + +export { HeadingsContent, HeadingsContent as default } diff --git a/packages/next.js/components/TipTap/extentions/Heading.jsx b/packages/next.js/components/TipTap/extentions/Heading.jsx new file mode 100644 index 000000000..7e71241ba --- /dev/null +++ b/packages/next.js/components/TipTap/extentions/Heading.jsx @@ -0,0 +1,230 @@ +import { Node, mergeAttributes, findChildren, isActive } from '@tiptap/core' +import { Slice, Fragment } from 'prosemirror-model' +import { Selection, Plugin, PluginKey, TextSelection } from 'prosemirror-state' + +import changeHeadingLevel from './changeHeadingLevel' +import wrapContenWithHeading from './wrapContenWithHeading' +import clipboardPast from './clipboardPast' +import changeHeading2paragraphs from './changeHeading2paragraphs' +import { getSelectionBlocks, getNodeState } from './helper' + +const Blockquote = Node.create({ + name: 'heading', + content: 'contentHeading+ contentWrapper*', + group: 'contentWrapper', + defining: true, + isolating: true, + allowGapCursor: false, + addOptions () { + return { + levels: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + persist: false, + openClassName: 'opend', + id: 1, + HTMLAttributes: { + class: 'heading', + level: 1 + } + } + }, + addAttributes () { + return { + level: { + default: 1, + rendered: false + } + } + }, + addNodeView () { + return ({ editor, getPos, node, HTMLAttributes }) => { + const dom = document.createElement('div') + const attributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + 'data-type': this.name, + level: node.firstChild?.attrs.level, + 'data-id': HTMLAttributes['data-id'] || this.options.id + }) + + const headingId = !node.attrs.id ? '1' : HTMLAttributes['data-id'] + + const nodeState = getNodeState(headingId) + + Object.entries(attributes).forEach(([key, value]) => dom.setAttribute(key, value)) + + dom.classList.add(nodeState.crinkleOpen ? 'opend' : 'closed') + + const content = document.createElement('div') + + content.classList.add('wrapBlock') + content.setAttribute('data-id', headingId) + dom.append(content) + + return { + dom, + contentDOM: content, + ignoreMutation: mutation => { + if (mutation.type === 'selection') return false + + return !dom.contains(mutation.target) || dom === mutation.target + }, + update: updatedNode => { + if (updatedNode.type.name !== this.name) return false + + return true + } + } + } + }, + parseHTML () { + return [ + { tag: 'div' } + ] + }, + renderHTML ({ node, HTMLAttributes }) { + // console.log(node, "coming render html") + const hasLevel = this.options.levels.includes(node.attrs.level) + const level = hasLevel + ? node.attrs.level + : this.options.levels[0] + + return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + }, + addCommands () { + return { + normalText: () => (arrg) => { + return changeHeading2paragraphs(arrg) + }, + wrapBlock: (attributes) => (arrg) => { + const { can, chain, commands, dispatch, editor, state, tr, view } = arrg + const { schema, selection, doc } = state + const { $from, $to, $anchor, $cursor } = selection + + // TODO: change heading level + // First get the content of heading + // then copy the contentWrapper of the heading + if ($anchor.parent.type.name === schema.nodes.contentHeading.name) { + return changeHeadingLevel(arrg, attributes, dispatch) + } + + return wrapContenWithHeading(arrg, attributes, dispatch) + } + } + }, + addKeyboardShortcuts () { + return { + Backspace: (data) => { }, + Enter: ({ editor, chain }) => { + const { state, view } = editor + const { schema, selection, doc, tr } = state + const { $head, $anchor, $from, $to } = selection + + // TODO: limited just for contentHeading, contentWrapper + if ($head.parent.type.name !== schema.nodes.contentHeading.name) { + return false + } + + const { start, end, depth } = $from.blockRange($to) + + // if a user Enter in the contentHeading block, + // should go to the next block, which is contentWrapper + const parent = $head.path.filter(x => x?.type?.name) + .findLast(x => x.type.name === this.name) + + // INFO: if the content is hide, do not anything + // ! this open in the Heading block is wrong and Have to change, It's opposite + const headingId = parent.attrs.id + const nodeState = getNodeState(headingId) + + if (!nodeState.crinkleOpen) return true + + console.log('yes new', { + $head, + state, + $anchor, + parent, + content: parent?.lastChild?.firstChild?.type.name, + sd: Selection.near(state.doc.resolve($from.pos), 1), + // after: $head.start(depth + 1), + // newResolve: $head.node(depth + 1) + isHeading: parent + }) + + // FIXME: not working + // some times the contentWrapper cleaned up, so it should be create first + // otherwise just the cursour must move to contnetWrapper + // TODO: find better way for this 4 + if (parent?.content?.content.length === 1 || parent.lastChild?.firstChild?.type.name === 'heading') { + // console.log("yes iminininin", parent.lastChild.firstChild.contentsize === 0, parent.lastChild.firstChild) + // If there is not any contentWrapper + console.log(parent.lastChild) + // if first child of the heading is another heading + // console.log(parent.lastChild.type.name === "contentWrapper") + // console.log(parent.lastChild.content.lastChild.type.name === "heading") + // if the contentWrapper does not contain any content + if (parent.lastChild.content.size === 0 || parent.lastChild?.firstChild?.content.size === 0) { + return editor.commands.insertContentAt($anchor.pos, { + type: 'contentWrapper', + content: [ + { + type: 'paragraph' + } + ] + + }) + } + console.log('move to contetnWrapper', { + after: $anchor.after(depth + 1), + start, + start1: $anchor.start(depth + 2) + 1 + }) + // move to contentWrapper + editor.commands + .insertContentAt($anchor.start(depth + 2) + 1, '

') + + return true + } + + // INFO: 1 mean start of the next line + const nextLine = end + 1 + + return editor.chain() + .insertContentAt(nextLine, '

') + .scrollIntoView() + .run() + } + } + }, + addProseMirrorPlugins () { + return [ + // https://github.com/pageboard/pagecut/blob/bd91a17986978d560cc78642e442655f4e09ce06/src/editor.js#L234-L241 + new Plugin({ + key: new PluginKey('copy&pasteHeading'), + props: { + // INFO: Div turn confuses the schema service; + // INFO:if there is a div in the clipboard, the docsplus schema will not serialize as a must. + transformPastedHTML: (html, event) => html.replace(/div/g, 'span'), + transformPasted: (slice) => clipboardPast(slice, this.editor), + transformCopied: (slice, view) => { + // Can be used to transform copied or cut content before it is serialized to the clipboard. + const { selection, doc } = this.editor.state + const { from, to } = selection + + // TODO: this function retrive blocks level from the selection, I need to block characters level from the selection + const contentWrapper = getSelectionBlocks(doc.cut(from, to), null, null, true, true) + + // convert Json Block to Node Block + let serializeSelection = contentWrapper + .map(x => this.editor.state.schema.nodeFromJSON(x)) + + // convert Node Block to Fragment + serializeSelection = Fragment.fromArray(serializeSelection) + + // convert Fragment to Slice and save it to clipboard + return Slice.maxOpen(serializeSelection) + } + } + }) + ] + } +}) + +export { Blockquote, Blockquote as default } diff --git a/packages/next.js/components/TipTap/extentions/UniqueId.jsx b/packages/next.js/components/TipTap/extentions/UniqueId.jsx new file mode 100644 index 000000000..ebf3ada26 --- /dev/null +++ b/packages/next.js/components/TipTap/extentions/UniqueId.jsx @@ -0,0 +1,254 @@ +import { Extension, findChildren, combineTransactionSteps, getChangedRanges, findChildrenInRange } from '@tiptap/core' +import { Plugin, PluginKey } from 'prosemirror-state' +import { Slice, Fragment } from 'prosemirror-model' +import { v4 } from 'uuid' + +/** + * Removes duplicated values within an array. + * Supports numbers, strings and objects. + */ +function removeDuplicates (array, by = JSON.stringify) { + const seen = {} + + return array.filter(item => { + const key = by(item) + + return Object.prototype.hasOwnProperty.call(seen, key) + ? false + : (seen[key] = true) + }) +} + +/** + * Returns a list of duplicated items within an array. + */ +function findDuplicates (items) { + const filtered = items.filter((el, index) => items.indexOf(el) !== index) + const duplicates = removeDuplicates(filtered) + + return duplicates +} + +const UniqueID = Extension.create({ + name: 'uniqueID', + // we’ll set a very high priority to make sure this runs first + // and is compatible with `appendTransaction` hooks of other extensions + priority: 10000, + addOptions () { + return { + attributeName: 'id', + types: [], + generateID: () => v4(), + filterTransaction: null + } + }, + addGlobalAttributes () { + return [ + { + types: this.options.types, + attributes: { + [this.options.attributeName]: { + default: null, + parseHTML: element => element.getAttribute(`data-${this.options.attributeName}`), + renderHTML: attributes => { + if (!attributes[this.options.attributeName]) { + return {} + } + + return { + [`data-${this.options.attributeName}`]: attributes[this.options.attributeName] + } + } + } + } + } + ] + }, + // check initial content for missing ids + onCreate () { + // Don’t do this when the collaboration extension is active + // because this may update the content, so Y.js tries to merge these changes. + // This leads to empty block nodes. + // See: https://github.com/ueberdosis/tiptap/issues/2400 + // console.log(this.editor.extensionManager.extensions.filter(x => x.type === 'extension')) + if (this.editor.extensionManager.extensions.find(extension => extension.name === 'collaboration')) { + return + } + console.log('once update========>>>>') + const { view, state } = this.editor + const { tr, doc } = state + const { types, attributeName, generateID } = this.options + const nodesWithoutId = findChildren(doc, node => { + return types.includes(node.type.name) && + node.attrs[attributeName] === null + }) + + nodesWithoutId.forEach(({ node, pos }) => { + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + [attributeName]: generateID() + }) + }) + tr.setMeta('addToHistory', false) + view.dispatch(tr) + }, + addProseMirrorPlugins () { + let dragSourceElement = null + let transformPasted = false + + return [ + new Plugin({ + key: new PluginKey('uniqueID'), + appendTransaction: (transactions, oldState, newState) => { + const docChanges = transactions.some(transaction => transaction.docChanged) && + !oldState.doc.eq(newState.doc) + const filterTransactions = this.options.filterTransaction && + transactions.some(tr => { + let _a, _b + + return !((_b = (_a = this.options).filterTransaction) === null || _b === void 0 ? void 0 : _b.call(_a, tr)) + }) + + if (!docChanges || filterTransactions) { + return + } + const { tr } = newState + const { types, attributeName, generateID } = this.options + const transform = combineTransactionSteps(oldState.doc, transactions) + const { mapping } = transform + + // get changed ranges based on the old state + const changes = getChangedRanges(transform) + + changes.forEach(({ newRange }) => { + const newNodes = findChildrenInRange(newState.doc, newRange, node => { + return types.includes(node.type.name) + }) + const newIds = newNodes + .map(({ node }) => node.attrs[attributeName]) + .filter(id => id !== null) + const duplicatedNewIds = findDuplicates(newIds) + + newNodes.forEach(({ node, pos }) => { + let _a + // instead of checking `node.attrs[attributeName]` directly + // we look at the current state of the node within `tr.doc`. + // this helps to prevent adding new ids to the same node + // if the node changed multiple times within one transaction + const id = (_a = tr.doc.nodeAt(pos)) === null || _a === void 0 ? void 0 : _a.attrs[attributeName] + + if (id === null) { + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + [attributeName]: generateID() + }) + + return + } + // check if the node doesn’t exist in the old state + const { deleted } = mapping.invert().mapResult(pos) + const newNode = deleted && duplicatedNewIds.includes(id) + + if (newNode) { + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + [attributeName]: generateID() + }) + } + }) + }) + if (!tr.steps.length) { + return + } + + return tr + }, + // we register a global drag handler to track the current drag source element + view (view) { + const handleDragstart = (event) => { + let _a + + dragSourceElement = ((_a = view.dom.parentElement) === null || _a === void 0 ? void 0 : _a.contains(event.target)) + ? view.dom.parentElement + : null + } + + window.addEventListener('dragstart', handleDragstart) + + return { + destroy () { + window.removeEventListener('dragstart', handleDragstart) + } + } + }, + props: { + // `handleDOMEvents` is called before `transformPasted` + // so we can do some checks before + handleDOMEvents: { + // only create new ids for dropped content while holding `alt` + // or content is dragged from another editor + drop: (view, event) => { + let _a + + if (dragSourceElement !== view.dom.parentElement || + ((_a = event.dataTransfer) === null || _a === void 0 ? void 0 : _a.effectAllowed) === 'copy') { + dragSourceElement = null + transformPasted = true + } + + return false + }, + // always create new ids on pasted content + paste: () => { + transformPasted = true + + return false + } + }, + // we’ll remove ids for every pasted node + // so we can create a new one within `appendTransaction` + transformPasted: slice => { + if (!transformPasted) { + return slice + } + const { types, attributeName } = this.options + const removeId = (fragment) => { + const list = [] + + fragment.forEach(node => { + // don’t touch text nodes + if (node.isText) { + list.push(node) + + return + } + // check for any other child nodes + if (!types.includes(node.type.name)) { + list.push(node.copy(removeId(node.content))) + + return + } + // remove id + const nodeWithoutId = node.type.create({ + ...node.attrs, + [attributeName]: null + }, removeId(node.content), node.marks) + + list.push(nodeWithoutId) + }) + + return Fragment.from(list) + } + + // reset check + transformPasted = false + + return new Slice(removeId(slice.content), slice.openStart, slice.openEnd) + } + } + }) + ] + } +}) + +export { UniqueID, UniqueID as default } diff --git a/packages/next.js/components/TipTap/extentions/changeHeading2paragraphs.js b/packages/next.js/components/TipTap/extentions/changeHeading2paragraphs.js new file mode 100644 index 000000000..4f75a0fe2 --- /dev/null +++ b/packages/next.js/components/TipTap/extentions/changeHeading2paragraphs.js @@ -0,0 +1,18 @@ +import onHeading from './normalText/onHeading' +import onSelection from './normalText/onSelection' + +export default (arrg) => { + const { state } = arrg + const { selection } = state + const { $anchor, $head } = selection + + if ($anchor.pos === $head.pos) { + console.info('[Heading], normalText on Heading block') + + return onHeading(arrg) + } else { + console.info('[Heading], normalText on selection') + + return onSelection(arrg) + } +} diff --git a/packages/next.js/components/TipTap/extentions/changeHeadingLevel-backward.js b/packages/next.js/components/TipTap/extentions/changeHeadingLevel-backward.js new file mode 100644 index 000000000..26f7bc283 --- /dev/null +++ b/packages/next.js/components/TipTap/extentions/changeHeadingLevel-backward.js @@ -0,0 +1,70 @@ +import { TextSelection } from 'prosemirror-state' + +import { getRangeBlocks, getHeadingsBlocksMap, createThisBlockMap } from './helper' + +export default (arrg, attributes, asWrapper = false) => { + const { can, chain, commands, dispatch, editor, state, tr, view } = arrg + const { schema, selection, doc } = state + const { $from, $to, $anchor, $cursor, from } = selection + const { start, end, depth } = $from.blockRange($to) + + console.log('[Heading]: Backward process, comingLevel < currentHLevel') + + const comingLevel = attributes.level + const caretSelectionTextBlock = { type: 'text', text: doc?.nodeAt($anchor.pos)?.text || $anchor.nodeBefore?.text || ' ' } + + const block = createThisBlockMap($from, depth, caretSelectionTextBlock) + const titleNode = $from.doc.nodeAt($from.start(1) - 1) + const titleStartPos = $from.start(1) - 1 + const titleEndPos = titleStartPos + titleNode.content.size + const contentWrapper = getRangeBlocks(doc, start, titleEndPos) + const titleHMap = getHeadingsBlocksMap(doc, start, titleEndPos) + + const sliceTargetContent = contentWrapper.filter(x => { + if (x.type !== 'heading') return x + + return x.le > comingLevel + }) + + // remove the first paragraph, if the request is to wrap the content + if (asWrapper) { + const pickedNode = sliceTargetContent.shift() + + if (sliceTargetContent.length === 0) { + sliceTargetContent.push({ ...pickedNode, content: [block.paragraph] }) + } + } + + const endSliceBlocPos = sliceTargetContent[sliceTargetContent.length - 1].endBlockPos + const insertPos = titleHMap + .filter(x => endSliceBlocPos <= x.endBlockPos) + .find(x => x.le >= comingLevel)?.endBlockPos + + const jsonNewBlock = { + type: 'heading', + content: [ + { + type: 'contentHeading', + content: [block.headingContent], + attrs: { + level: attributes.level + } + }, + { + type: 'contentWrapper', + content: sliceTargetContent + } + ] + } + + const node = state.schema.nodeFromJSON(jsonNewBlock) + + const newTr = tr.insert(insertPos, node) + + const newTextSelection = new TextSelection(newTr.doc.resolve(from)) + + newTr.setSelection(newTextSelection) + newTr.deleteRange(start - 1, insertPos) + + return true +} diff --git a/packages/next.js/components/TipTap/extentions/changeHeadingLevel-forward.js b/packages/next.js/components/TipTap/extentions/changeHeadingLevel-forward.js new file mode 100644 index 000000000..5a14157af --- /dev/null +++ b/packages/next.js/components/TipTap/extentions/changeHeadingLevel-forward.js @@ -0,0 +1,110 @@ +import { TextSelection } from 'prosemirror-state' + +import { getRangeBlocks, getHeadingsBlocksMap, createThisBlockMap, getPrevHeadingList, getPrevHeadingPos } from './helper' + +export default (arrg, attributes) => { + const { can, chain, commands, dispatch, editor, state, tr, view } = arrg + const { schema, selection, doc } = state + const { $from, $to, $anchor, $cursor, $head, from } = selection + const { start, end, depth } = $from.blockRange($to) + + console.log('[Heading]: change heading level forwarding') + + const commingLevel = attributes.level + const caretSelectionTextBlock = { type: 'text', text: doc?.nodeAt($anchor.pos)?.text || $anchor.nodeBefore?.text || ' ' } + + const block = createThisBlockMap($from, depth, caretSelectionTextBlock) + const titleNode = $from.doc.nodeAt($from.start(1) - 1) + const titleStartPos = $from.start(1) - 1 + const titleEndPos = titleStartPos + titleNode.content.size + const contentWrapper = getRangeBlocks(doc, start, titleEndPos) + const titleHMap = getHeadingsBlocksMap(doc, titleStartPos, titleEndPos) + + const contentWrapperParagraphs = contentWrapper.filter(x => x.type !== 'heading') + const contentWrapperHeadings = contentWrapper.filter(x => x.type === 'heading') + + const { prevHStartPos, prevHEndPos } = getPrevHeadingPos(doc, titleStartPos, start - 1) + + let mapHPost = titleHMap.filter(x => + x.startBlockPos < start - 1 && + x.startBlockPos >= prevHStartPos + ) + + let shouldNested = false + + // let prevBlock = mapHPost.find(x => x.le >= commingLevel) + // FIXME: this is heavy! I need to find better solotion with less loop + const prevBlockEqual = mapHPost.findLast(x => x.le === commingLevel) + const prevBlockGratherFromFirst = mapHPost.find(x => x.le >= commingLevel) + const prevBlockGratherFromLast = mapHPost.findLast(x => x.le <= commingLevel) + const lastBlock = mapHPost.at(-1) + let prevBlock = prevBlockEqual || prevBlockGratherFromLast || prevBlockGratherFromFirst + + if (lastBlock?.le <= commingLevel) prevBlock = lastBlock + if (!prevBlock) prevBlock = lastBlock + shouldNested = prevBlock.le < commingLevel + + if (prevBlock.le === 1) shouldNested = false + + const jsonNode = { + type: 'heading', + content: [ + { + type: 'contentHeading', + content: [block.headingContent], + attrs: { + level: attributes.level + } + }, + { + type: 'contentWrapper', + content: contentWrapperParagraphs + } + ] + } + + const node = state.schema.nodeFromJSON(jsonNode) + + // remove content from the current positon to the end of the heading + tr.delete(start - 1, titleEndPos) + // then add the new heading with the content + const insertPos = (contentWrapperHeadings.length === 0 ? prevBlock.endBlockPos : start - 1) - (shouldNested ? 2 : 0) + + tr.insert(tr.mapping.map(insertPos), node) + + // set the cursor to the end of the heading + const newSelection = new TextSelection(tr.doc.resolve(from)) + + tr.setSelection(newSelection) + + // after all that, we need to loop through the heading to append + for (const heading of contentWrapperHeadings) { + const commingLevel = heading.le + + mapHPost = getPrevHeadingList( + tr, + mapHPost.at(0).startBlockPos, + tr.mapping.map(mapHPost.at(0).endBlockPos) + ) + + mapHPost = mapHPost.filter(x => + x.startBlockPos < heading.startBlockPos && + x.startBlockPos >= prevHStartPos + ) + + const prevBlockEqual = mapHPost.findLast(x => x.le === commingLevel) + const prevBlockGratherFromFirst = mapHPost.find(x => x.le >= commingLevel) + const prevBlockGratherFromLast = mapHPost.findLast(x => x.le <= commingLevel) + const lastBlock = mapHPost.at(-1) + + prevBlock = prevBlockEqual || prevBlockGratherFromLast || prevBlockGratherFromFirst + if (lastBlock.le <= commingLevel) prevBlock = lastBlock + shouldNested = prevBlock.le < commingLevel + + const node = state.schema.nodeFromJSON(heading) + + tr.insert(prevBlock.endBlockPos - (shouldNested ? 2 : 0), node) + } + + return true +} diff --git a/packages/next.js/components/TipTap/extentions/changeHeadingLevel-h1.js b/packages/next.js/components/TipTap/extentions/changeHeadingLevel-h1.js new file mode 100644 index 000000000..d4c4be94d --- /dev/null +++ b/packages/next.js/components/TipTap/extentions/changeHeadingLevel-h1.js @@ -0,0 +1,118 @@ +import { TextSelection } from 'prosemirror-state' + +import { getPrevHeadingList, createThisBlockMap, getHeadingsBlocksMap, getRangeBlocks, getPrevHeadingPos } from './helper' + +export default (arrg, attributes) => { + const { can, chain, commands, dispatch, editor, state, tr, view } = arrg + const { schema, selection, doc } = state + const { $from, $to, $anchor, $cursor, from } = selection + const { start, end, depth } = $from.blockRange($to) + + console.log('[Heading]: change heading Level h1') + + const commingLevel = attributes.level + const caretSelectionTextBlock = { type: 'text', text: doc?.nodeAt($anchor.pos)?.text || $anchor.nodeBefore?.text || ' ' } + + const block = createThisBlockMap($from, depth, caretSelectionTextBlock) + const currentHLevel = $from.doc.nodeAt(block.start).attrs.level + + let titleStartPos = 0 + let titleEndPos = 0 + + doc.nodesBetween($from.start(0), start - 1, function (node, pos, parent, index) { + if (node.type.name === 'heading') { + const headingLevel = node.firstChild?.attrs?.level + + if (headingLevel === currentHLevel) { + titleStartPos = pos + // INFO: I need the pos of last content in contentWrapper + titleEndPos = pos + node.content.size + } + } + }) + + const contentWrapper = getRangeBlocks(doc, start, $from.start(1) - 1 + $from.doc.nodeAt($from.start(1) - 1).content.size) + const titleHMap = getHeadingsBlocksMap(doc, titleStartPos, titleEndPos) + + const contentWrapperParagraphs = contentWrapper.filter(x => x.type !== 'heading') + const contentWrapperHeadings = contentWrapper.filter(x => x.type === 'heading') + + const { prevHStartPos, prevHEndPos } = getPrevHeadingPos(doc, titleStartPos, start - 1) + + + let mapHPost = titleHMap.filter(x => + x.startBlockPos < start - 1 && + x.startBlockPos >= prevHStartPos + ) + + let shouldNested = false + + // FIXME: this is heavy! I need to find better solotion with less loop + const prevBlockEqual = mapHPost.findLast(x => x.le === commingLevel) + const prevBlockGratherFromFirst = mapHPost.find(x => x.le >= commingLevel) + const prevBlockGratherFromLast = mapHPost.findLast(x => x.le <= commingLevel) + const lastbloc = mapHPost.at(-1) + let prevBlock = prevBlockEqual || prevBlockGratherFromLast || prevBlockGratherFromFirst + + if (lastbloc.le <= commingLevel) prevBlock = lastbloc + shouldNested = prevBlock.le < commingLevel + + const jsonNode = { + type: 'heading', + content: [ + { + type: 'contentHeading', + content: [block.headingContent], + attrs: { + level: attributes.level + } + }, + { + type: 'contentWrapper', + content: contentWrapperParagraphs + } + ] + } + const node = state.schema.nodeFromJSON(jsonNode) + + // remove content from the current positon to the end of the heading + tr.delete(start - 1, $from.start(1) - 1 + $from.doc.nodeAt($from.start(1) - 1).content.size) + + // then add the new heading with the content + const insertPos = prevBlock.endBlockPos - (shouldNested ? 2 : 0) + + tr.insert(tr.mapping.map(insertPos), node) + + // set the cursor to the end of the heading + const newSelection = new TextSelection(tr.doc.resolve(from)) + + tr.setSelection(newSelection) + + // FIXME: this loop so much heavy, I need to find better solotion! + // then loop through the heading to append + for (const heading of contentWrapperHeadings) { + mapHPost = getPrevHeadingList( + tr, + mapHPost.at(0).startBlockPos, + mapHPost.at(0).startBlockPos + doc.nodeAt(mapHPost.at(0).startBlockPos).nodeSize + 2 + ) + + mapHPost = mapHPost.filter(x => + x.startBlockPos < heading.startBlockPos && + x.startBlockPos >= prevHStartPos + ) + + const node = state.schema.nodeFromJSON(heading) + + const prevBlockEqual = mapHPost.findLast(x => x.le === heading.le) + const prevBlockGratherFromFirst = mapHPost.find(x => x.le >= heading.le) + const prevBlockGratherFromLast = mapHPost.findLast(x => x.le <= heading.le) + const lastbloc = mapHPost.at(-1) + let prevBlock = prevBlockEqual || prevBlockGratherFromLast || prevBlockGratherFromFirst + + if (lastbloc.le <= heading.le) prevBlock = lastbloc + shouldNested = prevBlock.le < heading.le + + tr.insert(prevBlock.endBlockPos - (shouldNested ? 2 : 0), node) + } +} diff --git a/packages/next.js/components/TipTap/extentions/changeHeadingLevel.js b/packages/next.js/components/TipTap/extentions/changeHeadingLevel.js new file mode 100644 index 000000000..2933700fd --- /dev/null +++ b/packages/next.js/components/TipTap/extentions/changeHeadingLevel.js @@ -0,0 +1,36 @@ +import changeHeadingLevelBackward from './changeHeadingLevel-backward' +import changeHeadingLevelForward from './changeHeadingLevel-forward' +import changeHeadingLevelForwardH1 from './changeHeadingLevel-h1' + +export default (arrg, attributes) => { + const { can, chain, commands, dispatch, editor, state, tr, view } = arrg + const { schema, selection, doc } = state + const { $from, $to, $anchor, $cursor } = selection + const { start, end, depth } = $from.blockRange($to) + + const commingLevel = attributes.level + const currentHLevel = $from.doc.nodeAt(start).attrs.level + + if (commingLevel === currentHLevel) { + console.log('[heading]: commingLevel === nextSiblingLevel') + + return true + } + + // H1 + if (currentHLevel === 1) { + return changeHeadingLevelForwardH1(arrg, attributes) + } + + // H2 > H3 || H4 > H3 + if (commingLevel > currentHLevel) { + return changeHeadingLevelForward(arrg, attributes) + } + + // H2 < H3 || H4 < H3 + if (commingLevel < currentHLevel) { + return changeHeadingLevelBackward(arrg, attributes) + } + + return true +} diff --git a/packages/next.js/components/TipTap/extentions/clipboardPast.js b/packages/next.js/components/TipTap/extentions/clipboardPast.js new file mode 100644 index 000000000..8bc59b859 --- /dev/null +++ b/packages/next.js/components/TipTap/extentions/clipboardPast.js @@ -0,0 +1,213 @@ +import { Slice, Fragment, NodeRange, NodeType, Mark, ContentMatch } from 'prosemirror-model' +import { TextSelection, Selection } from 'prosemirror-state' + +import { getRangeBlocks, getHeadingsBlocksMap } from './helper' + +const normalizeClipboardContents = (clipboardContents, editor) => { + const paragraphs = [] + const headings = [] + let heading = null + + for (const node of clipboardContents) { + if (!heading && node.type !== 'contentHeading') { + paragraphs.push(editor.schema.nodeFromJSON(node)) + } + + if (node.type === 'contentHeading') { + // if new heading is found, push the previous heading into the heading list + // and reset the heading + if (heading) { + headings.push(editor.schema.nodeFromJSON(heading)) + heading = null + } + heading = { + type: 'heading', + attrs: { level: node?.attrs.level }, + content: [ + node, + { + type: 'contentWrapper', + content: [] + } + ] + } + } else { + heading?.content.at(1).content.push(node) + } + } + + if (heading) { + headings.push(editor.schema.nodeFromJSON(heading)) + } + + return [paragraphs, headings] +} + +export default (slice, editor) => { + const { state, view } = editor + const { schema, selection, doc, tr } = state + const { from, to, $anchor } = selection + + let newPosResolver + let $from = selection.$from + let start = $from.pos + + // if user cursor is in the heading, + // move the cursor to the contentWrapper and do the rest + if ($from.parent.type.name === 'contentHeading') { + const firstLine = doc.nodeAt(start + 2) + + let resolveNextBlock = tr.doc.resolve(start + 2) + + newPosResolver = resolveNextBlock + + // if the heading block does not contain contentWrapper as a first child + // then create a contentWrapper block + if (firstLine.type.name === 'heading') { + const contentWrapperBlock = { + type: 'contentWrapper', + content: [ + { + type: 'paragraph' + } + ] + + } + + const node = state.schema.nodeFromJSON(contentWrapperBlock) + + tr.insert(start, node) + resolveNextBlock = tr.doc.resolve(start + 2) + } + + // put the selection to the first line of contentWrapper block + if (resolveNextBlock.parent.type.name === 'contentWrapper') { + tr.setSelection(TextSelection.near(resolveNextBlock)) + } + } + + // If caret selection move to contentWrapper, create a new selection + if (newPosResolver) { + $from = (new Selection( + newPosResolver, + newPosResolver + )).$from + } + + start = $from.pos + + // return Slice.empty + + const titleNode = $from.doc.nodeAt($from.start(1) - 1) + let titleStartPos = $from.start(1) - 1 + let titleEndPos = titleStartPos + titleNode.content.size + const contentWrapper = getRangeBlocks(doc, start, titleEndPos) + + let prevHStartPos = 0 + let prevHEndPos = 0 + + doc.nodesBetween(titleStartPos, start - 1, function (node, pos, parent, index) { + if (node.type.name === 'heading') { + const depth = doc.resolve(pos).depth + + // INFO: this the trick I've looking for + if (depth === 2) { + prevHStartPos = pos + prevHEndPos = pos + node.content.size + } + } + }) + + const clipboardContentJson = slice.toJSON().content + + const [paragraphs, headings] = normalizeClipboardContents(clipboardContentJson, state) + + // if there is no heading block, then just return + if (headings.length <= 0) return slice + + // return Slice.empty + let shouldNested = false + + tr.delete(to, titleEndPos) + + if (paragraphs.length !== 0) { + // first append the paragraphs in the current selection + tr.replaceWith(tr.mapping.map(from), tr.mapping.map(from), paragraphs) + + const newSelection = new TextSelection(tr.doc.resolve(selection.from)) + + tr.setSelection(newSelection) + } + + let lastBlockPos = paragraphs.length === 0 ? start : tr.mapping.map(start) + + const headingContent = contentWrapper.filter(x => x.type === 'heading') + + if (headingContent.length > 0) { + headingContent.forEach(heading => headings.push(state.schema.nodeFromJSON(heading))) + } + + let mapHPost = {} + // return Slice.empty + + const lastH1Inserted = { + startBlockPos: 0, + endBlockPos: 0, + } + + // paste the headings + for (const heading of headings) { + const comingLevel = heading.content.firstChild.attrs.level + + const startBlock = lastH1Inserted.startBlockPos === 0 ? tr.mapping.map(start) : lastH1Inserted.startBlockPos + const endBlock = lastH1Inserted.endBlockPos === 0 ? tr.mapping.map(titleEndPos) : tr.doc.nodeAt(lastH1Inserted.startBlockPos).content.size + lastH1Inserted.startBlockPos + + if (lastH1Inserted.startBlockPos !== 0) { + lastH1Inserted.startBlockPos = 0 + lastH1Inserted.endBlockPos = 0 + } + + mapHPost = getHeadingsBlocksMap(tr.doc, startBlock, endBlock) + + mapHPost = mapHPost.filter(x => + x.startBlockPos >= (comingLevel === 1 ? titleStartPos : prevHStartPos) + ) + + const prevBlockEqual = mapHPost.findLast(x => x.le === comingLevel) + const prevBlockGratherFromFirst = mapHPost.find(x => x.le >= comingLevel) + const prevBlockGratherFromLast = mapHPost.findLast(x => x.le <= comingLevel) + + const lastBlock = mapHPost.at(-1) + let prevBlock = prevBlockEqual || prevBlockGratherFromLast || prevBlockGratherFromFirst + + if (!prevBlock) prevBlock = lastBlock + + if (lastBlock.le <= comingLevel) prevBlock = lastBlock + + shouldNested = prevBlock.le < comingLevel + + // find prevBlock.le in mapHPost + const robob = mapHPost.filter(x => prevBlock.le === x.le) + + if (robob.length > 1) { + prevBlock = robob.at(-1) + } + + lastBlockPos = prevBlock.endBlockPos + + if (prevBlock && prevBlock.depth === 2) { + prevHStartPos = prevBlock.startBlockPos + } + + tr.insert(lastBlockPos - (shouldNested ? 2 : 0), heading) + + if (comingLevel === 1) { + lastH1Inserted.startBlockPos = lastBlockPos + lastH1Inserted.endBlockPos = tr.mapping.map(lastBlockPos + heading.content.size) + } + } + tr.setMeta('paste', true) + view.dispatch(tr) + + return Slice.empty +} diff --git a/packages/next.js/components/TipTap/extentions/crinkle.event.js b/packages/next.js/components/TipTap/extentions/crinkle.event.js new file mode 100644 index 000000000..e69de29bb diff --git a/packages/next.js/components/TipTap/extentions/helper.js b/packages/next.js/components/TipTap/extentions/helper.js new file mode 100644 index 000000000..1500eed40 --- /dev/null +++ b/packages/next.js/components/TipTap/extentions/helper.js @@ -0,0 +1,239 @@ +import { Slice, Fragment, NodeRange, NodeType, Mark, ContentMatch } from 'prosemirror-model' + +/** + * + * @param {Object} tr transform object + * @param {Number} start start pos + * @param {Number} from end pos + * @returns + */ +export const getPrevHeadingList = (tr, start, from) => { + const titleHMap = [] + + if (from < start) throw new Error("[Heading]: position is invalid 'from < start'") + try { + tr.doc.nodesBetween(start, from, function (node, pos, parent, index) { + if (node.type.name === 'heading') { + const headingLevel = node.firstChild?.attrs?.level + const depth = tr.doc.resolve(pos).depth + + titleHMap.push({ + le: headingLevel, + node: node.toJSON(), + depth, + startBlockPos: pos, + endBlockPos: pos + node.nodeSize + }) + } + }) + } catch (error) { + console.error('[Heading]: getPrevHeadingList', error, { tr, start, from }) + // return Slice.empty + } finally { + return titleHMap + } +} + +/** + * + * @param {Object} doc prosemirror doc + * @param {Number} start start pos + * @param {Number} end end pos + * @returns Array of Selection Block + */ +export const getSelectionBlocks = (doc, start, end, includeContentHeading = false, range = false) => { + const firstHEading = true + const prevDepth = 0 + const selectedContents = [] + + if (range) { + console.log(doc) + doc.descendants(function (node, pos, parent) { + if (firstHEading && node.type.name !== 'heading' && parent.type.name === 'contentWrapper') { + const depth = doc.resolve(pos).depth + + selectedContents.push({ depth, startBlockPos: pos, endBlockPos: pos + node.nodeSize, ...node.toJSON() }) + } + + if (node.type.name === 'contentHeading') { + const depth = doc.resolve(pos).depth + + selectedContents.push({ + depth, + level: node.attrs?.level, + attrs: includeContentHeading ? node.attrs : {}, + startBlockPos: pos, + endBlockPos: pos + node.nodeSize, + type: includeContentHeading ? node.type.name : 'paragraph', + content: node.toJSON().content + }) + } + }) + + return selectedContents + } + + doc.nodesBetween(start, end, function (node, pos, parent, index) { + if (pos < start) return + + if (firstHEading && node.type.name !== 'heading' && parent.type.name === 'contentWrapper') { + const depth = doc.resolve(pos).depth + + selectedContents.push({ depth, startBlockPos: pos, endBlockPos: pos + node.nodeSize, ...node.toJSON() }) + } + + if (node.type.name === 'contentHeading') { + const depth = doc.resolve(pos).depth + + selectedContents.push({ + depth, + level: node.attrs?.level, + attrs: includeContentHeading ? node.attrs : {}, + startBlockPos: pos, + endBlockPos: pos + node.nodeSize, + type: includeContentHeading ? node.type.name : 'paragraph', + content: node.toJSON().content + }) + } + }) + + return selectedContents +} + +/** + * + * @param {Object} doc prosemirror doc + * @param {Number} start start pos + * @param {Number} end end pos + * @returns Array of Selection Block + */ +export const getRangeBlocks = (doc, start, end) => { + let firstHEading = true + let prevDepth = 0 + const selectedContents = [] + + doc.nodesBetween(start, end, function (node, pos, parent, index) { + if (pos < start) return + + if (firstHEading && node.type.name !== 'heading' && parent.type.name === 'contentWrapper') { + const depth = doc.resolve(pos).depth + + selectedContents.push({ depth, startBlockPos: pos, endBlockPos: pos + node.nodeSize, ...node.toJSON() }) + } + if (node.type.name === 'heading') { + firstHEading = false + const headingLevel = node.firstChild?.attrs?.level + const depth = doc.resolve(pos).depth + + if (prevDepth === 0) prevDepth = depth + + if (prevDepth >= depth) { + selectedContents.push({ le: headingLevel, depth, startBlockPos: pos, endBlockPos: pos + node.nodeSize, ...node.toJSON() }) + prevDepth = depth + } + } + }) + + return selectedContents +} + +/** + * Returns a map of headings and the blocks that fall under them + * + * @param {Object} doc + * @param {number} start + * @param {number} end + * @returns {Map} + */ +export const getHeadingsBlocksMap = (doc, start, end) => { + const titleHMap = [] + + doc.nodesBetween(start, end, function (node, pos, parent, index) { + if (node.type.name === 'heading') { + const headingLevel = node.firstChild?.attrs?.level + const depth = doc.resolve(pos).depth + + titleHMap.push({ le: headingLevel, depth, startBlockPos: pos, endBlockPos: pos + node.nodeSize, index }) + } + }) + + return titleHMap +} + +/** + * Creates a block map of the editor state. + * @param {Object} $from - The current selection's start position in the editor state. + * @param {number} depth - The current selection's depth. + * @param {Object} caretSelectionTextBlock - The current selection's text block. + * @returns {Object} blockMap - The current selection's block map. + */ + +export const createThisBlockMap = ($from, depth, caretSelectionTextBlock = ' ') => { + return { + parent: { + end: $from.end(depth - 1), + start: $from.start(depth - 1) + }, + edge: { + end: $from.end(depth - 1) + 1, + start: $from.start(depth - 1) - 1 + }, + ancesster: { + start: $from.start(1), + end: $from.end(1) + }, + end: $from.end(depth), + start: $from.start(depth), + nextLevel: 0, + depth, + headingContent: caretSelectionTextBlock, + empty: { + type: 'paragraph', + content: [ + { + type: 'text', + text: ' ' + } + ] + }, + paragraph: { type: 'paragraph' } + } +} + +/** + * This method copies a text to clipboard + * @param {string} text - the text to copy + * @param {function} callback - a callback to execute after the text is copied + */ +export const copyToClipboard = (text, callback) => { + navigator.clipboard.writeText(text).then(() => { + alert('Copied to clipboard') + if (callback) callback() + }) +} + +export const getNodeState = (headingId) => { + const headingMap = JSON.parse(localStorage.getItem('headingMap')) || [] + const nodeState = headingMap.find(h => h.headingId === headingId) || { crinkleOpen: true } + + return nodeState +} + +export const getPrevHeadingPos = (doc, startPos, endPos) => { + let prevHStartPos = 0 + let prevHEndPos = 0 + + doc.nodesBetween(startPos, endPos, function (node, pos, parent, index) { + if (node.type.name === 'heading') { + const depth = doc.resolve(pos).depth + + // INFO: this the trick I've looking for + if (depth === 2) { + prevHStartPos = pos + prevHEndPos = pos + node.content.size + } + } + }) + + return { prevHStartPos, prevHEndPos } +} diff --git a/packages/next.js/components/TipTap/extentions/normalText/onHeading.js b/packages/next.js/components/TipTap/extentions/normalText/onHeading.js new file mode 100644 index 000000000..d110d2e7c --- /dev/null +++ b/packages/next.js/components/TipTap/extentions/normalText/onHeading.js @@ -0,0 +1,115 @@ +import { TextSelection } from 'prosemirror-state' + +import { getRangeBlocks, getPrevHeadingList } from '../helper' + +export default (arrg) => { + const { can, chain, commands, dispatch, editor, state, tr, view } = arrg + const { schema, selection, doc } = state + const { $from, $to, $anchor, $cursor, $head, from } = selection + const { start, end, depth } = $from.blockRange($to) + + if (doc?.nodeAt(start).type.name !== 'contentHeading') { + return console.info('[Heading]: not heading') + } + + const headingText = { type: 'text', text: doc?.nodeAt($anchor.pos)?.text || $anchor.nodeBefore?.text || ' ' } + + const titleNode = $from.doc.nodeAt($from.start(1) - 1) + let titleStartPos = $from.start(1) - 1 + let titleEndPos = titleStartPos + titleNode.content.size + const contentWrapper = getRangeBlocks(doc, start, titleEndPos) + const currentHLevel = $from.parent.attrs.level + + let prevHStartPos = 0 + let prevHEndPos = 0 + + const backspaceAction = doc.nodeAt(from) === null && $anchor.parentOffset === 0 + + tr.delete(backspaceAction ? start - 1 : start - 1, titleEndPos) + + // if the current heading is not H1, otherwise we need to find the previous H1 + if (currentHLevel !== 1) { + doc.nodesBetween(titleStartPos, start - 1, function (node, pos, parent, index) { + if (node.type.name === 'heading') { + const depth = doc.resolve(pos).depth + + // INFO: this the trick I've looking for + if (depth === 2) { + prevHStartPos = pos + prevHEndPos = pos + node.content.size + } + } + }) + } else { + doc.nodesBetween($from.start(0), start - 1, function (node, pos, parent, index) { + if (node.type.name === 'heading') { + const headingLevel = node.firstChild?.attrs?.level + + if (headingLevel === currentHLevel) { + titleStartPos = pos + titleEndPos = pos + node.content.size + } + } + }) + } + + const contentWrapperParagraphs = contentWrapper.filter(x => x.type !== 'heading') + const contentWrapperHeadings = contentWrapper.filter(x => x.type === 'heading') + const normalContents = [headingText, ...contentWrapperParagraphs] + .map(x => editor.schema.nodeFromJSON(x)) + + // this is for backspace, if the node is empty remove the TextNode + if (backspaceAction) normalContents.shift() + + const titleHMap = getPrevHeadingList(tr, titleStartPos, tr.mapping.map(titleEndPos)) + + let mapHPost = titleHMap.filter(x => + x.startBlockPos < start - 1 && + x.startBlockPos >= prevHStartPos + ) + + let shouldNested = false + + const comingLevel = mapHPost.at(-1).le + 1 + + const prevBlockEqual = mapHPost.findLast(x => x.le === comingLevel) + const prevBlockGratherFromFirst = mapHPost.find(x => x.le >= comingLevel) + const prevBlockGratherFromLast = mapHPost.findLast(x => x.le <= comingLevel) + const lastbloc = mapHPost.at(-1) + let prevBlock = prevBlockEqual || prevBlockGratherFromLast || prevBlockGratherFromFirst + + if (lastbloc.le <= comingLevel) prevBlock = lastbloc + shouldNested = prevBlock.le < comingLevel + + const insertPos = prevBlock.endBlockPos - (shouldNested ? 2 : 0) + + tr.insert(tr.mapping.map(insertPos), normalContents) + + const focusSelection = new TextSelection(tr.doc.resolve(insertPos + 1)) + + tr.setSelection(focusSelection) + + for (let heading of contentWrapperHeadings) { + if (!heading.le) heading = { ...heading, le: heading.content[0].attrs.level, startBlockPos: 0 } + + const startBlock = mapHPost[0].startBlockPos + const endBlock = tr.mapping.map(mapHPost.at(0).endBlockPos) + + mapHPost = getPrevHeadingList(tr, startBlock, endBlock) + + const node = state.schema.nodeFromJSON(heading) + + const prevBlockEqual = mapHPost.findLast(x => x.le === heading.le) + const prevBlockGratherFromFirst = mapHPost.find(x => x.le >= heading.le) + const prevBlockGratherFromLast = mapHPost.findLast(x => x.le <= heading.le) + const lastbloc = mapHPost[mapHPost.length - 1] + let prevBlock = prevBlockEqual || prevBlockGratherFromLast || prevBlockGratherFromFirst + + if (lastbloc.le <= heading.le) prevBlock = lastbloc + shouldNested = prevBlock.le < heading.le + + tr.insert(prevBlock.endBlockPos - (shouldNested ? 2 : 0), node) + } + + return (backspaceAction) ? view.dispatch(tr) : true +} diff --git a/packages/next.js/components/TipTap/extentions/normalText/onSelection.js b/packages/next.js/components/TipTap/extentions/normalText/onSelection.js new file mode 100644 index 000000000..b084c458d --- /dev/null +++ b/packages/next.js/components/TipTap/extentions/normalText/onSelection.js @@ -0,0 +1,94 @@ +import { TextSelection } from 'prosemirror-state' + +import { getSelectionBlocks, getRangeBlocks, getPrevHeadingList } from '../helper' + +export default (arrg) => { + const { can, chain, commands, dispatch, editor, state, tr, view } = arrg + const { schema, selection, doc } = state + const { $from, $to, $anchor, $cursor, $head, from, to } = selection + const { start, end, depth } = $from.blockRange($to) + + const currentHLevel = $from.parent.attrs.level + const titleNode = $from.doc.nodeAt($from.start(1) - 1) + let titleStartPos = $from.start(1) - 1 + let titleEndPos = titleStartPos + titleNode.content.size + const prevHStartPos = 0 + const prevHEndPos = 0 + + const selectionFirstLinePos = $from.pos - $from.parentOffset + + const selectedContents = getSelectionBlocks(doc, selectionFirstLinePos - 1, to) + const lastHeadingInSelection = selectedContents.findLast(x => x.hasOwnProperty('attrs')) + const lastHeadingIndex = selectedContents.findLastIndex(x => x.hasOwnProperty('attrs')) + + // on selection we have Heading level 1 + if (titleEndPos < to) { + const getTitleBlock = selectedContents.findLast(x => x.level === 1) + + titleEndPos = (getTitleBlock.startBlockPos + doc.nodeAt(getTitleBlock.startBlockPos - 1).nodeSize) - 1 + } + + // select rest of contents + const contentWrapper = getRangeBlocks(doc, lastHeadingInSelection.startBlockPos, titleEndPos) + const contentWrapperParagraphs = contentWrapper.filter(x => x.type !== 'heading') + const contentWrapperHeadings = contentWrapper.filter(x => x.type === 'heading') + + const normalizeSelectedContents = [ + ...[...selectedContents].splice(0, lastHeadingIndex + 1), + ...contentWrapperParagraphs + ] + + doc.nodesBetween(titleStartPos, start - 1, function (node, pos, parent, index) { + if (node.type.name === 'heading') { + const headingLevel = node.firstChild?.attrs?.level + + if (headingLevel === currentHLevel) { + titleStartPos = pos + // INFO: I need the pos of last content in contentWrapper + titleEndPos = pos + node.content.size + } + } + }) + + const normalizeSelectedContentsBlocks = normalizeSelectedContents.map(node => state.schema.nodeFromJSON(node)) + + tr.delete(selectionFirstLinePos, titleEndPos) + + tr.insert(tr.mapping.map(selectionFirstLinePos) - 1, normalizeSelectedContentsBlocks) + + const focusSelection = new TextSelection(tr.doc.resolve(from)) + + tr.setSelection(focusSelection) + + const titleHMap = getPrevHeadingList(tr, tr.mapping.map(titleStartPos), tr.mapping.map(titleEndPos)) + let shouldNested = false + + let mapHPost = titleHMap.filter(x => + x.startBlockPos < start - 1 && + x.startBlockPos >= prevHStartPos + ) + + if (!mapHPost.length) mapHPost = titleHMap + + for (const heading of contentWrapperHeadings) { + const startBlock = mapHPost[0].startBlockPos + const endBlock = tr.mapping.map(mapHPost.at(0).endBlockPos) + + mapHPost = getPrevHeadingList(tr, startBlock, endBlock) + + const node = state.schema.nodeFromJSON(heading) + + const prevBlockEqual = mapHPost.findLast(x => x.le === heading.le) + const prevBlockGratherFromFirst = mapHPost.find(x => x.le >= heading.le) + const prevBlockGratherFromLast = mapHPost.findLast(x => x.le <= heading.le) + const lastbloc = mapHPost[mapHPost.length - 1] + let prevBlock = prevBlockEqual || prevBlockGratherFromLast || prevBlockGratherFromFirst + + if (lastbloc.le <= heading.le) prevBlock = lastbloc + shouldNested = prevBlock.le < heading.le + + tr.insert(prevBlock.endBlockPos - (shouldNested ? 2 : 0), node) + } + + return true +} diff --git a/packages/next.js/components/TipTap/extentions/wrapContenWithHeading.js b/packages/next.js/components/TipTap/extentions/wrapContenWithHeading.js new file mode 100644 index 000000000..5fbc022e2 --- /dev/null +++ b/packages/next.js/components/TipTap/extentions/wrapContenWithHeading.js @@ -0,0 +1,136 @@ +import { TextSelection } from 'prosemirror-state' + +import changeHeadingLevelBackward from './changeHeadingLevel-backward' +import { + getPrevHeadingList, + createThisBlockMap, + getHeadingsBlocksMap, + getRangeBlocks +} from './helper' + +export default (arrg, attributes) => { + const { can, chain, commands, dispatch, editor, state, tr, view } = arrg + const { schema, selection, doc } = state + const { $from, $to, $anchor, $cursor, to } = selection + const { start, end, depth } = $from.blockRange($to) + + const cominglevel = attributes.level + const caretSelectionTextBlock = { type: 'text', text: doc?.nodeAt($anchor.pos)?.text || $anchor.nodeBefore?.text || ' ' } + const block = createThisBlockMap($from, depth, caretSelectionTextBlock) + + // TODO: check this statment for comment, (block.edge.start !== -1) + const parentLevel = block.edge.start !== -1 && doc?.nodeAt(block.edge.start)?.content?.content[0]?.attrs.level + + // Create a new heading with the same level + // in this case all content below must cut and wrapp with the new heading + // And the depth should be the same as the sibling Heading + if (cominglevel === parentLevel) { + console.info('[Heading]: create a new heading with same level') + const contents = getRangeBlocks(doc, end, block.parent.end) + const contentWrapper = contents.length === 0 ? [block.paragraph] : contents + + const jsonNode = { + type: 'heading', + content: [ + { + type: 'contentHeading', + content: [block.headingContent], + attrs: { + level: attributes.level + } + }, + { + type: 'contentWrapper', + content: contentWrapper + } + ] + } + + const headingNode = state.schema.nodeFromJSON(jsonNode) + const insertPos = start + 1 + + tr.delete(insertPos, block.edge.end) + tr.insert(tr.mapping.map(block.edge.end), headingNode) + + const newSelection = new TextSelection(tr.doc.resolve(insertPos)) + + tr.setSelection(newSelection) + + return true + } + + // Create a new Heading block as a child of the current Heading block + if (cominglevel > parentLevel) { + console.info('[Heading]: Create a new Heading block as a child of the current Heading block') + + const titleHMap = getHeadingsBlocksMap(doc, block.start, block.parent.end) + const contentWrapper = getRangeBlocks(doc, end, block.parent.end) + const contentWrapperParagraphs = contentWrapper.filter(x => x.type !== 'heading') + const contentWrapperHeadings = contentWrapper.filter(x => x.type === 'heading') + + const jsonNode = { + type: 'heading', + content: [ + { + type: 'contentHeading', + content: [block.headingContent], + attrs: { + level: cominglevel + } + }, + { + type: 'contentWrapper', + content: contentWrapperParagraphs + } + ] + } + const newHeadingNode = state.schema.nodeFromJSON(jsonNode) + + tr.delete(start, block.parent.end) + tr.insert(tr.mapping.map(start), newHeadingNode) + + const newSelection = new TextSelection(tr.doc.resolve(end)) + + tr.setSelection(newSelection) + + let mapHPost = titleHMap + + for (const heading of contentWrapperHeadings) { + const cominglevel = heading.le + + mapHPost = getPrevHeadingList( + tr, + mapHPost.at(0).startBlockPos, + tr.mapping.map(mapHPost.at(0).endBlockPos) + ) + + mapHPost = mapHPost.filter(x => + x.startBlockPos < heading.startBlockPos && + x.startBlockPos >= block.parent.start + ) + + const prevBlockEqual = mapHPost.findLast(x => x.le === cominglevel) + const prevBlockGratherFromFirst = mapHPost.find(x => x.le >= cominglevel) + const prevBlockGratherFromLast = mapHPost.findLast(x => x.le <= cominglevel) + const lastBlock = mapHPost.at(-1) + + let prevBlock = prevBlockEqual || prevBlockGratherFromLast || prevBlockGratherFromFirst + + if (lastBlock.le <= cominglevel) prevBlock = lastBlock + + const shouldNested = prevBlock.le < cominglevel + + const node = state.schema.nodeFromJSON(heading) + + tr.insert(prevBlock.endBlockPos - (shouldNested ? 2 : 0), node) + } + + return true + } + + if (cominglevel < parentLevel) { + console.info('[Heading]: break the current Heading chain, cominglevel is grether than parentLevel') + + return changeHeadingLevelBackward(arrg, attributes, true) + } +} diff --git a/packages/next.js/components/TipTap/styles/_blocks.scss b/packages/next.js/components/TipTap/styles/_blocks.scss new file mode 100644 index 000000000..7a2dca02d --- /dev/null +++ b/packages/next.js/components/TipTap/styles/_blocks.scss @@ -0,0 +1,517 @@ +.pad { + position: relative; + @apply h-full; + + .header { + // height: $Pad_Header__height; + } + + .toolbars { + // height: $Pad_Toolbar__height; + } + + .editor { + background-color: #f8f9fa; + overflow: auto; + // @apply absolute; + + .tipta__editor { + @apply max-w-4xl w-full; + + .heading { + background-color: #fff; + // padding: 0 1rem; + } + + } + } +} + +.toc { + background: rgba(black, 0.1); + border-radius: 0.5rem; + opacity: 0.75; + padding: 0.75rem; + min-width: 200px; + text-align: left; + line-height: 2rem; + + &__list { + list-style: none; + padding: 0; + + &::before { + content: "Table of Contents"; + display: block; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.025rem; + opacity: 0.5; + text-transform: uppercase; + padding-bottom: 20px; + } + } + + &__item { + padding-left: 1.5rem; + + &.active > span { + a { + color: #4285f4; + } + + a::after { + + opacity: 1; + } + } + + a { + font-weight: normal; + position: relative; + @apply text-sm text-gray-400; + + &::after { + content: ""; + display: block; + width: 100%; + border: 1px solid #4285f4; + position: absolute; + opacity: 0; + transition: all 0.1s cubic-bezier(0.39, 0.575, 0.565, 1); + } + } + + a:hover { + opacity: 0.5; + color: #0D0D0D + } + + &--1 { + padding-left: 0!important; + > span > a { + @apply text-base font-medium text-black; + } + } + + } +} + +.drag_box { + position: relative; + + &:hover { + >#dragspan { + opacity: 1; + } + } + + #dragspan { + display: block; + position: absolute; + left: -20px; + top: 0; + opacity: 0; + transition: all .1s cubic-bezier(0.39, 0.575, 0.565, 1); + + &:hover { + opacity: 1; + } + + &::after { + content: ''; + display: block; + flex: 0 0 auto; + position: relative; + width: 1rem; + height: 1rem; + top: 0.3rem; + margin-right: 0.5rem; + cursor: grab; + background-image: url('data:image/svg+xml;charset=UTF-8,'); + background-repeat: no-repeat; + background-size: contain; + background-position: center; + } + } + +} + +.tiptap__toolbar { + @apply border-t border-b; + + .divided { + display: inline-block; + height: 20px; + @apply border-l mx-[10px] my-[6px]; + // float: left; + } + + button { + float: left; + margin: 4px 2px; + // padding: 2px 6px; + background-color: transparent; + outline: none; + cursor: pointer; + height: 28px; + width: 28px; + @apply flex rounded items-center justify-center relative; + + &.btn_settingModal { + @apply ml-auto + } + + svg {} + + &:hover { + background: #eee; + color: black; + } + + &.is-active { + background-color: #e8f0fe; + color: #1a73e8; + + svg { + fill: #1a73e8; + } + } + + &[disabled]:hover { + color: #aaa; + background-color: transparent; + } + } + + .gearModal { + width: 300px; + padding: 8px; + border: 1px solid #ddd; + position: absolute; + right: 14px; + background: #fff; + border-radius: 4px; + position: absolute; + display: none; + top: 38px; + + &.active { + display: block; + } + + .content { + display: flex; + align-items: center; + } + } +} + +.nodeStyle__control, +.nodeStyle__value-container { + min-height: 28px !important; + height: 28px; +} + +.nodeStyle__control { + border-width: 0 !important; + padding: 0 0 0 4px !important; +} + +.nodeStyle__control:hover { + background-color: #eee; +} + +.nodeStyle__input-container, +.nodeStyle__value-container { + margin: 0 !important; + padding: 0 !important; +} + +.nodeStyle__indicator-separator { + margin: 5px 0 !important; + display: none !important; +} + +.nodeStyle__indicator { + padding: 0 !important; +} + +.nodeStyle__menu { + padding: 0 !important; + margin: 0 !important; +} + +.ProseMirror { + text-align: left; + outline: none; + min-height: 800px; + + *.unselectable { + -moz-user-select: -moz-none; + -khtml-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; + } + + >*+* { + margin-top: 0.75em; + } + + /* Placeholder (at the top) */ + .ProseMirror p.is-editor-empty:first-child::before { + color: #adb5bd; + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; + } + + /* Give a remote user a caret */ + .collaboration-cursor__caret { + border-left: 1px solid #0d0d0d; + border-right: 1px solid #0d0d0d; + margin-left: -1px; + margin-right: -1px; + pointer-events: none; + position: relative; + word-break: normal; + } + + /* Render the username above the caret */ + .collaboration-cursor__label { + border-radius: 3px 3px 3px 0; + color: #0d0d0d; + font-size: 12px; + font-style: normal; + font-weight: 600; + left: -1px; + line-height: normal; + padding: 0.1rem 0.3rem; + position: absolute; + top: -1.4em; + user-select: none; + white-space: nowrap; + } + + ul[data-type="taskList"] { + list-style: none; + padding: 0; + + p { + margin: 0; + } + + li { + display: flex; + + >label { + flex: 0 0 auto; + margin-right: 0.5rem; + user-select: none; + } + + >div { + flex: 1 1 auto; + } + } + } + + ul { + list-style-type: revert; + } + + ol { + list-style-type: decimal; + } + + ul, + ol { + padding: 0 1rem; + } + + :is(h1, h2, h3, h4, h5, h6, h7, h8, h9, h10) { + font-weight: bold; + @apply font-medium mt-0 mb-2; + } + + h1 { + @apply text-6xl; + } + + h2 { + @apply text-5xl; + } + + h3 { + @apply text-4xl; + } + + h4 { + @apply text-3xl; + } + + h5 { + @apply text-2xl; + } + + :is(h6, h7, h8, h9, h10) { + @apply text-xl; + } + + code { + background-color: rgba(#616161, 0.1); + color: #616161; + } + + img { + max-width: 100%; + height: auto; + } + + hr { + border: none; + border-top: 2px solid #fff; + margin: 2rem 0; + } + + .is-empty::before { + content: attr(data-placeholder); + float: left; + color: #adb5bd; + pointer-events: none; + height: 0; + } + + pre { + background: #0d0d0d; + border-radius: 0.5rem; + color: #fff; + font-family: "JetBrainsMono", monospace; + padding: 0.75rem 1rem; + + code { + background: none; + color: inherit; + font-size: 0.8rem; + padding: 0; + } + + .hljs-comment, + .hljs-quote { + color: #616161; + } + + .hljs-variable, + .hljs-template-variable, + .hljs-attribute, + .hljs-tag, + .hljs-name, + .hljs-regexp, + .hljs-link, + .hljs-name, + .hljs-selector-id, + .hljs-selector-class { + color: #f98181; + } + + .hljs-number, + .hljs-meta, + .hljs-built_in, + .hljs-builtin-name, + .hljs-literal, + .hljs-type, + .hljs-params { + color: #fbbc88; + } + + .hljs-string, + .hljs-symbol, + .hljs-bullet { + color: #b9f18d; + } + + .hljs-title, + .hljs-section { + color: #faf594; + } + + .hljs-keyword, + .hljs-selector-tag { + color: #70cff8; + } + + .hljs-emphasis { + font-style: italic; + } + + .hljs-strong { + font-weight: 700; + } + } + + table { + border-collapse: collapse; + margin: 0; + overflow: hidden; + table-layout: fixed; + width: 100%; + + td, + th { + border: 2px solid #ced4da; + box-sizing: border-box; + min-width: 1em; + padding: 3px 5px; + position: relative; + vertical-align: top; + + >* { + margin-bottom: 0; + } + } + + th { + background-color: #f1f3f5; + font-weight: bold; + text-align: left; + } + + .selectedCell:after { + background: rgba(200, 200, 255, 0.4); + content: ""; + left: 0; + right: 0; + top: 0; + bottom: 0; + pointer-events: none; + position: absolute; + z-index: 2; + } + + .column-resize-handle { + background-color: #adf; + bottom: -2px; + position: absolute; + right: -2px; + pointer-events: none; + top: 0; + width: 4px; + } + + p { + margin: 0; + } + } + + .tableWrapper { + padding: 1rem 0; + overflow-x: auto; + } + + .resize-cursor { + cursor: ew-resize; + cursor: col-resize; + } +} diff --git a/packages/next.js/components/TipTap/styles/_headings.scss b/packages/next.js/components/TipTap/styles/_headings.scss new file mode 100644 index 000000000..b17608fab --- /dev/null +++ b/packages/next.js/components/TipTap/styles/_headings.scss @@ -0,0 +1,127 @@ +body.indentHeading { + &.h1SectionBreak{ + div.heading { + &[level="1"] { + box-shadow: 8px 0 34px 0 rgb(60 64 67 / 10%); + } + } + } + div.heading { + border-left: 3px solid rgba(#0d0d0d, 0.1); + padding-left: 1rem; + position: relative; + margin-top: 1rem; + + .title::before { + content: attr(level); + position: absolute; + background-color: tomato; + font-size: 12px !important; + height: 20px; + width: 20px; + top: -16px; + left: 33px; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + } + } +} + +body.h1SectionBreak{ + .ProseMirror { + background-color: transparent; + + .heading { + margin-bottom: 1rem; + &[level="1"] { + box-shadow: 0 1px 3px 1px rgb(00 64 67 / 15%); + padding: 1rem; + padding-bottom: 0; + border-bottom: 1px solid #e1e6e7; + + } + } + } +} + +.ProseMirror { + .heading { + &[level="1"] { + padding: 1rem; + margin-top: 0; + border: 1px solid #e1e6e7; + border-bottom: none; + border-top:none; + + &:first-of-type{ + border-top: 1px solid #e1e6e7; + } + + &:last-of-type{ + border-bottom: 1px solid #e1e6e7; + } + } + } + + .title { + z-index: 9; + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + + &.is-empty::before { + position: absolute; + line-height: 0; + } + + &[level="1"].is-empty::before { + font-size: 3.75rem; + } + + &[level="2"].is-empty::before { + font-size: 3rem; + } + + &[level="3"].is-empty::before { + font-size: 2.25rem; + } + + &[level="4"].is-empty::before { + font-size: 1.875rem; + } + + &[level="5"].is-empty::before { + font-size: 1.5rem; + } + + &[level="6"].is-empty::before { + font-size: 1.25rem; + } + + &[level="7"].is-empty::before { + font-size: 1.125rem; + } + + &[level="8"].is-empty::before { + font-size: 1.125rem; + } + + &[level="9"].is-empty::before { + font-size: 1.125rem; + } + + &[level="10"].is-empty::before { + font-size: 1.125rem; + } + + :is(h1, h2, h3, h4, h5, h6, h7, h8, h9, h10) { + margin-left: -33px; + width: 100%; + } + + } +} diff --git a/packages/next.js/components/TipTap/styles/styles.scss b/packages/next.js/components/TipTap/styles/styles.scss new file mode 100644 index 000000000..de940080b --- /dev/null +++ b/packages/next.js/components/TipTap/styles/styles.scss @@ -0,0 +1,374 @@ +@use './blocks'; +@use './headings'; + +$pad-header-height: 64px; +$pad-toolbar-height: 38px; +$crinkle-time: 0.9s; +$crinkle-transition: ease-out; +$crinkle-fold-bg: #bbb; +$crinkle-unfold-bg: #efefef; + +.foldWrapper { + background-color: #f8f9fa; + cursor: pointer; + height: 100%; + position: absolute; + width: 100%; + bottom: 0; + height: calc(100% - 90px); + display: flex; + flex-direction: column; + justify-content: flex-start; + transition: all $crinkle-time $crinkle-transition; + + .fold { + width: calc(100% + 2rem + 6px); + position: relative; + z-index: 2; + left: -19px; + background-color: #f8f9fa; + padding-right: 4px; + height: 100%; + margin-top: -2px; + transition: all $crinkle-time $crinkle-transition; + + &::after { + content: ""; + display: block; + max-height: 50%; + height: 100%; + min-height: 5px; + background-color: #fff; + width: 100%; + transform: skew(44deg); + transition: all $crinkle-time $crinkle-transition; + border-right: 0; + border-left: 0; + } + + &::before { + content: ""; + display: block; + max-height: 50%; + height: 100%; + min-height: 5px; + background-color: $crinkle-fold-bg; + width: 100%; + transform: skew(-44deg); + border-bottom: none; + transition: all $crinkle-time $crinkle-transition; + box-shadow: 0 1px 5px 1px rgb(0 64 67 / 15%); + border-right: 0; + border-left: 0; + } + + &:last-of-type { + + &::after { + border: 1px solid #e1e6e7; + box-shadow: none; + } + } + } +} + +.ProseMirror { + + // buttons in heading + div.heading { + padding-bottom: 1rem; + position: relative; + height: 100%; + + &.opend, + &.opening { + .buttonWrapper { + button.btnFold::before { + content: '\25BC'; + } + } + } + + &.closed, + &.closing { + .buttonWrapper { + button.btnFold::before { + content: '\25B6'; + } + } + } + + &[level="1"] { + > .wrapBlock > .foldWrapper .fold { + width: calc(100% + 6px); + left: 14px; + } + } + + &[level="2"] { + > .wrapBlock > .foldWrapper { + height: calc(100% - 60px); + } + } + + &[level="3"] { + > .wrapBlock > .foldWrapper { + height: calc(100% - 50px); + } + } + + &[level="4"] { + > .wrapBlock > .foldWrapper { + height: calc(100% - 40px); + } + } + + &[level="5"] { + > .wrapBlock > .foldWrapper { + height: calc(100% - 40px); + } + } + + &:is([level="6"], [level="7"], [level="8"], [level="9"], [level="10"]) { + > .wrapBlock > .foldWrapper { + height: calc(100% - 40px); + } + } + + @keyframes foldCrinkle_after { + 0% { + transform: skew(0deg); + } + + 100% { + transform: skew(44deg); + } + } + + @keyframes foldCrinkle_before { + 0% { + transform: skew(0deg); + background-color: $crinkle-unfold-bg; + } + + 100% { + transform: skew(-44deg); + background-color: $crinkle-fold-bg; + } + } + + @keyframes unfoldCrinkle_after { + 0% { + transform: skew(44deg); + } + + 100% { + transform: skew(0deg); + } + } + + @keyframes unfoldCrinkle_before { + 0% { + transform: skew(-44deg); + background-color: $crinkle-fold-bg; + } + + 100% { + transform: skew(0deg); + background-color: $crinkle-unfold-bg; + } + } + + @keyframes unfoldWrapper { + 0% { + background-color: inherit; + } + + 100% { + background-color: transparent; + } + } + + &.closed { + > .wrapBlock > .foldWrapper { + z-index: 2; + } + + &:hover { + + > .wrapBlock > .foldWrapper ~ .contentWrapper { + height: 70px !important; + } + + height: calc(100% - 70px); + + > .wrapBlock > .foldWrapper .fold::before { + background-color: #ccc; + } + } + + } + + &.closing { + > .wrapBlock > .foldWrapper { + z-index: 4; + } + } + + &.closed, + &.closing { + > .wrapBlock > .foldWrapper { + display: flex; + z-index: 4; + + .fold::after { + animation: foldCrinkle_after $crinkle-time $crinkle-transition; + } + + .fold::before { + animation: foldCrinkle_before $crinkle-time $crinkle-transition; + } + } + } + + &.opening { + > .wrapBlock > .foldWrapper { + .fold {} + } + } + + &.opend, + &.opening { + > .wrapBlock > .foldWrapper { + .fold::after { + animation: unfoldCrinkle_after $crinkle-time $crinkle-transition; + } + + .fold::before { + animation: unfoldCrinkle_before $crinkle-time $crinkle-transition; + } + } + } + + &.opend { + > .wrapBlock > .foldWrapper { + display: none; + } + } + } + + .wrapBlock { + height: 100%; + display: flex; + flex-wrap: wrap; + flex-direction: row-reverse; + + .title { + flex: 1; + padding-left: 60px; + margin-left: -100px; + position: relative; + z-index: 1; + min-width: 100%; + + &:hover ~ .buttonWrapper { + visibility: visible; + } + } + + .foldWrapper, + .contentWrapper { + width: 100%; + } + + .buttonWrapper { + display: flex; + width: 40px; + align-items: center; + position: relative; + left: -60px; + z-index: 2; + visibility: hidden; + + &:hover { + visibility: visible; + } + + > a { + margin-left: 14px; + color: #ccc; + + &:hover { + color: #646cff + } + } + + } + + .contentWrapper { + height: auto; + position: relative; + display: flex; + flex-direction: column; + min-height: 40px; + z-index: 0; + padding-bottom: 10px; + // NOTE: only transition duration is work here! other property are in the js file + transition: all $crinkle-time $crinkle-transition; + + .foldWrapper { + transition: all $crinkle-time $crinkle-transition; + + .fold { + transition: height $crinkle-time $crinkle-transition; + } + } + + &.collapsed { + height: 0; + padding: 0; + overflow: hidden; + } + } + } +} + + + +.tiptap__toc{ + .toc__item{ + position: relative; + width: 100%; + margin: 6px 0; + > span{ + display: flex; + align-items: center; + } + + &.closed{ + > span .btnFold{ + &::before{ + content: '\25B6'; + } + } + } + > span .btnFold{ + &::before{ + content: '\25BC'; + display: block; + margin-right: 6px; + width: 16px; + height: 16px; + // border: 2px solid red; + font-size: 0.7rem; + text-align: center; + opacity: 0; + cursor: pointer; + } + } + + &:hover > span > .btnFold::before{ + opacity: 1; + } + } +} diff --git a/packages/next.js/components/icons/Icons.jsx b/packages/next.js/components/icons/Icons.jsx new file mode 100644 index 000000000..1adf00d3c --- /dev/null +++ b/packages/next.js/components/icons/Icons.jsx @@ -0,0 +1,243 @@ +// Source: google Icons for G Suits https://ssl.gstatic.com/docs/common/material_common_sprite413_blue.svg +// https://ssl.gstatic.com/docs/documents/share/images/sprite-24.svg +import React from 'react' + +export const Bold = ({ size = 18, fill = 'black' }) => { + return ( + + + + ) +} + +export const Italic = ({ size = 18, fill = 'black' }) => { + return ( + + + + ) +} + +export const Stric = ({ size = 18, fill = 'black' }) => { + return ( + + + + ) +} + +export const HighlightMarker = ({ size = 18, fill = 'black' }) => { + return ( + + + + ) +} + +export const ClearMark = ({ size = 18, fill = 'black' }) => { + return ( + + + + ) +} + +export const Underline = ({ size = 18, fill = 'black' }) => { + return ( + + + + ) +} + +export const CheckList = ({ size = 18, fill = 'black' }) => { + return ( + + + + ) +} + +export const OrderList = ({ size = 18, fill = 'black' }) => { + return ( + + + + ) +} + +export const BulletList = ({ size = 18, fill = 'black' }) => { + return ( + + + + ) +} + +export const Link = ({ size = 18, fill = 'black' }) => { + return ( + + + + ) +} + +export const UnLink = ({ size = 18, fill = 'black' }) => { + return ( + + + + ) +} + +export const CheckBox = ({ size = 18, fill = 'black' }) => { + return ( + + + + ) +} + +export const Undo = ({ size = 18, fill = 'black' }) => { + return ( + + + + ) +} + +export const Redo = ({ size = 18, fill = 'black' }) => { + return ( + + + + ) +} + +export const Printer = ({ size = 18, fill = 'black' }) => { + return ( + + + + ) +} + +export const Plus = ({ size = 18, fill = 'black' }) => { + return ( + + + + ) +} + +export const AddComment = ({ size = 18, fill = 'black' }) => { + return ( + + + + ) +} + +export const Video = ({ size = 18, fill = 'black' }) => { + return ( + + + ) +} + +export const Image = ({ size = 18, fill = 'black' }) => { + return ( + + + + ) +} + +export const Gear = ({ size = 18, fill = 'black' }) => { + return ( + + + + ) +} + +export const OfflineCloud = ({ size = 18, fill = 'black', className }) => { + return ( + + + + ) +} + +export const OnlineCloud = ({ size = 18, fill = 'black', className }) => { + return ( + + + + ) +} + +export const ArrowLeft = ({ size = 18, fill = 'black' }) => { + return ( + + + + + + ) +} + +export const Doc = ({ size = 18, fill = 'black', className }) => { + return ( + + + + + + + + + + ) +} + +export const DocsPlus = ({ size = 18, fill = 'black', className }) => { + return ( + + + + + + + + + + + + + + + + + + + ) +} + +/** + * Renders an SVG icon "PrivateShare". + * + * @param {object} props - The component props. + * @param {number} [props.size=18] - The width and height of the SVG icon. + * @param {string} [props.fill='black'] - The color of the SVG icon. + * @returns {JSX.Element} The rendered SVG icon. + */ +export const PrivateShare = ({ size = 18, fill = 'black' }) => { + return ( + + + + + + ) +} diff --git a/packages/next.js/components/icons/croper.html b/packages/next.js/components/icons/croper.html new file mode 100644 index 000000000..31fdd8eaa --- /dev/null +++ b/packages/next.js/components/icons/croper.html @@ -0,0 +1,30 @@ + + + + + + + + Document + + + + +
+ + + + diff --git a/packages/next.js/components/icons/logo copy.svg b/packages/next.js/components/icons/logo copy.svg new file mode 100644 index 000000000..f55d441bc --- /dev/null +++ b/packages/next.js/components/icons/logo copy.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/packages/next.js/components/icons/logo.svg b/packages/next.js/components/icons/logo.svg new file mode 100644 index 000000000..ac2e3241c --- /dev/null +++ b/packages/next.js/components/icons/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/next.js/components/icons/material_common_sprite413_blue.svg b/packages/next.js/components/icons/material_common_sprite413_blue.svg new file mode 100644 index 000000000..a7ff41068 --- /dev/null +++ b/packages/next.js/components/icons/material_common_sprite413_blue.svg @@ -0,0 +1,2029 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/next.js/components/icons/sprite-24.svg b/packages/next.js/components/icons/sprite-24.svg new file mode 100644 index 000000000..7c1693cbb --- /dev/null +++ b/packages/next.js/components/icons/sprite-24.svg @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Artboard + Created with Sketch. + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/next.js/db.js b/packages/next.js/db.js new file mode 100644 index 000000000..e787009fd --- /dev/null +++ b/packages/next.js/db.js @@ -0,0 +1,15 @@ +import Dexie from 'dexie' + +export let db + +export const initDB = (dbName, t11) => { + const DexieDB = new Dexie(dbName) + + const newdb = DexieDB.version(1).stores({ + meta: 'headingId, *docId, text, crinkleOpen, level' + }) + + db = newdb.db + + return db +} diff --git a/packages/next.js/next.config.js b/packages/next.js/next.config.js new file mode 100644 index 000000000..40fafe0b2 --- /dev/null +++ b/packages/next.js/next.config.js @@ -0,0 +1,17 @@ +/** @type {import('next').NextConfig} */ + +const runtimeCaching = require('next-pwa/cache') +const isProduction = process.env.NODE_ENV === 'production'; + +const withPWA = require('next-pwa')({ + dest: 'public', + register: true, + skipWaiting: false, + runtimeCaching +}) + + +module.exports = withPWA({ + // config + +}) diff --git a/packages/next.js/package.json b/packages/next.js/package.json new file mode 100644 index 000000000..8e2f935f7 --- /dev/null +++ b/packages/next.js/package.json @@ -0,0 +1,84 @@ +{ + "name": "@docsplus/next.js", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "@hocuspocus/provider": "^2.0.1", + "@tanstack/react-query": "^4.28.0", + "@tiptap/core": "^2.0.1", + "@tiptap/extension-blockquote": "^2.0.0-beta.209", + "@tiptap/extension-bold": "^2.0.0-beta.209", + "@tiptap/extension-bullet-list": "^2.0.0-beta.209", + "@tiptap/extension-code-block": "^2.0.0-beta.209", + "@tiptap/extension-code-block-lowlight": "^2.0.0-beta.209", + "@tiptap/extension-collaboration": "^2.0.1", + "@tiptap/extension-collaboration-cursor": "^2.0.1", + "@tiptap/extension-document": "^2.0.0-beta.209", + "@tiptap/extension-dropcursor": "^2.0.0-beta.209", + "@tiptap/extension-gapcursor": "^2.0.0-beta.209", + "@tiptap/extension-heading": "^2.0.0-beta.209", + "@tiptap/extension-highlight": "^2.0.0-beta.209", + "@tiptap/extension-image": "^2.0.0-beta.209", + "@tiptap/extension-italic": "^2.0.0-beta.209", + "@tiptap/extension-link": "^2.0.0-beta.209", + "@tiptap/extension-list-item": "^2.0.0-beta.209", + "@tiptap/extension-paragraph": "^2.0.0-beta.209", + "@tiptap/extension-placeholder": "^2.0.0-beta.209", + "@tiptap/extension-strike": "^2.0.0-beta.209", + "@tiptap/extension-subscript": "^2.0.0-beta.209", + "@tiptap/extension-superscript": "^2.0.0-beta.209", + "@tiptap/extension-table": "^2.0.0-beta.209", + "@tiptap/extension-table-cell": "^2.0.0-beta.209", + "@tiptap/extension-table-header": "^2.0.0-beta.209", + "@tiptap/extension-table-row": "^2.0.0-beta.209", + "@tiptap/extension-task-item": "^2.0.0-beta.209", + "@tiptap/extension-task-list": "^2.0.0-beta.209", + "@tiptap/extension-text": "^2.0.0-beta.209", + "@tiptap/extension-text-align": "^2.0.0-beta.209", + "@tiptap/extension-typography": "^2.0.0-beta.209", + "@tiptap/extension-underline": "^2.0.0-beta.209", + "@tiptap/pm": "^2.0.1", + "@tiptap/prosemirror-tables": "^1.1.4", + "@tiptap/react": "^2.0.1", + "@tiptap/starter-kit": "^2.0.1", + "dexie": "^3.2.3", + "lowlight": "^2.8.1", + "next": "latest", + "next-pwa": "^5.6.0", + "prosemirror-commands": "^1.5.0", + "prosemirror-dropcursor": "^1.6.1", + "prosemirror-gapcursor": "^1.3.1", + "prosemirror-history": "^1.3.0", + "prosemirror-schema-list": "^1.2.2", + "prosemirror-state": "^1.4.2", + "prosemirror-transform": "^1.7.0", + "pubsub-js": "^1.9.4", + "randomcolor": "^0.6.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-github-btn": "^1.4.0", + "react-select": "^5.7.2", + "short-unique-id": "^4.4.4", + "slugify": "^1.6.6", + "y-indexeddb": "^9.0.9", + "y-prosemirror": "^1.2.0", + "y-webrtc": "^10.2.5", + "y-websocket": "^1.4.5", + "yjs": "^13.5.51" + }, + "devDependencies": { + "@hocuspocus/cli": "^1.0.1", + "@types/node": "17.0.4", + "@types/react": "17.0.38", + "autoprefixer": "^10.4.14", + "postcss": "^8.4.21", + "sass": "^1.60.0", + "tailwindcss": "^3.3.0", + "typescript": "4.5.4" + } +} diff --git a/packages/next.js/pages/404.jsx b/packages/next.js/pages/404.jsx new file mode 100644 index 000000000..b0d9d8e46 --- /dev/null +++ b/packages/next.js/pages/404.jsx @@ -0,0 +1,14 @@ + +import Link from 'next/link' +export default function Custom404() { + return ( +
+
+

Oops! You seem to be lost. 404

+

+ Home +

+
+
+ ) + } diff --git a/packages/next.js/pages/_app.jsx b/packages/next.js/pages/_app.jsx new file mode 100644 index 000000000..474dc21e1 --- /dev/null +++ b/packages/next.js/pages/_app.jsx @@ -0,0 +1,87 @@ +import Head from 'next/head' +import { AppProps } from 'next/app' +import dynamic from 'next/dynamic' +import '../styles/styles.scss' +import '../styles/globals.scss' + +import { + useQuery, + useMutation, + useQueryClient, + QueryClient, + QueryClientProvider, +} from '@tanstack/react-query' + + +const RelpadPrompt = dynamic(() => import(`../components/ReloadPrompt`), { ssr: false }); + + +// Create a client +const queryClient = new QueryClient() + +export default function MyApp({ Component, pageProps }) { + return ( +
+ + + + + + + {/* Chrome, Firefox OS and Opera */} + + + + + + + + + {/* Sets whether a web application runs in full-screen mode. See: https://developer.apple.com/library/safari/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html#//apple_ref/doc/uid/TP40008193-SW3 */} + + {/* Web clip icon for Android homescreen shortcuts. Available since Chrome 31+ for Android.See: https://developers.google.com/chrome/mobile/docs/installtohomescreen */} + + + + + {/* + Disables automatic detection of possible phone numbers in a webpage in Safari on iOS. + See: https://developer.apple.com/library/safari/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html#//apple_ref/doc/uid/TP40008193-SW5 + See: https://developer.apple.com/library/safari/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html#//apple_ref/html/const/format-detection + */} + + + + + + Docs Plus + + + + + + + + + + + + + + + + + + + + + + + + +
+ ) +} diff --git a/packages/next.js/pages/index.jsx b/packages/next.js/pages/index.jsx new file mode 100644 index 000000000..c36de86d2 --- /dev/null +++ b/packages/next.js/pages/index.jsx @@ -0,0 +1,164 @@ +import Head from 'next/head' +import { useState, useRef, useContext, useEffect } from 'react' + +import Link from 'next/link' +import GitHubButton from 'react-github-btn' + +import { DocsPlus } from '../components/icons/Icons' +import Button from '../components/Button' +import slugify from 'slugify' + +import { useRouter } from 'next/router' + +export default function Home({hostname}) { + // const { signInWithOtp, signIn, signOut, user, profile } = useAuth() + const user = null + const profile = null + + const router = useRouter() + const docNameRef = useRef() + const [loadingDoc, setLoadingDoc] = useState(false) + const [error, setError] = useState(null) + const [namespace, setNamespace] = useState(`${hostname}/${profile?.doc_namespace ? profile?.doc_namespace : 'open'}`) + + // slugify the docNameRef + const validateDocName = (docSlug) => { + const slug = slugify(docSlug, { lower: true, strict: true }) + + console.log(slug) + if (docSlug.length <= 0 || docSlug !== slug) { + setError('Only lowercase letters, numbers and dashes are allowed') + + return [false, slug] + } + + return [true, slug] + } + + const enterToPad = (target) => { + setLoadingDoc(true) + let docSlug = docNameRef.current.value + + if (target === 'random') docSlug = (Math.random() + 1).toString(36).substring(2) + + const [error, slug] = validateDocName(docSlug) + + if (!error) { + setLoadingDoc(false) + + return + } + + if (!user) return router.push(`/open/${slug}`) + + // check if profile has a namespace + if (!profile?.doc_namespace) { + return router.push('/auth/username_needed') + } + + router.push(`/${profile?.doc_namespace}/${slug}`) + } + + const handleKeyDown = event => { + if (event.key === 'Enter') { + enterToPad(event) + } + } + + useEffect(() => { + setNamespace(`${location.host}/${profile?.doc_namespace ? profile?.doc_namespace : 'open'}`) + }, []) + + return ( +
+ + Docs Plus + + +
+
+
+
+

docs plus

+

Get everyone on the same page

+
+

+ A free & open source project by Newspeak House

+

+ Enquiries to @docsdotplus or ed@newspeak.house

+

+ Found a bug? Help us out by reporting it.

+

+ Back us on Patreon to help us pay for hosting & development

+

+ Kindly seed funded by Grant for Web & Nesta

+
+
+
+
+
+
+ {user &&
+ + Dashboard +
} + {user?.id && user.email && +
+

Continue As:

+

{profile?.display_name || user?.email}

+
} + + + +
+
+

{namespace}/

+ +
+ {error &&

*Only lowercase letters, numbers and dashes are allowed

} + +
+ +
+ {/* {!user && !user?.id && !user?.email &&
+
+
+
OR
+
+
+
+ + +
+

+ Already have an account? Log in +

+
} */} +
+
+ +
+
+

+ Start exploring our open-source project on GitHub, Join our discussions and help make it even better! +

+
+
+
+
+ Star +
+
+ Discuss +
+
+
+
+ +
+
+
+ ) +} + + +export async function getServerSideProps({req, res}) { + return { + props: {name: "hassan", hostname: req?.headers?.host }, // will be passed to the page component as props + } +} diff --git a/packages/next.js/pages/open/[slug].jsx b/packages/next.js/pages/open/[slug].jsx new file mode 100644 index 000000000..8ef3acb31 --- /dev/null +++ b/packages/next.js/pages/open/[slug].jsx @@ -0,0 +1,187 @@ +import React, { useState, useMemo, useEffect } from 'react' +import { useRouter } from 'next/router' +import Head from 'next/head' + +import { useEditor, EditorContent } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import * as Y from 'yjs' +import { IndexeddbPersistence } from 'y-indexeddb' +import { HocuspocusProvider } from '@hocuspocus/provider' +import editorConfig from '../../components/TipTap/TipTap' +import { useQuery } from '@tanstack/react-query' + +import Toolbar from '../../components/TipTap/Toolbar' +import PadTitle from '../../components/TipTap/PadTitle' +import TableOfContents from '../../components/TipTap/TableOfContents' +import { db, initDB } from '../../db' + +const useCustomeHook = (documentSlug) => { + // NOTE: This is a hack to get the correct URL in the build time + const url = `${process.env.NEXT_PUBLIC_RESTAPI_URL}/documents/${documentSlug}` + + const { isLoading, error, data, isSuccess } = useQuery({ + queryKey: ['getDocumentMetadataByDocName'], + queryFn: () => { + return fetch(url) + .then(res => res.json()) + } + }) + + return { isLoading, error, data, isSuccess } +} + +const OpenDocuments = ({ docTitle, docSlug}) => { + const [loading, setLoading] = useState(false) + const [ydoc, setYdoc] = useState(null) + const [provider, setProvider] = useState(null) + const [loadedData, setLoadedData] = useState(false) + const { isLoading, error, data, isSuccess } = useCustomeHook(docSlug) + const [documentTitle, setDocumentTitle] = useState(docTitle) + const [docId, setDocId] = useState(null) + + useEffect(() => { + // Use the data returned by useCustomHook in useEffect + if (data?.data?.documentId) { + const { documentId, isPrivate } = data?.data + + setDocumentTitle(data?.data.title) + setDocId(`${isPrivate ? 'private' : 'public'}.${documentId}`) + localStorage.setItem('docId', documentId) + localStorage.setItem('padName', `${isPrivate ? 'private' : 'public'}.${documentId}`) + localStorage.setItem('slug', docSlug) + localStorage.setItem('title', data?.data.title) + + initDB(`meta.${documentId}`, `${isPrivate ? 'private' : 'public'}.${documentId}`) + + db.meta.where({ docId: documentId }).toArray().then((data) => { + localStorage.setItem('headingMap', JSON.stringify(data)) + }) + } + }, [data]) + + useEffect(() => { + if (docId) { + const ydoc = new Y.Doc() + + setYdoc(ydoc) + + const colabProvider = new HocuspocusProvider({ + url: `${process.env.NEXT_PUBLIC_PROVIDER_URL}`, + name: docId, + document: ydoc, + onStatus: (data) => { + console.log('onStatus', data) + }, + onSynced: (data) => { + console.log('onSynced', data) + // console.log(`content loaded from Server, pad name: ${ docId }`, provider.isSynced) + if (data?.state) setLoading(false) + }, + documentUpdateHandler: (update) => { + console.log('documentUpdateHandler', update) + }, + onDisconnect: (data) => { + // console.log("onDisconnect", data) + }, + onMessage: (data) => { + // console.log('onMessage', data) + } + }) + + setProvider(colabProvider) + + console.log(colabProvider) + + // Store the Y document in the browser + const indexDbProvider = new IndexeddbPersistence(docId, colabProvider.document) + + indexDbProvider.on('synced', () => { + if (!loadedData) return + // console.log(`content loaded from indexdb, pad name: ${ docId }`) + setLoadedData(true) + }) + } + }, [docId]) + + const editor = useEditor(editorConfig({ padName: docId, provider, ydoc }), [provider]) + + // useEffect(() => { + // if (!loading && editor?.isEmpty) { + // console.log('editor is empty', editor?.isEmpty) + + // editor?.chain().focus().insertContentAt(2, '' + + // '

­

' + + // '

' + + // '

' + + // '

' + + // '

', + // { + // updateSelection: false + // }).setTextSelection(0).run() + // } + // }, [loading, editor]) + + const scrollHeadingSelection = (event) => { + const scrollTop = event.currentTarget.scrollTop + const toc = document.querySelector('.toc__list') + const tocLis = [...toc.querySelectorAll('.toc__item')] + const closest = tocLis + .map(li => { + li.classList.remove('active') + + return li + }) + .filter(li => { + const thisOffsetTop = +li.getAttribute('data-offsettop') - 220 + // const nextSiblingOffsetTop = +li.querySelector('div > .toc__item')?.getAttribute('data-offsettop') - 220 + + return thisOffsetTop <= scrollTop // && nextSiblingOffsetTop >= scrollTop + }) + + // if (event.target.offsetHeight === (event.target.scrollHeight - scrollTop) - 0.5) { + // tocLis.at(-1)?.classList.add('active') + // tocLis.at(-1)?.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' }) + + // return + // } + + // console.log(closest) + closest.at(-1)?.classList.add('active') + closest.at(-1)?.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' }) + } + + return ( + <> + + {documentTitle} + + +
+
+ {docSlug && } +
+
+ { editor ? : 'Loading...'} +
+
+ {loading ? 'loading...' : editor ? : 'Loading...'} +
+ {loading ? 'loading...' : editor ? : 'Loading...'} +
+
+
+ + ) +} + +export default OpenDocuments + + +export async function getServerSideProps(context) { + const documentSlug = context.query.slug + const res = await fetch(`${process.env.NEXT_PUBLIC_RESTAPI_URL}/documents/${documentSlug}`) + const data = await res.json() + return { + props: {name: "hassan", docTitle: data.data.title, docSlug: documentSlug}, // will be passed to the page component as props + } +} diff --git a/packages/next.js/pages/open/_error.jsx b/packages/next.js/pages/open/_error.jsx new file mode 100644 index 000000000..92f524e7b --- /dev/null +++ b/packages/next.js/pages/open/_error.jsx @@ -0,0 +1,16 @@ +function Error({ statusCode }) { + return ( +

+ {statusCode + ? `An error ${statusCode} occurred on server` + : 'An error occurred on client'} +

+ ) + } + + Error.getInitialProps = ({ res, err }) => { + const statusCode = res ? res.statusCode : err ? err.statusCode : 404 + return { statusCode } + } + + export default Error diff --git a/packages/next.js/postcss.config.js b/packages/next.js/postcss.config.js new file mode 100644 index 000000000..33ad091d2 --- /dev/null +++ b/packages/next.js/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/packages/next.js/public/favicon.ico b/packages/next.js/public/favicon.ico new file mode 100644 index 000000000..b0b10bd81 Binary files /dev/null and b/packages/next.js/public/favicon.ico differ diff --git a/packages/next.js/public/icons/android-chrome-192x192.png b/packages/next.js/public/icons/android-chrome-192x192.png new file mode 100644 index 000000000..e9fd2ba8d Binary files /dev/null and b/packages/next.js/public/icons/android-chrome-192x192.png differ diff --git a/packages/next.js/public/icons/android-chrome-512x512.png b/packages/next.js/public/icons/android-chrome-512x512.png new file mode 100644 index 000000000..884b1034e Binary files /dev/null and b/packages/next.js/public/icons/android-chrome-512x512.png differ diff --git a/packages/next.js/public/icons/apple-touch-icon.png b/packages/next.js/public/icons/apple-touch-icon.png new file mode 100644 index 000000000..691349dca Binary files /dev/null and b/packages/next.js/public/icons/apple-touch-icon.png differ diff --git a/packages/next.js/public/icons/favicon-16x16.png b/packages/next.js/public/icons/favicon-16x16.png new file mode 100644 index 000000000..3b788ca05 Binary files /dev/null and b/packages/next.js/public/icons/favicon-16x16.png differ diff --git a/packages/next.js/public/icons/favicon-32x32.png b/packages/next.js/public/icons/favicon-32x32.png new file mode 100644 index 000000000..39738e56c Binary files /dev/null and b/packages/next.js/public/icons/favicon-32x32.png differ diff --git a/packages/next.js/public/icons/favicon.ico b/packages/next.js/public/icons/favicon.ico new file mode 100644 index 000000000..b0b10bd81 Binary files /dev/null and b/packages/next.js/public/icons/favicon.ico differ diff --git a/packages/next.js/public/icons/logo.svg b/packages/next.js/public/icons/logo.svg new file mode 100644 index 000000000..844057320 --- /dev/null +++ b/packages/next.js/public/icons/logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/packages/next.js/public/icons/maskable_icon.png b/packages/next.js/public/icons/maskable_icon.png new file mode 100644 index 000000000..ff16bc55d Binary files /dev/null and b/packages/next.js/public/icons/maskable_icon.png differ diff --git a/packages/next.js/public/icons/maskable_icon_x192.png b/packages/next.js/public/icons/maskable_icon_x192.png new file mode 100644 index 000000000..88af3640b Binary files /dev/null and b/packages/next.js/public/icons/maskable_icon_x192.png differ diff --git a/packages/next.js/public/icons/maskable_icon_x384.png b/packages/next.js/public/icons/maskable_icon_x384.png new file mode 100644 index 000000000..0458a6c7e Binary files /dev/null and b/packages/next.js/public/icons/maskable_icon_x384.png differ diff --git a/packages/next.js/public/icons/maskable_icon_x512.png b/packages/next.js/public/icons/maskable_icon_x512.png new file mode 100644 index 000000000..bb8cdfcbd Binary files /dev/null and b/packages/next.js/public/icons/maskable_icon_x512.png differ diff --git a/packages/next.js/public/icons/screenshot1.png b/packages/next.js/public/icons/screenshot1.png new file mode 100644 index 000000000..38cd842e9 Binary files /dev/null and b/packages/next.js/public/icons/screenshot1.png differ diff --git a/packages/next.js/public/manifest.json b/packages/next.js/public/manifest.json new file mode 100644 index 000000000..6e82e463d --- /dev/null +++ b/packages/next.js/public/manifest.json @@ -0,0 +1,58 @@ +{ + "name": "Docs.plus", + "short_name": "Docs.plus", + "background_color": "#3367D6", + "display": "standalone", + "theme_color": "#3367D6", + "description": "A real-time collaborative", + "start_url": "/", + "scope": "/", + "icons": [ + { + "src": "icons/favicon.ico", + "type": "image/x-icon", + "sizes": "any" + }, + { + "src": "icons/logo.svg", + "type": "image/svg+xml", + "sizes": "any" + }, + { + "src": "icons/android-chrome-192x192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "icons/android-chrome-512x512.png", + "type": "image/png", + "sizes": "512x512" + }, + { + "src": "icons/maskable_icon_x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/maskable_icon_x384.png", + "sizes": "384x384", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/maskable_icon_x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "screenshots": [ + { + "src": "icons/screenshot1.png", + "type": "image/png", + "sizes": "1244x1370" + } + ], + "splash_pages": null +} diff --git a/packages/next.js/styles/_blocks.scss b/packages/next.js/styles/_blocks.scss new file mode 100644 index 000000000..7a2dca02d --- /dev/null +++ b/packages/next.js/styles/_blocks.scss @@ -0,0 +1,517 @@ +.pad { + position: relative; + @apply h-full; + + .header { + // height: $Pad_Header__height; + } + + .toolbars { + // height: $Pad_Toolbar__height; + } + + .editor { + background-color: #f8f9fa; + overflow: auto; + // @apply absolute; + + .tipta__editor { + @apply max-w-4xl w-full; + + .heading { + background-color: #fff; + // padding: 0 1rem; + } + + } + } +} + +.toc { + background: rgba(black, 0.1); + border-radius: 0.5rem; + opacity: 0.75; + padding: 0.75rem; + min-width: 200px; + text-align: left; + line-height: 2rem; + + &__list { + list-style: none; + padding: 0; + + &::before { + content: "Table of Contents"; + display: block; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.025rem; + opacity: 0.5; + text-transform: uppercase; + padding-bottom: 20px; + } + } + + &__item { + padding-left: 1.5rem; + + &.active > span { + a { + color: #4285f4; + } + + a::after { + + opacity: 1; + } + } + + a { + font-weight: normal; + position: relative; + @apply text-sm text-gray-400; + + &::after { + content: ""; + display: block; + width: 100%; + border: 1px solid #4285f4; + position: absolute; + opacity: 0; + transition: all 0.1s cubic-bezier(0.39, 0.575, 0.565, 1); + } + } + + a:hover { + opacity: 0.5; + color: #0D0D0D + } + + &--1 { + padding-left: 0!important; + > span > a { + @apply text-base font-medium text-black; + } + } + + } +} + +.drag_box { + position: relative; + + &:hover { + >#dragspan { + opacity: 1; + } + } + + #dragspan { + display: block; + position: absolute; + left: -20px; + top: 0; + opacity: 0; + transition: all .1s cubic-bezier(0.39, 0.575, 0.565, 1); + + &:hover { + opacity: 1; + } + + &::after { + content: ''; + display: block; + flex: 0 0 auto; + position: relative; + width: 1rem; + height: 1rem; + top: 0.3rem; + margin-right: 0.5rem; + cursor: grab; + background-image: url('data:image/svg+xml;charset=UTF-8,'); + background-repeat: no-repeat; + background-size: contain; + background-position: center; + } + } + +} + +.tiptap__toolbar { + @apply border-t border-b; + + .divided { + display: inline-block; + height: 20px; + @apply border-l mx-[10px] my-[6px]; + // float: left; + } + + button { + float: left; + margin: 4px 2px; + // padding: 2px 6px; + background-color: transparent; + outline: none; + cursor: pointer; + height: 28px; + width: 28px; + @apply flex rounded items-center justify-center relative; + + &.btn_settingModal { + @apply ml-auto + } + + svg {} + + &:hover { + background: #eee; + color: black; + } + + &.is-active { + background-color: #e8f0fe; + color: #1a73e8; + + svg { + fill: #1a73e8; + } + } + + &[disabled]:hover { + color: #aaa; + background-color: transparent; + } + } + + .gearModal { + width: 300px; + padding: 8px; + border: 1px solid #ddd; + position: absolute; + right: 14px; + background: #fff; + border-radius: 4px; + position: absolute; + display: none; + top: 38px; + + &.active { + display: block; + } + + .content { + display: flex; + align-items: center; + } + } +} + +.nodeStyle__control, +.nodeStyle__value-container { + min-height: 28px !important; + height: 28px; +} + +.nodeStyle__control { + border-width: 0 !important; + padding: 0 0 0 4px !important; +} + +.nodeStyle__control:hover { + background-color: #eee; +} + +.nodeStyle__input-container, +.nodeStyle__value-container { + margin: 0 !important; + padding: 0 !important; +} + +.nodeStyle__indicator-separator { + margin: 5px 0 !important; + display: none !important; +} + +.nodeStyle__indicator { + padding: 0 !important; +} + +.nodeStyle__menu { + padding: 0 !important; + margin: 0 !important; +} + +.ProseMirror { + text-align: left; + outline: none; + min-height: 800px; + + *.unselectable { + -moz-user-select: -moz-none; + -khtml-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; + } + + >*+* { + margin-top: 0.75em; + } + + /* Placeholder (at the top) */ + .ProseMirror p.is-editor-empty:first-child::before { + color: #adb5bd; + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; + } + + /* Give a remote user a caret */ + .collaboration-cursor__caret { + border-left: 1px solid #0d0d0d; + border-right: 1px solid #0d0d0d; + margin-left: -1px; + margin-right: -1px; + pointer-events: none; + position: relative; + word-break: normal; + } + + /* Render the username above the caret */ + .collaboration-cursor__label { + border-radius: 3px 3px 3px 0; + color: #0d0d0d; + font-size: 12px; + font-style: normal; + font-weight: 600; + left: -1px; + line-height: normal; + padding: 0.1rem 0.3rem; + position: absolute; + top: -1.4em; + user-select: none; + white-space: nowrap; + } + + ul[data-type="taskList"] { + list-style: none; + padding: 0; + + p { + margin: 0; + } + + li { + display: flex; + + >label { + flex: 0 0 auto; + margin-right: 0.5rem; + user-select: none; + } + + >div { + flex: 1 1 auto; + } + } + } + + ul { + list-style-type: revert; + } + + ol { + list-style-type: decimal; + } + + ul, + ol { + padding: 0 1rem; + } + + :is(h1, h2, h3, h4, h5, h6, h7, h8, h9, h10) { + font-weight: bold; + @apply font-medium mt-0 mb-2; + } + + h1 { + @apply text-6xl; + } + + h2 { + @apply text-5xl; + } + + h3 { + @apply text-4xl; + } + + h4 { + @apply text-3xl; + } + + h5 { + @apply text-2xl; + } + + :is(h6, h7, h8, h9, h10) { + @apply text-xl; + } + + code { + background-color: rgba(#616161, 0.1); + color: #616161; + } + + img { + max-width: 100%; + height: auto; + } + + hr { + border: none; + border-top: 2px solid #fff; + margin: 2rem 0; + } + + .is-empty::before { + content: attr(data-placeholder); + float: left; + color: #adb5bd; + pointer-events: none; + height: 0; + } + + pre { + background: #0d0d0d; + border-radius: 0.5rem; + color: #fff; + font-family: "JetBrainsMono", monospace; + padding: 0.75rem 1rem; + + code { + background: none; + color: inherit; + font-size: 0.8rem; + padding: 0; + } + + .hljs-comment, + .hljs-quote { + color: #616161; + } + + .hljs-variable, + .hljs-template-variable, + .hljs-attribute, + .hljs-tag, + .hljs-name, + .hljs-regexp, + .hljs-link, + .hljs-name, + .hljs-selector-id, + .hljs-selector-class { + color: #f98181; + } + + .hljs-number, + .hljs-meta, + .hljs-built_in, + .hljs-builtin-name, + .hljs-literal, + .hljs-type, + .hljs-params { + color: #fbbc88; + } + + .hljs-string, + .hljs-symbol, + .hljs-bullet { + color: #b9f18d; + } + + .hljs-title, + .hljs-section { + color: #faf594; + } + + .hljs-keyword, + .hljs-selector-tag { + color: #70cff8; + } + + .hljs-emphasis { + font-style: italic; + } + + .hljs-strong { + font-weight: 700; + } + } + + table { + border-collapse: collapse; + margin: 0; + overflow: hidden; + table-layout: fixed; + width: 100%; + + td, + th { + border: 2px solid #ced4da; + box-sizing: border-box; + min-width: 1em; + padding: 3px 5px; + position: relative; + vertical-align: top; + + >* { + margin-bottom: 0; + } + } + + th { + background-color: #f1f3f5; + font-weight: bold; + text-align: left; + } + + .selectedCell:after { + background: rgba(200, 200, 255, 0.4); + content: ""; + left: 0; + right: 0; + top: 0; + bottom: 0; + pointer-events: none; + position: absolute; + z-index: 2; + } + + .column-resize-handle { + background-color: #adf; + bottom: -2px; + position: absolute; + right: -2px; + pointer-events: none; + top: 0; + width: 4px; + } + + p { + margin: 0; + } + } + + .tableWrapper { + padding: 1rem 0; + overflow-x: auto; + } + + .resize-cursor { + cursor: ew-resize; + cursor: col-resize; + } +} diff --git a/packages/next.js/styles/_headings.scss b/packages/next.js/styles/_headings.scss new file mode 100644 index 000000000..b17608fab --- /dev/null +++ b/packages/next.js/styles/_headings.scss @@ -0,0 +1,127 @@ +body.indentHeading { + &.h1SectionBreak{ + div.heading { + &[level="1"] { + box-shadow: 8px 0 34px 0 rgb(60 64 67 / 10%); + } + } + } + div.heading { + border-left: 3px solid rgba(#0d0d0d, 0.1); + padding-left: 1rem; + position: relative; + margin-top: 1rem; + + .title::before { + content: attr(level); + position: absolute; + background-color: tomato; + font-size: 12px !important; + height: 20px; + width: 20px; + top: -16px; + left: 33px; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + } + } +} + +body.h1SectionBreak{ + .ProseMirror { + background-color: transparent; + + .heading { + margin-bottom: 1rem; + &[level="1"] { + box-shadow: 0 1px 3px 1px rgb(00 64 67 / 15%); + padding: 1rem; + padding-bottom: 0; + border-bottom: 1px solid #e1e6e7; + + } + } + } +} + +.ProseMirror { + .heading { + &[level="1"] { + padding: 1rem; + margin-top: 0; + border: 1px solid #e1e6e7; + border-bottom: none; + border-top:none; + + &:first-of-type{ + border-top: 1px solid #e1e6e7; + } + + &:last-of-type{ + border-bottom: 1px solid #e1e6e7; + } + } + } + + .title { + z-index: 9; + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + + &.is-empty::before { + position: absolute; + line-height: 0; + } + + &[level="1"].is-empty::before { + font-size: 3.75rem; + } + + &[level="2"].is-empty::before { + font-size: 3rem; + } + + &[level="3"].is-empty::before { + font-size: 2.25rem; + } + + &[level="4"].is-empty::before { + font-size: 1.875rem; + } + + &[level="5"].is-empty::before { + font-size: 1.5rem; + } + + &[level="6"].is-empty::before { + font-size: 1.25rem; + } + + &[level="7"].is-empty::before { + font-size: 1.125rem; + } + + &[level="8"].is-empty::before { + font-size: 1.125rem; + } + + &[level="9"].is-empty::before { + font-size: 1.125rem; + } + + &[level="10"].is-empty::before { + font-size: 1.125rem; + } + + :is(h1, h2, h3, h4, h5, h6, h7, h8, h9, h10) { + margin-left: -33px; + width: 100%; + } + + } +} diff --git a/packages/next.js/styles/globals.scss b/packages/next.js/styles/globals.scss new file mode 100644 index 000000000..7ff0a2706 --- /dev/null +++ b/packages/next.js/styles/globals.scss @@ -0,0 +1,101 @@ +.dark { + --color-bg-primary: #2d3748; + --color-bg-secondary: #283141; + --color-text-primary: #f7fafc; + --color-text-secondary: #e2e8f0; + --color-text-accent: #81e6d9; +} + +.light { + --color-bg-primary: #ffffff; + --color-bg-secondary: #edf2f7; + --color-text-primary: #2d3748; + --color-text-secondary: #4a5568; + --color-text-accent: #2b6cb0; +} + +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* #root { + @apply h-full +} */ + +* { + @apply box-border +} + +.dark * { + @apply bg-slate-800 text-slate-400; + +} + +.dark { + + h1, + h2, + h3, + h4, + h5, + h4 { + @apply text-white font-medium tracking-tight + } + + border-color: #283141; + + svg { + color: #fff; + fill: #fff; + } + + p { + @apply text-sm text-slate-400 + } + + button { + @apply text-white + } +} + + +.ProseMirror { + @apply h-full +} + +body, +html { + @apply p-0 m-0 max-h-full min-h-full h-full +} + +#root, #__next { + @apply h-full +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} + +a:hover { + color: #535bf2; +} + + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +@media (prefers-color-scheme: light) { + /* :root { + color: #213547; + background-color: #ffffff; + } + + a:hover { + color: #747bff; + } */ + +} diff --git a/packages/next.js/styles/styles.scss b/packages/next.js/styles/styles.scss new file mode 100644 index 000000000..de940080b --- /dev/null +++ b/packages/next.js/styles/styles.scss @@ -0,0 +1,374 @@ +@use './blocks'; +@use './headings'; + +$pad-header-height: 64px; +$pad-toolbar-height: 38px; +$crinkle-time: 0.9s; +$crinkle-transition: ease-out; +$crinkle-fold-bg: #bbb; +$crinkle-unfold-bg: #efefef; + +.foldWrapper { + background-color: #f8f9fa; + cursor: pointer; + height: 100%; + position: absolute; + width: 100%; + bottom: 0; + height: calc(100% - 90px); + display: flex; + flex-direction: column; + justify-content: flex-start; + transition: all $crinkle-time $crinkle-transition; + + .fold { + width: calc(100% + 2rem + 6px); + position: relative; + z-index: 2; + left: -19px; + background-color: #f8f9fa; + padding-right: 4px; + height: 100%; + margin-top: -2px; + transition: all $crinkle-time $crinkle-transition; + + &::after { + content: ""; + display: block; + max-height: 50%; + height: 100%; + min-height: 5px; + background-color: #fff; + width: 100%; + transform: skew(44deg); + transition: all $crinkle-time $crinkle-transition; + border-right: 0; + border-left: 0; + } + + &::before { + content: ""; + display: block; + max-height: 50%; + height: 100%; + min-height: 5px; + background-color: $crinkle-fold-bg; + width: 100%; + transform: skew(-44deg); + border-bottom: none; + transition: all $crinkle-time $crinkle-transition; + box-shadow: 0 1px 5px 1px rgb(0 64 67 / 15%); + border-right: 0; + border-left: 0; + } + + &:last-of-type { + + &::after { + border: 1px solid #e1e6e7; + box-shadow: none; + } + } + } +} + +.ProseMirror { + + // buttons in heading + div.heading { + padding-bottom: 1rem; + position: relative; + height: 100%; + + &.opend, + &.opening { + .buttonWrapper { + button.btnFold::before { + content: '\25BC'; + } + } + } + + &.closed, + &.closing { + .buttonWrapper { + button.btnFold::before { + content: '\25B6'; + } + } + } + + &[level="1"] { + > .wrapBlock > .foldWrapper .fold { + width: calc(100% + 6px); + left: 14px; + } + } + + &[level="2"] { + > .wrapBlock > .foldWrapper { + height: calc(100% - 60px); + } + } + + &[level="3"] { + > .wrapBlock > .foldWrapper { + height: calc(100% - 50px); + } + } + + &[level="4"] { + > .wrapBlock > .foldWrapper { + height: calc(100% - 40px); + } + } + + &[level="5"] { + > .wrapBlock > .foldWrapper { + height: calc(100% - 40px); + } + } + + &:is([level="6"], [level="7"], [level="8"], [level="9"], [level="10"]) { + > .wrapBlock > .foldWrapper { + height: calc(100% - 40px); + } + } + + @keyframes foldCrinkle_after { + 0% { + transform: skew(0deg); + } + + 100% { + transform: skew(44deg); + } + } + + @keyframes foldCrinkle_before { + 0% { + transform: skew(0deg); + background-color: $crinkle-unfold-bg; + } + + 100% { + transform: skew(-44deg); + background-color: $crinkle-fold-bg; + } + } + + @keyframes unfoldCrinkle_after { + 0% { + transform: skew(44deg); + } + + 100% { + transform: skew(0deg); + } + } + + @keyframes unfoldCrinkle_before { + 0% { + transform: skew(-44deg); + background-color: $crinkle-fold-bg; + } + + 100% { + transform: skew(0deg); + background-color: $crinkle-unfold-bg; + } + } + + @keyframes unfoldWrapper { + 0% { + background-color: inherit; + } + + 100% { + background-color: transparent; + } + } + + &.closed { + > .wrapBlock > .foldWrapper { + z-index: 2; + } + + &:hover { + + > .wrapBlock > .foldWrapper ~ .contentWrapper { + height: 70px !important; + } + + height: calc(100% - 70px); + + > .wrapBlock > .foldWrapper .fold::before { + background-color: #ccc; + } + } + + } + + &.closing { + > .wrapBlock > .foldWrapper { + z-index: 4; + } + } + + &.closed, + &.closing { + > .wrapBlock > .foldWrapper { + display: flex; + z-index: 4; + + .fold::after { + animation: foldCrinkle_after $crinkle-time $crinkle-transition; + } + + .fold::before { + animation: foldCrinkle_before $crinkle-time $crinkle-transition; + } + } + } + + &.opening { + > .wrapBlock > .foldWrapper { + .fold {} + } + } + + &.opend, + &.opening { + > .wrapBlock > .foldWrapper { + .fold::after { + animation: unfoldCrinkle_after $crinkle-time $crinkle-transition; + } + + .fold::before { + animation: unfoldCrinkle_before $crinkle-time $crinkle-transition; + } + } + } + + &.opend { + > .wrapBlock > .foldWrapper { + display: none; + } + } + } + + .wrapBlock { + height: 100%; + display: flex; + flex-wrap: wrap; + flex-direction: row-reverse; + + .title { + flex: 1; + padding-left: 60px; + margin-left: -100px; + position: relative; + z-index: 1; + min-width: 100%; + + &:hover ~ .buttonWrapper { + visibility: visible; + } + } + + .foldWrapper, + .contentWrapper { + width: 100%; + } + + .buttonWrapper { + display: flex; + width: 40px; + align-items: center; + position: relative; + left: -60px; + z-index: 2; + visibility: hidden; + + &:hover { + visibility: visible; + } + + > a { + margin-left: 14px; + color: #ccc; + + &:hover { + color: #646cff + } + } + + } + + .contentWrapper { + height: auto; + position: relative; + display: flex; + flex-direction: column; + min-height: 40px; + z-index: 0; + padding-bottom: 10px; + // NOTE: only transition duration is work here! other property are in the js file + transition: all $crinkle-time $crinkle-transition; + + .foldWrapper { + transition: all $crinkle-time $crinkle-transition; + + .fold { + transition: height $crinkle-time $crinkle-transition; + } + } + + &.collapsed { + height: 0; + padding: 0; + overflow: hidden; + } + } + } +} + + + +.tiptap__toc{ + .toc__item{ + position: relative; + width: 100%; + margin: 6px 0; + > span{ + display: flex; + align-items: center; + } + + &.closed{ + > span .btnFold{ + &::before{ + content: '\25B6'; + } + } + } + > span .btnFold{ + &::before{ + content: '\25BC'; + display: block; + margin-right: 6px; + width: 16px; + height: 16px; + // border: 2px solid red; + font-size: 0.7rem; + text-align: center; + opacity: 0; + cursor: pointer; + } + } + + &:hover > span > .btnFold::before{ + opacity: 1; + } + } +} diff --git a/packages/next.js/tailwind.config.js b/packages/next.js/tailwind.config.js new file mode 100644 index 000000000..cf140077c --- /dev/null +++ b/packages/next.js/tailwind.config.js @@ -0,0 +1,29 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./app/**/*.{js,ts,jsx,tsx}", + "./pages/**/*.{js,ts,jsx,tsx}", + "./components/**/*.{js,ts,jsx,tsx}", + + // Or if using `src` directory: + "./src/**/*.{js,ts,jsx,tsx}", + ], + plugins: [], + darkMode: 'class', + theme: { + extend: { + spacing: { + }, + backgroundColor: { + primary: 'var(--color-bg-primary)', + secondary: 'var(--color-bg-secondary)', + }, + textColor: { + accent: 'var(--color-text-accent)', + primary: 'var(--color-text-primary)', + secondary: 'var(--color-text-secondary)', + }, + }, + }, +} + diff --git a/packages/next.js/tsconfig.json b/packages/next.js/tsconfig.json new file mode 100644 index 000000000..4627fc3bc --- /dev/null +++ b/packages/next.js/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + "pages/index.jsx", + "pages/_app.jsx", + "pages/api/hello.js" + ], + "exclude": [ + "node_modules" + ] +}