mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-01 03:29:21 +02:00
Compare commits
52 Commits
3.3.8-andr
...
feat/make-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f35d36a2a | ||
|
|
873ba49e32 | ||
|
|
9f88eaae77 | ||
|
|
704ad578fa | ||
|
|
cf608977e1 | ||
|
|
bdd78df452 | ||
|
|
cec05b6dfc | ||
|
|
3e4779e6bf | ||
|
|
9ebcbb7bd8 | ||
|
|
f3f8020929 | ||
|
|
43186d28e7 | ||
|
|
d9d6a40276 | ||
|
|
3d00502341 | ||
|
|
7975dfcc78 | ||
|
|
907406e7b2 | ||
|
|
8b03f96a3f | ||
|
|
5183b33f9d | ||
|
|
bfddf288eb | ||
|
|
28ada2b032 | ||
|
|
33c3bf0d69 | ||
|
|
2fc3cc3c76 | ||
|
|
69a3fa4d90 | ||
|
|
bd4f130ef8 | ||
|
|
28b563b95b | ||
|
|
71952a354e | ||
|
|
41ad8e0988 | ||
|
|
3e87e2ea5a | ||
|
|
a6541b9731 | ||
|
|
cc584461ad | ||
|
|
05ea085cdc | ||
|
|
015453a40b | ||
|
|
dad92a186c | ||
|
|
7e5e19e72c | ||
|
|
8e1484a029 | ||
|
|
b2645fc4d9 | ||
|
|
58f060ae59 | ||
|
|
d9ea44f92a | ||
|
|
1bf86386fa | ||
|
|
6fc328c4dd | ||
|
|
a60a1aa406 | ||
|
|
7bd1931195 | ||
|
|
c59091f946 | ||
|
|
6a017c237d | ||
|
|
27c641536a | ||
|
|
8aa0a539f8 | ||
|
|
61549e8e06 | ||
|
|
317be2c8da | ||
|
|
c47c410446 | ||
|
|
3a5558eb16 | ||
|
|
f8171ddc3f | ||
|
|
55fb3d5332 | ||
|
|
3ebcd36ca9 |
4
apps/desktop/package-lock.json
generated
4
apps/desktop/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@notesnook/desktop",
|
||||
"version": "3.3.4",
|
||||
"version": "3.3.6-beta.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@notesnook/desktop",
|
||||
"version": "3.3.4",
|
||||
"version": "3.3.6-beta.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@notesnook/desktop",
|
||||
"productName": "Notesnook",
|
||||
"description": "Your private note taking space",
|
||||
"version": "3.3.4",
|
||||
"version": "3.3.6-beta.0",
|
||||
"appAppleId": "1544027013",
|
||||
"private": true,
|
||||
"main": "./dist/cjs/index.js",
|
||||
|
||||
@@ -124,7 +124,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
multiDexEnabled true
|
||||
versionCode 3077
|
||||
versionCode 3078
|
||||
versionName getNpmVersion()
|
||||
testBuildType System.getProperty('testBuildType', 'debug')
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
|
||||
@@ -1095,7 +1095,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2156;
|
||||
CURRENT_PROJECT_VERSION = 2157;
|
||||
DEVELOPMENT_TEAM = 53CWBG3QUC;
|
||||
ENABLE_BITCODE = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
|
||||
@@ -1169,7 +1169,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 3.3.8;
|
||||
MARKETING_VERSION = 3.3.9;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -1200,7 +1200,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 2156;
|
||||
CURRENT_PROJECT_VERSION = 2157;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 53CWBG3QUC;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
|
||||
@@ -1274,7 +1274,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 3.3.8;
|
||||
MARKETING_VERSION = 3.3.9;
|
||||
ONLY_ACTIVE_ARCH = NO;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
@@ -1433,7 +1433,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2156;
|
||||
CURRENT_PROJECT_VERSION = 2157;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = 53CWBG3QUC;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
|
||||
@@ -1445,7 +1445,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 3.3.8;
|
||||
MARKETING_VERSION = 3.3.9;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.streetwriters.notesnook.notewidget;
|
||||
@@ -1476,7 +1476,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 2156;
|
||||
CURRENT_PROJECT_VERSION = 2157;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 53CWBG3QUC;
|
||||
@@ -1489,7 +1489,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 3.3.8;
|
||||
MARKETING_VERSION = 3.3.9;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.streetwriters.notesnook.notewidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -1519,7 +1519,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Make Note/Make Note.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2156;
|
||||
CURRENT_PROJECT_VERSION = 2157;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = 53CWBG3QUC;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
|
||||
@@ -1600,7 +1600,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift$(inherited)";
|
||||
MARKETING_VERSION = 3.3.8;
|
||||
MARKETING_VERSION = 3.3.9;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.streetwriters.notesnook.share;
|
||||
@@ -1631,7 +1631,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 2156;
|
||||
CURRENT_PROJECT_VERSION = 2157;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 53CWBG3QUC;
|
||||
@@ -1713,7 +1713,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift$(inherited)";
|
||||
MARKETING_VERSION = 3.3.8;
|
||||
MARKETING_VERSION = 3.3.9;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.streetwriters.notesnook.share;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@notesnook/mobile",
|
||||
"version": "3.3.8",
|
||||
"version": "3.3.9",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -56,4 +56,4 @@
|
||||
"react": "18.2.0",
|
||||
"react-native": "0.77.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1427
apps/web/package-lock.json
generated
1427
apps/web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,8 @@
|
||||
{
|
||||
"name": "@notesnook/web",
|
||||
"description": "Your private note taking space",
|
||||
"version": "3.3.4",
|
||||
"version": "3.3.6-beta.0",
|
||||
"private": true,
|
||||
"main": "./src/app.js",
|
||||
"homepage": "https://notesnook.com/",
|
||||
"repository": "https://github.com/streetwriters/notesnook",
|
||||
"license": "GPL-3.0-or-later",
|
||||
@@ -30,7 +29,6 @@
|
||||
"@notesnook/theme": "file:../../packages/theme",
|
||||
"@notesnook/themes-server": "file:../../servers/themes",
|
||||
"@notesnook/ui": "file:../../packages/ui",
|
||||
"@notesnook/web-clipper": "file:../../extensions/web-clipper",
|
||||
"@paddle/paddle-js": "^1.4.2",
|
||||
"@react-pdf-viewer/core": "^3.12.0",
|
||||
"@react-pdf-viewer/toolbar": "^3.12.0",
|
||||
|
||||
@@ -30,13 +30,18 @@ export async function handleDrop(
|
||||
item:
|
||||
| ItemReference
|
||||
| Context
|
||||
| { type: "trash" | "notebooks" | "favorites" | undefined }
|
||||
| { type: "trash" | "notebooks" | "favorites" | "archive" | undefined }
|
||||
) {
|
||||
if (!item.type) return;
|
||||
|
||||
const noteIds = getDragData(dataTransfer, "note");
|
||||
const notebookIds = getDragData(dataTransfer, "notebook");
|
||||
const { setColor, favorite, delete: trashNotes } = useNoteStore.getState();
|
||||
const {
|
||||
setColor,
|
||||
favorite,
|
||||
delete: trashNotes,
|
||||
archive
|
||||
} = useNoteStore.getState();
|
||||
switch (item.type) {
|
||||
case "notebook":
|
||||
if (noteIds.length > 0) {
|
||||
@@ -83,5 +88,8 @@ export async function handleDrop(
|
||||
await useNoteStore.getState().refresh();
|
||||
}
|
||||
break;
|
||||
case "archive":
|
||||
archive(true, ...noteIds);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ type ToolButton = {
|
||||
hidden?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
toggled?: boolean;
|
||||
onClick: () => void;
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
};
|
||||
|
||||
export function EditorActionBar() {
|
||||
@@ -147,11 +147,15 @@ export function EditorActionBar() {
|
||||
enabled:
|
||||
activeSession &&
|
||||
(activeSession.type === "default" || activeSession.type === "readonly"),
|
||||
onClick: () =>
|
||||
activeSession &&
|
||||
(activeSession.type === "default" ||
|
||||
activeSession.type === "readonly") &&
|
||||
showPublishView(activeSession.note, "top")
|
||||
onClick: (e) => {
|
||||
if (
|
||||
!activeSession ||
|
||||
(activeSession.type !== "default" &&
|
||||
activeSession.type !== "readonly")
|
||||
)
|
||||
return;
|
||||
showPublishView(activeSession.note, e.target as HTMLElement);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: strings.toc(),
|
||||
|
||||
@@ -118,7 +118,7 @@ function countCharacters(text: string) {
|
||||
function countParagraphs(fragment: Fragment) {
|
||||
let count = 0;
|
||||
fragment.nodesBetween(0, fragment.size, (node) => {
|
||||
if (node.type.name === "paragraph") {
|
||||
if (node.type.name === "paragraph" && node.content.size > 0) {
|
||||
count++;
|
||||
}
|
||||
return true;
|
||||
@@ -737,7 +737,7 @@ function toIEditor(editor: Editor): IEditor {
|
||||
function getSelectedParagraphs(editor: Editor, selection: Selection): number {
|
||||
let count = 0;
|
||||
editor.state.doc.nodesBetween(selection.from, selection.to, (node) => {
|
||||
if (node.type.name === "paragraph") {
|
||||
if (node.type.name === "paragraph" && node.content.size > 0) {
|
||||
count++;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -506,6 +506,8 @@ function RouteItem({
|
||||
? "trash"
|
||||
: item.path === "/favorites"
|
||||
? "favorites"
|
||||
: item.path == "/archive"
|
||||
? "archive"
|
||||
: undefined
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -96,7 +96,7 @@ import {
|
||||
} from "../icons";
|
||||
import { Context } from "../list-container/types";
|
||||
import ListItem from "../list-item";
|
||||
import { showPublishView } from "../publish-view";
|
||||
import { PublishDialog } from "../publish-view";
|
||||
import TimeAgo from "../time-ago";
|
||||
|
||||
type NoteProps = NoteResolvedData & {
|
||||
@@ -480,7 +480,7 @@ export const noteMenuItems: (
|
||||
title: strings.update(),
|
||||
icon: Update.path,
|
||||
onClick: () => {
|
||||
showPublishView(note, "bottom");
|
||||
PublishDialog.show({ note });
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -499,7 +499,7 @@ export const noteMenuItems: (
|
||||
]
|
||||
}
|
||||
: undefined,
|
||||
onClick: () => showPublishView(note, "bottom")
|
||||
onClick: () => PublishDialog.show({ note })
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
|
||||
@@ -18,34 +18,39 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { Flex, Text, Button, Link } from "@theme-ui/components";
|
||||
import { Copy } from "../icons";
|
||||
import Toggle from "../toggle";
|
||||
import Field from "../field";
|
||||
import { Flex, Text, Button, Link, Switch, Input } from "@theme-ui/components";
|
||||
import { Loading, Refresh } from "../icons";
|
||||
import { db } from "../../common/db";
|
||||
import { writeText } from "clipboard-polyfill";
|
||||
import { ScopedThemeProvider } from "../theme-provider";
|
||||
import { showToast } from "../../utils/toast";
|
||||
import { EV, EVENTS, hosts } from "@notesnook/core";
|
||||
import { EV, EVENTS, hosts, MonographAnalytics } from "@notesnook/core";
|
||||
import { useStore } from "../../stores/monograph-store";
|
||||
import ReactModal from "react-modal";
|
||||
import { DialogButton } from "../dialog";
|
||||
import { Note } from "@notesnook/core";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import {
|
||||
getFormattedDate,
|
||||
useIsFeatureAvailable,
|
||||
usePromise
|
||||
} from "@notesnook/common";
|
||||
import { createRoot, Root } from "react-dom/client";
|
||||
import { PopupPresenter } from "@notesnook/ui";
|
||||
import { BaseDialogProps, DialogManager } from "../../common/dialog-manager";
|
||||
import Dialog from "../../components/dialog";
|
||||
import { UpgradeDialog } from "../../dialogs/buy-dialog/upgrade-dialog";
|
||||
|
||||
type PublishViewProps = {
|
||||
note: Note;
|
||||
monograph?: ResolvedMonograph;
|
||||
onClose: (result: boolean) => void;
|
||||
};
|
||||
function PublishView(props: PublishViewProps) {
|
||||
const { note, onClose } = props;
|
||||
const [publishId, setPublishId] = useState<string | undefined>(
|
||||
db.monographs.monograph(note.id)
|
||||
const [selfDestruct, setSelfDestruct] = useState(
|
||||
props.monograph?.selfDestruct
|
||||
);
|
||||
const [isPasswordProtected, setIsPasswordProtected] = useState(false);
|
||||
const [selfDestruct, setSelfDestruct] = useState(false);
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [status, setStatus] = useState<{
|
||||
action: "publish" | "unpublish" | "analytics";
|
||||
}>();
|
||||
const [processingStatus, setProcessingStatus] = useState<{
|
||||
total?: number;
|
||||
current: number;
|
||||
@@ -53,29 +58,12 @@ function PublishView(props: PublishViewProps) {
|
||||
const passwordInput = useRef<HTMLInputElement>(null);
|
||||
const publishNote = useStore((store) => store.publish);
|
||||
const unpublishNote = useStore((store) => store.unpublish);
|
||||
|
||||
useEffect(() => {
|
||||
if (!publishId) return;
|
||||
(async () => {
|
||||
const monographId = db.monographs.monograph(note.id);
|
||||
if (monographId) {
|
||||
const monograph = await db.monographs.get(monographId);
|
||||
if (!monograph) return;
|
||||
setPublishId(monographId);
|
||||
setIsPasswordProtected(!!monograph.password);
|
||||
setSelfDestruct(!!monograph.selfDestruct);
|
||||
|
||||
if (monograph.password) {
|
||||
const password = await db.monographs.decryptPassword(
|
||||
monograph.password
|
||||
);
|
||||
if (passwordInput.current) {
|
||||
passwordInput.current.value = password;
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [publishId, isPublishing, note.id]);
|
||||
const [monograph, setMonograph] = useState(props.monograph);
|
||||
const monographAnalytics = useIsFeatureAvailable("monographAnalytics");
|
||||
const analytics = usePromise(async () => {
|
||||
if (!monographAnalytics?.isAllowed || !monograph) return { totalViews: 0 };
|
||||
return await db.monographs.analytics(monograph?.id);
|
||||
}, [monograph?.id, monographAnalytics]);
|
||||
|
||||
useEffect(() => {
|
||||
const fileDownloadedEvent = EV.subscribe(
|
||||
@@ -93,175 +81,182 @@ function PublishView(props: PublishViewProps) {
|
||||
}, [note.id]);
|
||||
|
||||
return (
|
||||
<ScopedThemeProvider
|
||||
scope="dialog"
|
||||
injectCssVars
|
||||
sx={{
|
||||
width: ["100%", 350, 350],
|
||||
border: "1px solid",
|
||||
borderColor: "border",
|
||||
borderRadius: "dialog",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden"
|
||||
}}
|
||||
bg="background"
|
||||
>
|
||||
<Flex p={2} sx={{ flexDirection: "column" }}>
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{ fontSize: "title", fontWeight: "bold", color: "accent" }}
|
||||
<>
|
||||
{monograph?.id ? (
|
||||
<Flex
|
||||
sx={{
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "default",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between"
|
||||
}}
|
||||
>
|
||||
{note.title}
|
||||
</Text>
|
||||
{isPublishing ? (
|
||||
<Flex
|
||||
my={50}
|
||||
<Link
|
||||
variant="text.body"
|
||||
as="a"
|
||||
target="_blank"
|
||||
href={`${hosts.MONOGRAPH_HOST}/${monograph?.id}`}
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
textDecoration: "none",
|
||||
overflow: "hidden",
|
||||
px: 1
|
||||
}}
|
||||
>
|
||||
<Text>{strings.pleaseWait()}...</Text>
|
||||
{processingStatus && (
|
||||
<Text variant="subBody" mt={1}>
|
||||
{strings.downloadingImages()} ({processingStatus.current}/
|
||||
{processingStatus.total})
|
||||
</Text>
|
||||
)}
|
||||
{`${hosts.MONOGRAPH_HOST}/${monograph?.id}`}
|
||||
</Link>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="copyPublishLink"
|
||||
sx={{ flexShrink: 0, m: 0 }}
|
||||
onClick={() => {
|
||||
writeText(`${hosts.MONOGRAPH_HOST}/${monograph?.id}`);
|
||||
}}
|
||||
>
|
||||
{strings.copy()}
|
||||
</Button>
|
||||
</Flex>
|
||||
) : null}
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "default"
|
||||
}}
|
||||
>
|
||||
{monograph?.publishedAt ? (
|
||||
<Flex
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
px: 1,
|
||||
height: 30
|
||||
}}
|
||||
>
|
||||
<Text variant="body">{strings.publishedAt()}</Text>
|
||||
<Text variant="body" sx={{ color: "paragraph-secondary" }}>
|
||||
{getFormattedDate(monograph?.publishedAt, "date-time")}
|
||||
</Text>
|
||||
</Flex>
|
||||
) : (
|
||||
<>
|
||||
{publishId ? (
|
||||
<Flex
|
||||
mt={1}
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
overflow: "hidden"
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{ fontWeight: "bold", color: "paragraph" }}
|
||||
>
|
||||
{strings.publishedAt()}
|
||||
</Text>
|
||||
<Flex
|
||||
sx={{
|
||||
bg: "var(--background-secondary)",
|
||||
mt: 1,
|
||||
p: 1,
|
||||
borderRadius: "default",
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
variant="text.body"
|
||||
as="a"
|
||||
target="_blank"
|
||||
href={`${hosts.MONOGRAPH_HOST}/${publishId}`}
|
||||
) : null}
|
||||
{monograph?.id ? (
|
||||
<Flex
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
px: 1,
|
||||
height: 30
|
||||
}}
|
||||
>
|
||||
<Text variant="body">{strings.views()}</Text>
|
||||
{monographAnalytics?.isAllowed ? (
|
||||
analytics.status === "fulfilled" ? (
|
||||
<Flex sx={{ alignItems: "center", gap: 1 }}>
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
textDecoration: "none",
|
||||
overflow: "hidden",
|
||||
mr: 2
|
||||
color: "paragraph-secondary"
|
||||
}}
|
||||
>
|
||||
{`${hosts.MONOGRAPH_HOST}/${publishId}`}
|
||||
</Link>
|
||||
{analytics.value.totalViews}
|
||||
</Text>
|
||||
<Button
|
||||
variant="anchor"
|
||||
className="copyPublishLink"
|
||||
onClick={() => {
|
||||
writeText(`${hosts.MONOGRAPH_HOST}/${publishId}`);
|
||||
variant="tertiary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setStatus({ action: "analytics" });
|
||||
analytics.refresh();
|
||||
} finally {
|
||||
setStatus(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy size={20} color="accent" />
|
||||
<Refresh
|
||||
size={14}
|
||||
rotate={status?.action === "analytics"}
|
||||
/>
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
) : (
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
color: "paragraph"
|
||||
}}
|
||||
) : (
|
||||
<Loading size={14} />
|
||||
)
|
||||
) : monographAnalytics ? (
|
||||
<Button
|
||||
variant="anchor"
|
||||
onClick={() =>
|
||||
UpgradeDialog.show({ feature: monographAnalytics })
|
||||
}
|
||||
>
|
||||
{strings.monographDesc()}
|
||||
</Text>
|
||||
)}
|
||||
<Toggle
|
||||
title={strings.monographSelfDestructHeading()}
|
||||
tip={strings.monographSelfDestructDesc()}
|
||||
isToggled={selfDestruct}
|
||||
onToggled={() => setSelfDestruct((s) => !s)}
|
||||
/>
|
||||
<Toggle
|
||||
title={strings.monographPassHeading()}
|
||||
tip={strings.monographPassDesc()}
|
||||
isToggled={isPasswordProtected}
|
||||
onToggled={() => setIsPasswordProtected((s) => !s)}
|
||||
/>
|
||||
{isPasswordProtected && (
|
||||
<Field
|
||||
inputRef={passwordInput}
|
||||
autoFocus
|
||||
type="password"
|
||||
id="publishPassword"
|
||||
placeholder={strings.enterPassword()}
|
||||
required
|
||||
sx={{ my: 1 }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{strings.upgrade()}
|
||||
</Button>
|
||||
) : null}
|
||||
</Flex>
|
||||
) : null}
|
||||
<Flex
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
cursor: "pointer",
|
||||
px: 1,
|
||||
height: 30,
|
||||
|
||||
"& label": { width: "auto", flexShrink: 0 }
|
||||
}}
|
||||
onClick={() => setSelfDestruct((s) => !s)}
|
||||
title={strings.monographSelfDestructDesc()}
|
||||
>
|
||||
<Text variant="body">{strings.monographSelfDestructHeading()}</Text>
|
||||
<Switch
|
||||
sx={{
|
||||
m: 0,
|
||||
bg: selfDestruct ? "accent" : "icon-secondary",
|
||||
flexShrink: 0,
|
||||
scale: 0.75
|
||||
}}
|
||||
checked={selfDestruct}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Flex
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
px: 1,
|
||||
height: 30,
|
||||
|
||||
"& label": { width: "auto", flexShrink: 0 }
|
||||
}}
|
||||
title={strings.monographPassDesc()}
|
||||
>
|
||||
<Text variant="body">{strings.monographPassHeading()}</Text>
|
||||
<Input
|
||||
ref={passwordInput}
|
||||
type="password"
|
||||
variant="clean"
|
||||
placeholder={strings.noPassword()}
|
||||
defaultValue={monograph?.password}
|
||||
sx={{ textAlign: "right", p: 0 }}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Flex
|
||||
bg="var(--background-secondary)"
|
||||
p={1}
|
||||
px={2}
|
||||
sx={{ alignItems: "center", justifyContent: "end" }}
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "default",
|
||||
overflow: "hidden"
|
||||
}}
|
||||
>
|
||||
<DialogButton
|
||||
color="accent"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setIsPublishing(true);
|
||||
const password = passwordInput.current?.value;
|
||||
|
||||
const publishId = await publishNote(note.id, {
|
||||
selfDestruct,
|
||||
password
|
||||
});
|
||||
setPublishId(publishId);
|
||||
onClose(true);
|
||||
showToast("success", strings.actions.published.note(1));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast(
|
||||
"error",
|
||||
`${strings.actionErrors.published.note(1)}: ${
|
||||
(e as Error).message
|
||||
}`
|
||||
);
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
}}
|
||||
loading={isPublishing}
|
||||
text={publishId ? strings.update() : strings.publish()}
|
||||
/>
|
||||
{publishId && (
|
||||
<DialogButton
|
||||
color="red"
|
||||
{monograph?.id && (
|
||||
<Button
|
||||
variant="errorSecondary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setIsPublishing(true);
|
||||
setStatus({ action: "unpublish" });
|
||||
await unpublishNote(note.id);
|
||||
setPublishId(undefined);
|
||||
onClose(true);
|
||||
showToast("success", strings.actions.unpublished.note(1));
|
||||
} catch (e) {
|
||||
@@ -272,74 +267,177 @@ function PublishView(props: PublishViewProps) {
|
||||
(e as Error).message
|
||||
);
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
setStatus(undefined);
|
||||
}
|
||||
}}
|
||||
text={"Unpublish"}
|
||||
/>
|
||||
sx={{ textAlign: "left", borderRadius: "none" }}
|
||||
disabled={!!status}
|
||||
>
|
||||
{status?.action === "unpublish" ? (
|
||||
<Loading size={16} />
|
||||
) : (
|
||||
strings.unpublish()
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant={monograph?.id ? "secondary" : "accentSecondary"}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setStatus({ action: "publish" });
|
||||
const password = passwordInput.current?.value;
|
||||
|
||||
<DialogButton
|
||||
data-test-id="dialog-no"
|
||||
onClick={() => {
|
||||
onClose(false);
|
||||
await publishNote(note.id, {
|
||||
selfDestruct,
|
||||
password
|
||||
});
|
||||
setMonograph(await resolveMonograph(note.id));
|
||||
showToast("success", strings.actions.published.note(1));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast(
|
||||
"error",
|
||||
`${strings.actionErrors.published.note(1)}: ${
|
||||
(e as Error).message
|
||||
}`
|
||||
);
|
||||
} finally {
|
||||
setStatus(undefined);
|
||||
}
|
||||
}}
|
||||
color="paragraph"
|
||||
text="Cancel"
|
||||
/>
|
||||
sx={{ textAlign: "left", borderRadius: "none" }}
|
||||
disabled={!!status}
|
||||
>
|
||||
{status?.action === "publish" ? (
|
||||
<Loading size={16} />
|
||||
) : monograph?.id ? (
|
||||
strings.update()
|
||||
) : (
|
||||
strings.publish()
|
||||
)}
|
||||
</Button>
|
||||
</Flex>
|
||||
</ScopedThemeProvider>
|
||||
{processingStatus ? (
|
||||
<Text variant="subBody">
|
||||
{strings.downloadingImages()} {processingStatus.current}/
|
||||
{processingStatus.total || "?"}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublishView;
|
||||
|
||||
export function showPublishView(note: Note, location = "top") {
|
||||
const root = document.getElementById("dialogContainer");
|
||||
let root: Root | null = null;
|
||||
|
||||
if (root) {
|
||||
return new Promise((resolve) => {
|
||||
const perform = (result: boolean) => {
|
||||
ReactDOM.unmountComponentAtNode(root);
|
||||
closePublishView();
|
||||
resolve(result);
|
||||
};
|
||||
ReactDOM.render(
|
||||
<ReactModal
|
||||
isOpen
|
||||
onRequestClose={() => perform(false)}
|
||||
preventScroll={false}
|
||||
shouldCloseOnOverlayClick
|
||||
shouldCloseOnEsc
|
||||
shouldFocusAfterRender
|
||||
shouldReturnFocusAfterClose
|
||||
style={{
|
||||
overlay: { backgroundColor: "transparent", zIndex: 999 },
|
||||
content: {
|
||||
padding: 0,
|
||||
top: location === "top" ? 60 : undefined,
|
||||
right: location === "top" ? 10 : undefined,
|
||||
bottom: location === "bottom" ? 0 : undefined,
|
||||
left: location === "bottom" ? 0 : undefined,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
borderRadius: 0,
|
||||
boxShadow: "0px 0px 15px 0px #00000011"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PublishView note={note} onClose={perform} />
|
||||
</ReactModal>,
|
||||
root
|
||||
);
|
||||
});
|
||||
}
|
||||
return Promise.reject("No element with id 'dialogContainer'");
|
||||
function close() {
|
||||
root?.unmount();
|
||||
root = null;
|
||||
}
|
||||
|
||||
function closePublishView() {
|
||||
const root = document.getElementById("dialogContainer");
|
||||
if (root) {
|
||||
root.innerHTML = "";
|
||||
}
|
||||
export async function showPublishView(note: Note, target?: HTMLElement) {
|
||||
const rootElement = document.getElementById("dialogContainer");
|
||||
if (!rootElement) return;
|
||||
|
||||
if (root) return close();
|
||||
|
||||
const monograph = await resolveMonograph(note.id);
|
||||
|
||||
root = createRoot(rootElement);
|
||||
root.render(
|
||||
<PopupPresenter
|
||||
isOpen
|
||||
onClose={() => close()}
|
||||
position={{
|
||||
target,
|
||||
location: "below",
|
||||
isTargetAbsolute: true,
|
||||
yOffset: 10,
|
||||
xOffset: -10
|
||||
}}
|
||||
sx={{
|
||||
boxShadow: "0px 0px 15px 0px #00000011"
|
||||
}}
|
||||
scope="dialog"
|
||||
>
|
||||
<Flex
|
||||
p={2}
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
bg: "background",
|
||||
width: ["100%", 350, 350],
|
||||
border: "1px solid",
|
||||
borderColor: "border",
|
||||
borderRadius: "dialog",
|
||||
overflow: "hidden"
|
||||
}}
|
||||
>
|
||||
<Text variant="subtitle">{strings.publishToTheWeb()}</Text>
|
||||
<Text variant="subBody">{strings.monographDesc()}</Text>
|
||||
<PublishView
|
||||
note={note}
|
||||
monograph={monograph}
|
||||
onClose={() => close()}
|
||||
/>
|
||||
</Flex>
|
||||
</PopupPresenter>
|
||||
);
|
||||
}
|
||||
|
||||
export const PublishDialog = DialogManager.register(function PublishDialog(
|
||||
props: BaseDialogProps<boolean> & { note: Note }
|
||||
) {
|
||||
const monograph = usePromise(
|
||||
() => resolveMonograph(props.note.id),
|
||||
[props.note.id]
|
||||
);
|
||||
|
||||
if (monograph.status !== "fulfilled") return null;
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
title={strings.publishToTheWeb()}
|
||||
description={strings.monographDesc()}
|
||||
width={400}
|
||||
onClose={() => props.onClose(false)}
|
||||
>
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
mb: 3
|
||||
}}
|
||||
>
|
||||
<PublishView
|
||||
note={props.note}
|
||||
monograph={monograph.value}
|
||||
onClose={props.onClose}
|
||||
/>
|
||||
</Flex>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
|
||||
type ResolvedMonograph = {
|
||||
id: string;
|
||||
selfDestruct: boolean;
|
||||
publishedAt?: number;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
async function resolveMonograph(
|
||||
monographId: string
|
||||
): Promise<ResolvedMonograph | undefined> {
|
||||
const monograph = await db.monographs.get(monographId);
|
||||
if (!monograph) return;
|
||||
return {
|
||||
id: monographId,
|
||||
selfDestruct: !!monograph.selfDestruct,
|
||||
publishedAt: monograph.datePublished,
|
||||
password: monograph.password
|
||||
? await db.monographs.decryptPassword(monograph.password)
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import Tip from "../tip";
|
||||
import { Flex, Switch } from "@theme-ui/components";
|
||||
import { Loading } from "../icons";
|
||||
|
||||
function Toggle(props) {
|
||||
const {
|
||||
title,
|
||||
onTip,
|
||||
offTip,
|
||||
isToggled,
|
||||
onToggled,
|
||||
onlyIf,
|
||||
testId,
|
||||
disabled,
|
||||
tip
|
||||
} = props;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const onClick = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onToggled();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [onToggled]);
|
||||
|
||||
if (onlyIf === false) return null;
|
||||
return (
|
||||
<Flex
|
||||
onClick={disabled ? null : onClick}
|
||||
data-test-id={testId}
|
||||
py={2}
|
||||
sx={{
|
||||
opacity: disabled ? 0.7 : 1,
|
||||
cursor: "pointer",
|
||||
borderBottom: "1px solid",
|
||||
borderBottomColor: "separator",
|
||||
":hover": { borderBottomColor: "accent" },
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
|
||||
"& > label": { width: "auto", flexShrink: 0 }
|
||||
}}
|
||||
>
|
||||
<Tip
|
||||
text={title}
|
||||
tip={tip ? tip : isToggled ? onTip : offTip}
|
||||
sx={{ mr: 2 }}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Loading size={18} />
|
||||
) : (
|
||||
<Switch
|
||||
onClick={disabled ? null : onClick}
|
||||
checked={isToggled}
|
||||
sx={{ m: 0, bg: isToggled ? "accent" : "icon" }}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
export default Toggle;
|
||||
@@ -91,18 +91,7 @@ const features: Record<FeatureKeys, Feature> = {
|
||||
)
|
||||
}
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: "Notesnook Circle",
|
||||
subtitle:
|
||||
"Notesnook Circle brings together trusted partners who share our commitment to privacy, transparency, and user freedom. As a member you get exclusive discounts and offers from our partners. Check it out in Settings > Notesnook Circle."
|
||||
},
|
||||
{
|
||||
title: "Collapsible headings",
|
||||
subtitle:
|
||||
"You can now collapse and expand headings in your notes. This makes it easier to manage large notes and focus on specific sections."
|
||||
}
|
||||
],
|
||||
: [],
|
||||
cta: {
|
||||
title: strings.gotIt(),
|
||||
icon: Checkmark,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"extends": "../../tsconfig",
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true
|
||||
|
||||
@@ -19,18 +19,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
const { version } = require("../package.json");
|
||||
|
||||
const ICONS = {
|
||||
16: "16x16.png",
|
||||
32: "32x32.png",
|
||||
48: "48x48.png",
|
||||
64: "64x64.png",
|
||||
128: "128x128.png",
|
||||
256: "256x256.png"
|
||||
16: "assets/16x16.png",
|
||||
32: "assets/32x32.png",
|
||||
48: "assets/48x48.png",
|
||||
64: "assets/64x64.png",
|
||||
128: "assets/128x128.png",
|
||||
256: "assets/256x256.png"
|
||||
};
|
||||
const BACKGROUND_SCRIPT = "background.bundle.js";
|
||||
const ACTION = {
|
||||
default_icon: ICONS,
|
||||
default_title: "Notesnook Web Clipper",
|
||||
default_popup: "popup.html"
|
||||
default_popup: "public/index.html"
|
||||
};
|
||||
|
||||
const nnHost =
|
||||
@@ -60,8 +59,20 @@ const v2 = {
|
||||
},
|
||||
manifest_version: 2,
|
||||
background: {
|
||||
scripts: [BACKGROUND_SCRIPT]
|
||||
scripts: ["src/background.ts"]
|
||||
},
|
||||
content_scripts: [
|
||||
{
|
||||
matches: ["<all_urls>"],
|
||||
js: ["src/content-scripts/all.ts"],
|
||||
run_at: "document_end"
|
||||
},
|
||||
{
|
||||
matches: [nnHost, v3nnHost],
|
||||
js: ["src/content-scripts/nn.ts"],
|
||||
run_at: "document_end"
|
||||
}
|
||||
],
|
||||
browser_action: ACTION
|
||||
};
|
||||
|
||||
@@ -72,12 +83,37 @@ const v3 = {
|
||||
optional_host_permissions: ["http://*/*", "https://*/*"],
|
||||
manifest_version: 3,
|
||||
background: {
|
||||
service_worker: BACKGROUND_SCRIPT
|
||||
service_worker: "src/background.ts",
|
||||
type: "module"
|
||||
},
|
||||
content_scripts: [
|
||||
{
|
||||
matches: ["<all_urls>"],
|
||||
js: ["src/content-scripts/all.ts"]
|
||||
},
|
||||
{
|
||||
matches: [nnHost],
|
||||
js: ["src/content-scripts/nn.ts"]
|
||||
}
|
||||
],
|
||||
content_security_policy: {
|
||||
extension_pages: "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';"
|
||||
},
|
||||
web_accessible_resources: [
|
||||
{
|
||||
resources: ["*.wasm"],
|
||||
matches: ["<all_urls>"]
|
||||
}
|
||||
],
|
||||
action: ACTION
|
||||
};
|
||||
|
||||
function getManifest(version) {
|
||||
return version === "2" ? v2 : v3;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
v2,
|
||||
v3
|
||||
v3,
|
||||
getManifest
|
||||
};
|
||||
107
extensions/web-clipper/build-utils/manifest.ts
Normal file
107
extensions/web-clipper/build-utils/manifest.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import packageJson from "../package.json";
|
||||
|
||||
const { version } = packageJson;
|
||||
|
||||
const ICONS = {
|
||||
"16": "assets/16x16.png",
|
||||
"32": "assets/32x32.png",
|
||||
"48": "assets/48x48.png",
|
||||
"64": "assets/64x64.png",
|
||||
"128": "assets/128x128.png",
|
||||
"256": "assets/256x256.png"
|
||||
};
|
||||
const ACTION = {
|
||||
default_icon: ICONS,
|
||||
default_title: "Notesnook Web Clipper",
|
||||
default_popup: "index.html"
|
||||
};
|
||||
|
||||
const nnHost =
|
||||
process.env.NODE_ENV === "production"
|
||||
? "*://app.notesnook.com/*"
|
||||
: "*://localhost/*";
|
||||
const corsHost = "https://cors.notesnook.com/*";
|
||||
const common = {
|
||||
name: "Notesnook Web Clipper",
|
||||
version,
|
||||
description:
|
||||
"Clip web pages & save interesting things you find on the web directly into Notesnook in a private & secure way.",
|
||||
permissions: ["activeTab", "tabs", "storage", "notifications"],
|
||||
icons: ICONS
|
||||
};
|
||||
|
||||
const v2 = {
|
||||
...common,
|
||||
permissions: [...common.permissions, corsHost, nnHost],
|
||||
optional_permissions: ["http://*/*", "https://*/*"],
|
||||
browser_specific_settings: {
|
||||
gecko: {
|
||||
id: "notesnook-web-clipper-unlisted@notesnook.com",
|
||||
strict_min_version: "105.0"
|
||||
}
|
||||
},
|
||||
manifest_version: 2,
|
||||
background: {
|
||||
scripts: ["src/background.ts"]
|
||||
},
|
||||
content_scripts: [
|
||||
{
|
||||
matches: ["<all_urls>"],
|
||||
js: ["src/content-scripts/all.ts"],
|
||||
run_at: "document_end"
|
||||
}
|
||||
],
|
||||
browser_action: ACTION
|
||||
};
|
||||
|
||||
const v3 = {
|
||||
...common,
|
||||
permissions: [...common.permissions, "scripting"],
|
||||
host_permissions: [corsHost, nnHost],
|
||||
optional_host_permissions: ["http://*/*", "https://*/*"],
|
||||
manifest_version: 3,
|
||||
background: {
|
||||
service_worker: "src/background.ts",
|
||||
type: "module"
|
||||
},
|
||||
content_scripts: [
|
||||
{
|
||||
matches: ["<all_urls>"],
|
||||
js: ["src/content-scripts/all.ts"]
|
||||
}
|
||||
],
|
||||
content_security_policy: {
|
||||
extension_pages: "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';"
|
||||
},
|
||||
web_accessible_resources: [
|
||||
{
|
||||
resources: ["*.wasm"],
|
||||
matches: ["<all_urls>"]
|
||||
}
|
||||
],
|
||||
action: ACTION
|
||||
};
|
||||
|
||||
export function getManifest(version: string) {
|
||||
return version === "2" ? v2 : v3;
|
||||
}
|
||||
|
||||
export { v2, v3 };
|
||||
3
extensions/web-clipper/global.d.ts
vendored
3
extensions/web-clipper/global.d.ts
vendored
@@ -16,6 +16,9 @@ GNU General Public License for more details.
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import "vite/client";
|
||||
|
||||
declare module "*.svg" {
|
||||
import React = require("react");
|
||||
export const ReactComponent: React.SFC<React.SVGProps<SVGSVGElement>>;
|
||||
|
||||
@@ -10,5 +10,6 @@
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3847
extensions/web-clipper/package-lock.json
generated
3847
extensions/web-clipper/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,40 +2,27 @@
|
||||
"name": "@notesnook/web-clipper",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"main": "./dist/cjs/index.js",
|
||||
"module": "./dist/esm/index.js",
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"require": {
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"default": "./dist/cjs/index.js"
|
||||
},
|
||||
"import": {
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"default": "./dist/esm/index.js"
|
||||
}
|
||||
},
|
||||
"./*": {
|
||||
"require": {
|
||||
"types": "./dist/types/*",
|
||||
"default": "./dist/cjs/*"
|
||||
},
|
||||
"import": {
|
||||
"types": "./dist/types/*",
|
||||
"default": "./dist/esm/*"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "11.11.1",
|
||||
"@mdi/js": "7.4.47",
|
||||
"@mdi/react": "1.6.1",
|
||||
"@lingui/core": "5.1.2",
|
||||
"@lingui/react": "5.1.2",
|
||||
"@notesnook/clipper": "file:../../packages/clipper",
|
||||
"@notesnook/common": "file:../../packages/common",
|
||||
"@notesnook/core": "file:../../packages/core",
|
||||
"@notesnook/crypto": "file:../../packages/crypto",
|
||||
"@notesnook/logger": "file:../../packages/logger",
|
||||
"@notesnook/streamable-fs": "file:../../packages/streamable-fs",
|
||||
"@notesnook/theme": "file:../../packages/theme",
|
||||
"@notesnook/web": "file:../../apps/web",
|
||||
"@notesnook/intl": "file:../../packages/intl",
|
||||
"@streetwriters/kysely": "^0.27.4",
|
||||
"@theme-ui/components": "0.16.1",
|
||||
"@theme-ui/core": "0.16.1",
|
||||
"async-mutex": "^0.5.0",
|
||||
"comlink": "^4.3.1",
|
||||
"event-source-polyfill": "^1.0.31",
|
||||
"hyperapp": "^2.0.22",
|
||||
"mac-scrollbar": "0.13.6",
|
||||
"react": "18.3.1",
|
||||
@@ -45,14 +32,15 @@
|
||||
"react-scripts": "5.0.0",
|
||||
"svg-react-loader": "^0.4.6",
|
||||
"webextension-polyfill-ts": "^0.26.0",
|
||||
"zustand": "4.5.5"
|
||||
"zustand": "^4.5.7",
|
||||
"zustand-mutative": "^1.3.1",
|
||||
"dayjs": "1.11.13"
|
||||
},
|
||||
"scripts": {
|
||||
"build:firefox": "cross-env MANIFEST_VERSION=2 node build-utils/build.js",
|
||||
"build:chrome": "cross-env MANIFEST_VERSION=3 node build-utils/build.js",
|
||||
"dev:chrome": "cross-env MANIFEST_VERSION=3 node build-utils/dev.js",
|
||||
"dev:firefox": "cross-env MANIFEST_VERSION=2 node build-utils/dev.js",
|
||||
"build": "node ../../scripts/build.mjs"
|
||||
"build:firefox": "cross-env MANIFEST_VERSION=2 NODE_ENV=production vite build",
|
||||
"build:chrome": "cross-env MANIFEST_VERSION=3 NODE_ENV=production vite build",
|
||||
"dev:chrome": "cross-env MANIFEST_VERSION=3 vite",
|
||||
"dev:firefox": "cross-env MANIFEST_VERSION=2 vite"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@@ -67,11 +55,10 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.22.5",
|
||||
"@babel/plugin-proposal-class-properties": "^7.16.7",
|
||||
"@babel/preset-env": "^7.16.11",
|
||||
"@babel/preset-react": "^7.16.7",
|
||||
"@crxjs/vite-plugin": "^2.0.0-beta.25",
|
||||
"@emotion/babel-plugin": "^11.11.0",
|
||||
"@types/chrome": "^0.0.180",
|
||||
"@types/event-source-polyfill": "1.0.5",
|
||||
"@types/firefox-webext-browser": "^94.0.1",
|
||||
"@types/inline-css": "^3.0.1",
|
||||
"@types/jest": "^26.0.14",
|
||||
@@ -81,12 +68,8 @@
|
||||
"@types/react-modal": "3.16.3",
|
||||
"@types/sanitize-html": "^2.6.2",
|
||||
"@types/webextension-polyfill": "^0.8.3",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-loader": "^8.2.3",
|
||||
"babel-preset-react-app": "^10.0.1",
|
||||
"clean-webpack-plugin": "^4.0.0",
|
||||
"copy-webpack-plugin": "^7.0.0",
|
||||
"css-loader": "^6.6.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.8.0",
|
||||
"eslint-config-react-app": "^7.0.0",
|
||||
"eslint-plugin-flowtype": "^8.0.3",
|
||||
@@ -94,21 +77,10 @@
|
||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||
"eslint-plugin-react": "^7.28.0",
|
||||
"eslint-plugin-react-hooks": "^4.3.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"html-loader": "^3.1.0",
|
||||
"html-webpack-plugin": "^5.5.0",
|
||||
"node-sass": "^9.0.0",
|
||||
"prettier": "^2.5.1",
|
||||
"sass-loader": "^13.2.0",
|
||||
"source-map-loader": "^3.0.1",
|
||||
"style-loader": "^3.3.1",
|
||||
"terser-webpack-plugin": "^5.3.1",
|
||||
"ts-loader": "^9.2.6",
|
||||
"web-ext": "^7.12.0",
|
||||
"webpack": "5.88.2",
|
||||
"webpack-cli": "5.1.4",
|
||||
"webpack-dev-server": "^4.7.4"
|
||||
"vite": "^5.4.11",
|
||||
"web-ext": "^7.12.0"
|
||||
},
|
||||
"license": "GPL-3.0-or-later",
|
||||
"author": {
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import { browser, Runtime, Tabs } from "webextension-polyfill-ts";
|
||||
import { Remote, wrap } from "comlink";
|
||||
import { createEndpoint } from "./utils/comlink-extension";
|
||||
import { Server } from "./common/bridge";
|
||||
import { APP_URL, APP_URL_FILTER } from "./common/constants";
|
||||
|
||||
type WebExtensionChannelMessage = { success: boolean };
|
||||
|
||||
let api: Remote<Server> | undefined;
|
||||
export async function connectApi(openNew = false, onDisconnect?: () => void) {
|
||||
if (api) return api;
|
||||
|
||||
const tabs = await findNotesnookTabs(openNew);
|
||||
for (const tab of tabs) {
|
||||
try {
|
||||
const api = await Promise.race([
|
||||
connectToTab(tab, onDisconnect),
|
||||
timeout(5000)
|
||||
]);
|
||||
if (!api) continue;
|
||||
return api as Remote<Server>;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function timeout(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms, false));
|
||||
}
|
||||
|
||||
async function connectToTab(tab: Tabs.Tab, onDisconnect?: () => void) {
|
||||
if (!tab.id) return false;
|
||||
|
||||
if (browser.scripting) {
|
||||
await browser.scripting.executeScript({
|
||||
files: ["nnContentScript.bundle.js"],
|
||||
target: { tabId: tab.id }
|
||||
});
|
||||
} else {
|
||||
await browser.tabs.executeScript(tab.id, {
|
||||
file: "nnContentScript.bundle.js"
|
||||
});
|
||||
}
|
||||
|
||||
const port = browser.tabs.connect(tab.id);
|
||||
port.onDisconnect.addListener(() => {
|
||||
api = undefined;
|
||||
onDisconnect?.();
|
||||
});
|
||||
|
||||
return new Promise<Remote<Server> | false>(function connect(resolve) {
|
||||
async function onMessage(
|
||||
message: WebExtensionChannelMessage,
|
||||
port: Runtime.Port
|
||||
) {
|
||||
if (message.success) {
|
||||
port.onMessage.removeListener(onMessage);
|
||||
api = wrap<Server>(createEndpoint(port));
|
||||
resolve(api);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
}
|
||||
|
||||
port.onMessage.addListener(onMessage);
|
||||
});
|
||||
}
|
||||
|
||||
export async function findNotesnookTabs(openNew = false) {
|
||||
const tabs = await browser.tabs.query({
|
||||
url: APP_URL_FILTER,
|
||||
discarded: false,
|
||||
status: "complete"
|
||||
});
|
||||
|
||||
if (tabs.length) return tabs;
|
||||
|
||||
if (openNew) {
|
||||
const tab = await browser.tabs.create({ url: APP_URL, active: false });
|
||||
await new Promise<void>((resolve) =>
|
||||
browser.tabs.onUpdated.addListener(function onUpdated(id, info) {
|
||||
if (id === tab.id && info.status === "complete") {
|
||||
browser.tabs.onUpdated.removeListener(onUpdated);
|
||||
resolve();
|
||||
}
|
||||
})
|
||||
);
|
||||
return [tab];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -27,27 +27,35 @@ import {
|
||||
useThemeEngineStore
|
||||
} from "@notesnook/theme";
|
||||
import { Global, css } from "@emotion/react";
|
||||
import { useStore as useUserStore } from "@notesnook/web/src/stores/user-store";
|
||||
import AppEffects from "@notesnook/web/src/app-effects";
|
||||
|
||||
export function App() {
|
||||
const isLoggedIn = useAppStore((s) => s.isLoggedIn);
|
||||
const user = useAppStore((s) => s.user);
|
||||
// const isLoggedIn = useUserStore((store) => store.isLoggedIn);
|
||||
// const isLoggedIn = useAppStore((s) => s.isLoggedIn);
|
||||
// const user = useAppStore((s) => s.user);
|
||||
const route = useAppStore((s) => s.route);
|
||||
const navigate = useAppStore((s) => s.navigate);
|
||||
const theme = useThemeEngineStore((store) => store.theme);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) {
|
||||
navigate("/login");
|
||||
} else navigate("/");
|
||||
}, [isLoggedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && user.theme) {
|
||||
document.body.style.backgroundColor =
|
||||
user.theme.scopes.base.primary.background;
|
||||
useThemeEngineStore.getState().setTheme(user.theme);
|
||||
async function main() {
|
||||
await useUserStore.getState().init();
|
||||
const { isLoggedIn } = useUserStore.getState();
|
||||
if (!isLoggedIn) {
|
||||
navigate("/login");
|
||||
} else navigate("/");
|
||||
}
|
||||
}, [user]);
|
||||
main();
|
||||
}, []);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (user && user.theme) {
|
||||
// document.body.style.backgroundColor =
|
||||
// user.theme.scopes.base.primary.background;
|
||||
// useThemeEngineStore.getState().setTheme(user.theme);
|
||||
// }
|
||||
// }, [user]);
|
||||
const cssTheme = useMemo(() => themeToCSS(theme), [theme]);
|
||||
|
||||
return (
|
||||
@@ -57,6 +65,7 @@ export function App() {
|
||||
${cssTheme}
|
||||
`}
|
||||
/>
|
||||
<AppEffects />
|
||||
<EmotionThemeProvider scope="base" injectCssVars>
|
||||
{(() => {
|
||||
switch (route) {
|
||||
|
||||
@@ -17,17 +17,10 @@ You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { ThemeDefinition } from "@notesnook/theme";
|
||||
|
||||
export type ClipArea = "full-page" | "visible" | "selection" | "article";
|
||||
|
||||
export type ClipMode = "bookmark" | "simplified" | "screenshot" | "complete";
|
||||
|
||||
export type User = {
|
||||
email?: string;
|
||||
pro: boolean;
|
||||
theme: ThemeDefinition;
|
||||
};
|
||||
export type ItemReference = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -35,15 +28,6 @@ export type ItemReference = {
|
||||
|
||||
export type NotebookReference = ItemReference;
|
||||
|
||||
export type ClientMetadata = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export interface Gateway {
|
||||
connect(): ClientMetadata;
|
||||
}
|
||||
|
||||
type SelectedNotebookReference = ItemReference & {
|
||||
type: "notebook";
|
||||
};
|
||||
@@ -55,27 +39,6 @@ export type SelectedReference =
|
||||
| SelectedTagReference
|
||||
| SelectedNotebookReference;
|
||||
|
||||
export type Clip = {
|
||||
url: string;
|
||||
title: string;
|
||||
data: string;
|
||||
area: ClipArea;
|
||||
mode: ClipMode;
|
||||
width?: number;
|
||||
height?: number;
|
||||
pageTitle?: string;
|
||||
note?: ItemReference;
|
||||
refs?: SelectedReference[];
|
||||
};
|
||||
|
||||
export interface Server {
|
||||
login(): Promise<User | null>;
|
||||
getNotes(): Promise<ItemReference[] | undefined>;
|
||||
getNotebooks(parentId?: string): Promise<NotebookReference[] | undefined>;
|
||||
getTags(): Promise<ItemReference[] | undefined>;
|
||||
saveClip(clip: Clip): Promise<void>;
|
||||
}
|
||||
|
||||
export const WEB_EXTENSION_CHANNEL_EVENTS = {
|
||||
ON_CREATED: "web-extension-channel-created",
|
||||
ON_READY: "web-extension-channel-ready"
|
||||
|
||||
135
extensions/web-clipper/src/common/db.ts
Normal file
135
extensions/web-clipper/src/common/db.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { EventSourcePolyfill as EventSource } from "event-source-polyfill";
|
||||
import { database, getFeature, getFeatureLimit } from "@notesnook/common";
|
||||
import { createDialect } from "@notesnook/web/src/common/sqlite";
|
||||
import { isFeatureSupported } from "@notesnook/web/src/utils/feature-check";
|
||||
import {
|
||||
deriveKey,
|
||||
useKeyStore
|
||||
} from "@notesnook/web/src/interfaces/key-store";
|
||||
import {
|
||||
DatabasePersistence,
|
||||
NNStorage
|
||||
} from "@notesnook/web/src/interfaces/storage";
|
||||
import { generatePassword } from "@notesnook/web/src/utils/password-generator";
|
||||
|
||||
const db = database;
|
||||
async function initializeDatabase(persistence: DatabasePersistence) {
|
||||
db.host({
|
||||
API_HOST: "https://api.notesnook.com",
|
||||
AUTH_HOST: "https://auth.streetwriters.co",
|
||||
SSE_HOST: "https://events.streetwriters.co",
|
||||
ISSUES_HOST: "https://issues.streetwriters.co",
|
||||
SUBSCRIPTIONS_HOST: "https://subscriptions.streetwriters.co",
|
||||
MONOGRAPH_HOST: "https://monogr.ph",
|
||||
NOTESNOOK_HOST: "https://notesnook.com"
|
||||
});
|
||||
|
||||
await useKeyStore.getState().init();
|
||||
let databaseKey = await useKeyStore.getState().getValue("databaseKey");
|
||||
if (!databaseKey) {
|
||||
databaseKey = await deriveKey(generatePassword());
|
||||
await useKeyStore.getState().setValue("databaseKey", databaseKey);
|
||||
}
|
||||
|
||||
const storage = new NNStorage(
|
||||
"Notesnook",
|
||||
() => useKeyStore.getState(),
|
||||
persistence
|
||||
);
|
||||
await storage.migrate();
|
||||
|
||||
const multiTab = !!globalThis.SharedWorker && isFeatureSupported("opfs");
|
||||
database.setup({
|
||||
sqliteOptions: {
|
||||
dialect: (name, init) =>
|
||||
createDialect({
|
||||
name: persistence === "memory" ? ":memory:" : name,
|
||||
encrypted: true,
|
||||
async: !isFeatureSupported("opfs"),
|
||||
init,
|
||||
multiTab
|
||||
}),
|
||||
...(isFeatureSupported("opfs")
|
||||
? { journalMode: "WAL", lockingMode: "exclusive" }
|
||||
: {
|
||||
journalMode: "MEMORY",
|
||||
lockingMode: "normal"
|
||||
}),
|
||||
tempStore: "memory",
|
||||
synchronous: "normal",
|
||||
pageSize: 8192,
|
||||
cacheSize: -32000,
|
||||
password: Buffer.from(databaseKey).toString("hex"),
|
||||
skipInitialization: multiTab
|
||||
},
|
||||
storage: storage,
|
||||
eventsource: EventSource,
|
||||
// @ts-ignore
|
||||
fs: {},
|
||||
compressor: () =>
|
||||
import("@notesnook/web/src/utils/compressor").then(
|
||||
({ Compressor }) => new Compressor()
|
||||
),
|
||||
maxNoteVersions: async () => {
|
||||
const limit = await getFeatureLimit(getFeature("maxNoteVersions"));
|
||||
return typeof limit.caption === "number" ? limit.caption : undefined;
|
||||
},
|
||||
batchSize: 100
|
||||
});
|
||||
|
||||
// if (IS_TESTING) {
|
||||
|
||||
// } else {
|
||||
// db.host({
|
||||
// API_HOST: "http://localhost:5264",
|
||||
// AUTH_HOST: "http://localhost:8264",
|
||||
// SSE_HOST: "http://localhost:7264",
|
||||
// });
|
||||
// const base = `http://localhost`;
|
||||
// db.host({
|
||||
// API_HOST: `${base}:5264`,
|
||||
// AUTH_HOST: `${base}:8264`,
|
||||
// SSE_HOST: `${base}:7264`,
|
||||
// ISSUES_HOST: `${base}:2624`,
|
||||
// SUBSCRIPTIONS_HOST: `${base}:9264`
|
||||
// });
|
||||
// }
|
||||
|
||||
await db.init();
|
||||
|
||||
// window.addEventListener("beforeunload", async () => {
|
||||
// if (IS_DESKTOP_APP) {
|
||||
// await db.sql().destroy();
|
||||
// await logManager?.close();
|
||||
// }
|
||||
// });
|
||||
|
||||
// if (db.migrations?.required()) {
|
||||
// await import("../dialogs/migration-dialog").then(({ MigrationDialog }) =>
|
||||
// MigrationDialog.show({})
|
||||
// );
|
||||
// }
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
export { db, initializeDatabase };
|
||||
@@ -43,7 +43,8 @@ import {
|
||||
mdiCircleOutline,
|
||||
mdiBookOutline,
|
||||
mdiBookmarkOutline,
|
||||
mdiPound
|
||||
mdiPound,
|
||||
mdiSync
|
||||
} from "@mdi/js";
|
||||
|
||||
export const Icons = {
|
||||
@@ -78,6 +79,7 @@ export const Icons = {
|
||||
notebook: mdiBookOutline,
|
||||
topic: mdiBookmarkOutline,
|
||||
tag: mdiPound,
|
||||
sync: mdiSync,
|
||||
|
||||
none: "",
|
||||
back: mdiArrowLeft
|
||||
|
||||
@@ -16,15 +16,18 @@ GNU General Public License for more details.
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Flex, Text } from "@theme-ui/components";
|
||||
import { FilteredList } from "../filtered-list";
|
||||
import { ItemReference } from "../../common/bridge";
|
||||
import { Icon } from "../icons/icon";
|
||||
import { Icons } from "../icons";
|
||||
import { useAppStore } from "../../stores/app-store";
|
||||
import { Picker } from "../picker";
|
||||
import { CheckListItem } from "../check-list-item";
|
||||
import { FilteredList } from "@notesnook/web/src/components/filtered-list";
|
||||
import { Note, VirtualizedGrouping } from "@notesnook/core";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { db } from "../../common/db";
|
||||
import { ResolvedItem } from "@notesnook/common";
|
||||
|
||||
type NotePickerProps = {
|
||||
selectedNote?: ItemReference;
|
||||
@@ -34,8 +37,20 @@ export const NotePicker = (props: NotePickerProps) => {
|
||||
const { selectedNote, onSelected } = props;
|
||||
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const notes = useAppStore((s) => s.notes);
|
||||
|
||||
const [notes, setNotes] = useState<VirtualizedGrouping<Note> | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
(async function () {
|
||||
if (!notes) {
|
||||
setNotes(
|
||||
await db.notes.all.grouped(db.settings.getGroupOptions("notes"))
|
||||
);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
console.log(notes);
|
||||
const close = () => {
|
||||
setModalVisible(false);
|
||||
};
|
||||
@@ -97,25 +112,51 @@ export const NotePicker = (props: NotePickerProps) => {
|
||||
</Flex>
|
||||
|
||||
<Picker onClose={close} onDone={close} isOpen={modalVisible}>
|
||||
<FilteredList
|
||||
getAll={() => notes}
|
||||
filter={(items, query) =>
|
||||
items.filter((i) => i.title.toLowerCase().indexOf(query) > -1)
|
||||
}
|
||||
itemName="note"
|
||||
placeholder={"Search for a note"}
|
||||
refreshItems={() => notes}
|
||||
renderItem={(note) => (
|
||||
<CheckListItem
|
||||
title={note.title}
|
||||
onSelected={() => {
|
||||
onSelected(note);
|
||||
close();
|
||||
}}
|
||||
isSelected={selectedNote?.id === note.id}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{notes && (
|
||||
<FilteredList
|
||||
getItemKey={(index) => notes.key(index)}
|
||||
mode="fixed"
|
||||
estimatedSize={30}
|
||||
items={notes.placeholders}
|
||||
sx={{ mt: 2 }}
|
||||
itemGap={5}
|
||||
placeholders={{
|
||||
empty: strings.notesEmpty(),
|
||||
filter: strings.searchANote()
|
||||
}}
|
||||
filter={async (query) => {
|
||||
setNotes(
|
||||
query
|
||||
? await db.lookup.notes(query).sorted()
|
||||
: await db.notes.all.grouped(
|
||||
db.settings.getGroupOptions("notes")
|
||||
)
|
||||
);
|
||||
}}
|
||||
onCreateNewItem={async () => {}}
|
||||
renderItem={({ index }) => {
|
||||
console.log("Rendering note at index:", index);
|
||||
return (
|
||||
<ResolvedItem
|
||||
key={index}
|
||||
type="note"
|
||||
items={notes}
|
||||
index={index}
|
||||
>
|
||||
{({ item }) => (
|
||||
<CheckListItem
|
||||
title={item.title}
|
||||
onSelected={() => {
|
||||
onSelected({ id: item.id, title: item.title });
|
||||
}}
|
||||
isSelected={selectedNote?.id === item.id}
|
||||
/>
|
||||
)}
|
||||
</ResolvedItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Picker>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,15 +16,25 @@ GNU General Public License for more details.
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Flex } from "@theme-ui/components";
|
||||
import { FilteredList } from "../filtered-list";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, Flex, Text } from "@theme-ui/components";
|
||||
import { NotebookReference, SelectedReference } from "../../common/bridge";
|
||||
import { Icons } from "../icons";
|
||||
import { useAppStore } from "../../stores/app-store";
|
||||
import { Picker } from "../picker";
|
||||
import { InlineTag } from "../inline-tag";
|
||||
import { CheckListItem } from "../check-list-item";
|
||||
import { FilteredList } from "@notesnook/web/src/components/filtered-list";
|
||||
import Field from "@notesnook/web/src/components/field";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { db } from "../../common/db";
|
||||
import {
|
||||
TreeNode,
|
||||
VirtualizedTree,
|
||||
VirtualizedTreeHandle
|
||||
} from "@notesnook/web/src/components/virtualized-tree";
|
||||
import { NotebookItem } from "@notesnook/web/src/dialogs/move-note-dialog";
|
||||
import { Notebook } from "@notesnook/core";
|
||||
|
||||
type NotebookPickerProps = {
|
||||
selectedItems: SelectedReference[];
|
||||
@@ -33,10 +43,18 @@ type NotebookPickerProps = {
|
||||
export const NotebookPicker = (props: NotebookPickerProps) => {
|
||||
const { onSelected } = props;
|
||||
|
||||
const treeRef = useRef<VirtualizedTreeHandle<Notebook>>(null);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [selectedItems, setSelectedItems] = useState<SelectedReference[]>(
|
||||
props.selectedItems
|
||||
);
|
||||
const [notebooks, setNotebooks] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
db.notebooks.roots
|
||||
.ids(db.settings.getGroupOptions("notebooks"))
|
||||
.then((ids) => setNotebooks(ids));
|
||||
}, []);
|
||||
|
||||
const close = () => {
|
||||
setModalVisible(false);
|
||||
@@ -90,92 +108,121 @@ export const NotebookPicker = (props: NotebookPickerProps) => {
|
||||
}}
|
||||
isOpen={modalVisible}
|
||||
>
|
||||
<FilteredList
|
||||
getAll={() => useAppStore.getState().notebooks}
|
||||
filter={(items, query) =>
|
||||
items.filter((item) => item.title.toLowerCase().indexOf(query) > -1)
|
||||
}
|
||||
itemName="notebook"
|
||||
placeholder={"Search for a notebook"}
|
||||
refreshItems={() => useAppStore.getState().notebooks}
|
||||
renderItem={(item) => (
|
||||
<Notebook
|
||||
notebook={item}
|
||||
isSelected={
|
||||
!!selectedItems.find(
|
||||
(n) => n.id === item.id && n.type === "notebook"
|
||||
)
|
||||
}
|
||||
onSelected={(ref) => {
|
||||
setSelectedItems((items) => {
|
||||
const copy = items.slice();
|
||||
const index = copy.findIndex(
|
||||
(n) => n.id === ref.id && n.type === ref.type
|
||||
);
|
||||
if (index > -1) {
|
||||
copy.splice(index, 1);
|
||||
} else {
|
||||
copy.push(ref);
|
||||
<Flex
|
||||
id="subnotebooks"
|
||||
variant="columnFill"
|
||||
sx={{
|
||||
height: "80vh"
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
autoFocus
|
||||
sx={{ m: 0, mb: 2 }}
|
||||
styles={{
|
||||
input: { p: "7.5px" }
|
||||
}}
|
||||
placeholder={strings.searchNotebooks()}
|
||||
onChange={async (e) => {
|
||||
const query = e.target.value.trim();
|
||||
const ids = await (query
|
||||
? db.lookup.notebooks(query).ids()
|
||||
: db.notebooks.roots.ids(
|
||||
db.settings.getGroupOptions("notebooks")
|
||||
));
|
||||
setNotebooks(ids);
|
||||
}}
|
||||
/>
|
||||
{notebooks.length > 0 ? (
|
||||
<>
|
||||
<VirtualizedTree
|
||||
rootId={"root"}
|
||||
itemHeight={30}
|
||||
treeRef={treeRef}
|
||||
getChildNodes={async ({ id, depth }) => {
|
||||
const nodes: TreeNode<Notebook>[] = [];
|
||||
if (id === "root") {
|
||||
for (const id of notebooks) {
|
||||
const notebook = (await db.notebooks.notebook(id))!;
|
||||
const children = await db.relations
|
||||
.from(notebook, "notebook")
|
||||
.count();
|
||||
nodes.push({
|
||||
data: notebook,
|
||||
depth: depth + 1,
|
||||
hasChildren: children > 0,
|
||||
id,
|
||||
parentId: "root"
|
||||
});
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
|
||||
const subNotebooks = await db.relations
|
||||
.from({ type: "notebook", id }, "notebook")
|
||||
.resolve();
|
||||
|
||||
for (const notebook of subNotebooks) {
|
||||
const hasChildren =
|
||||
(await db.relations.from(notebook, "notebook").count()) >
|
||||
0;
|
||||
nodes.push({
|
||||
parentId: id,
|
||||
id: notebook.id,
|
||||
data: notebook,
|
||||
depth: depth + 1,
|
||||
hasChildren
|
||||
});
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}}
|
||||
renderItem={({ item, expanded, index, collapse, expand }) => (
|
||||
<NotebookItem
|
||||
notebook={item.data}
|
||||
depth={item.depth}
|
||||
isExpandable={item.hasChildren}
|
||||
isExpanded={expanded}
|
||||
toggle={expanded ? collapse : expand}
|
||||
onCreateItem={() => {
|
||||
treeRef.current?.refreshItem(index, item.data, {
|
||||
expand: true
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Flex
|
||||
sx={{
|
||||
my: 2,
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center"
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<Text variant="body">{strings.notebooksEmpty()}</Text>
|
||||
<Button
|
||||
data-test-id="add-new-notebook"
|
||||
variant="secondary"
|
||||
sx={{ mt: 2 }}
|
||||
onClick={
|
||||
() => {}
|
||||
// AddNotebookDialog.show({}).then((res) =>
|
||||
// res
|
||||
// ? db.notebooks.roots
|
||||
// .ids(db.settings.getGroupOptions("notebooks"))
|
||||
// .then((ids) => setNotebooks(ids))
|
||||
// : null
|
||||
// )
|
||||
}
|
||||
>
|
||||
{strings.addNotebook()}
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
/>
|
||||
</Flex>
|
||||
</Picker>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type NotebookProps = {
|
||||
notebook: NotebookReference;
|
||||
isSelected: boolean;
|
||||
onSelected: (notebook: SelectedReference) => void;
|
||||
};
|
||||
function Notebook(props: NotebookProps) {
|
||||
const { notebook, isSelected, onSelected } = props;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
overflow: "hidden"
|
||||
}}
|
||||
>
|
||||
<CheckListItem
|
||||
title={notebook.title}
|
||||
isSelected={isSelected}
|
||||
onSelected={() => {
|
||||
onSelected({
|
||||
id: notebook.id,
|
||||
title: notebook.title,
|
||||
type: "notebook"
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* <FilteredList
|
||||
getAll={() => notebook.topics}
|
||||
itemName="topic"
|
||||
placeholder={"Search for a topic"}
|
||||
refreshItems={() => notebook.topics}
|
||||
renderItem={(topic) => (
|
||||
<CheckListItem
|
||||
title={topic.title}
|
||||
isSelected={isTopicSelected(topic)}
|
||||
indentLevel={1}
|
||||
onSelected={() => {
|
||||
onSelected({
|
||||
id: topic.id,
|
||||
title: topic.title,
|
||||
type: "topic",
|
||||
parentId: notebook.id
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/> */}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,15 +16,23 @@ GNU General Public License for more details.
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Flex } from "@theme-ui/components";
|
||||
import { FilteredList } from "../filtered-list";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Flex, Text } from "@theme-ui/components";
|
||||
import { Icons } from "../icons";
|
||||
import { useAppStore } from "../../stores/app-store";
|
||||
import { Picker } from "../picker";
|
||||
import { InlineTag } from "../inline-tag";
|
||||
import { CheckListItem } from "../check-list-item";
|
||||
import { SelectedReference } from "../../common/bridge";
|
||||
import { FilteredList } from "@notesnook/web/src/components/filtered-list";
|
||||
import { Tag, VirtualizedGrouping } from "@notesnook/core";
|
||||
import { db } from "../../common/db";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { checkFeature } from "@notesnook/web/src/common";
|
||||
import {
|
||||
SelectedCheck,
|
||||
selectMultiple,
|
||||
useSelectionStore
|
||||
} from "@notesnook/web/src/dialogs/move-note-dialog";
|
||||
import { ResolvedItem } from "@notesnook/common";
|
||||
|
||||
type TagPickerProps = {
|
||||
selectedTags: SelectedReference[];
|
||||
@@ -34,6 +42,24 @@ export const TagPicker = (props: TagPickerProps) => {
|
||||
const { selectedTags, onSelected } = props;
|
||||
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [tags, setTags] = useState<VirtualizedGrouping<Tag> | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
(async function () {
|
||||
if (!tags) {
|
||||
setTags(await db.tags.all.grouped(db.settings.getGroupOptions("tags")));
|
||||
return;
|
||||
}
|
||||
|
||||
useSelectionStore.getState().setSelected(
|
||||
selectedTags.map((t) => ({
|
||||
id: t.id,
|
||||
new: false,
|
||||
op: "add"
|
||||
}))
|
||||
);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const close = () => {
|
||||
setModalVisible(false);
|
||||
@@ -87,35 +113,78 @@ export const TagPicker = (props: TagPickerProps) => {
|
||||
}}
|
||||
isOpen={modalVisible}
|
||||
>
|
||||
<FilteredList
|
||||
getAll={() => useAppStore.getState().tags}
|
||||
filter={(items, query) =>
|
||||
items.filter((item) => item.title.toLowerCase().indexOf(query) > -1)
|
||||
}
|
||||
itemName="tag"
|
||||
placeholder={"Search for a tag"}
|
||||
refreshItems={() => useAppStore.getState().tags}
|
||||
renderItem={(tag) => (
|
||||
<CheckListItem
|
||||
title={`#${tag.title}`}
|
||||
onSelected={() => {
|
||||
const copy = selectedTags.slice();
|
||||
const index = copy.findIndex(
|
||||
(c) => c.type === "tag" && c.id === tag.id
|
||||
);
|
||||
if (index <= -1) copy.push({ ...tag, type: "tag" });
|
||||
else copy.splice(index, 1);
|
||||
onSelected(copy);
|
||||
}}
|
||||
isSelected={
|
||||
selectedTags.findIndex(
|
||||
(s) => s.type === "tag" && s.id === tag.id
|
||||
) > -1
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{tags && (
|
||||
<FilteredList
|
||||
getItemKey={(index) => tags.key(index)}
|
||||
mode="fixed"
|
||||
estimatedSize={30}
|
||||
items={tags.placeholders}
|
||||
sx={{ mt: 2 }}
|
||||
itemGap={5}
|
||||
placeholders={{
|
||||
empty: strings.addATag(),
|
||||
filter: strings.searchForTags()
|
||||
}}
|
||||
filter={async (query) => {
|
||||
setTags(
|
||||
query
|
||||
? await db.lookup.tags(query).sorted()
|
||||
: await db.tags.all.grouped(
|
||||
db.settings.getGroupOptions("tags")
|
||||
)
|
||||
);
|
||||
}}
|
||||
onCreateNewItem={async (title) => {
|
||||
if (!(await checkFeature("tags", { type: "toast" }))) return;
|
||||
|
||||
const tagId = await db.tags.add({ title });
|
||||
if (!tagId) return;
|
||||
setTags(
|
||||
await db.tags.all.grouped(db.settings.getGroupOptions("tags"))
|
||||
);
|
||||
const { selected, setSelected } = useSelectionStore.getState();
|
||||
setSelected([...selected, { id: tagId, new: true, op: "add" }]);
|
||||
}}
|
||||
renderItem={({ index }) => {
|
||||
return (
|
||||
<ResolvedItem key={index} type="tag" items={tags} index={index}>
|
||||
{({ item }) => <TagItem tag={item} />}
|
||||
</ResolvedItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Picker>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function TagItem(props: { tag: Tag }) {
|
||||
const { tag } = props;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
as="li"
|
||||
data-test-id="tag"
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
bg: "var(--background-secondary)",
|
||||
borderRadius: "default",
|
||||
p: 1
|
||||
}}
|
||||
onClick={() => {
|
||||
const { selected, setSelected } = useSelectionStore.getState();
|
||||
setSelected(selectMultiple(tag, selected));
|
||||
}}
|
||||
>
|
||||
<Flex sx={{ alignItems: "center" }}>
|
||||
<SelectedCheck size={18} item={tag} />
|
||||
<Text className="title" data-test-id="tag-title" variant="body">
|
||||
#{tag.title}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import { browser } from "webextension-polyfill-ts";
|
||||
import { expose, Remote, wrap } from "comlink";
|
||||
import { createEndpoint } from "../utils/comlink-extension";
|
||||
import {
|
||||
Clip,
|
||||
Gateway,
|
||||
Server,
|
||||
WEB_EXTENSION_CHANNEL_EVENTS
|
||||
} from "../common/bridge";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var clipperBridgeConnected: boolean;
|
||||
}
|
||||
|
||||
function attachOnConnectListener() {
|
||||
if (globalThis.clipperBridgeConnected) return;
|
||||
globalThis.clipperBridgeConnected = true;
|
||||
|
||||
browser.runtime.onConnect.addListener((port) => {
|
||||
window.addEventListener("message", (ev) => {
|
||||
const { type } = ev.data;
|
||||
switch (type) {
|
||||
case WEB_EXTENSION_CHANNEL_EVENTS.ON_CREATED:
|
||||
if (ev.ports.length) {
|
||||
const mainPort = ev.ports.at(0);
|
||||
if (mainPort) {
|
||||
expose(new BackgroundGateway(), mainPort);
|
||||
const server: Remote<Server> = wrap<Server>(mainPort);
|
||||
expose(
|
||||
{
|
||||
login: () => server.login(),
|
||||
getNotes: () => server.getNotes(),
|
||||
getNotebooks: () => server.getNotebooks(),
|
||||
getTags: () => server.getTags(),
|
||||
saveClip: (clip: Clip) => server.saveClip(clip)
|
||||
},
|
||||
createEndpoint(port)
|
||||
);
|
||||
port.postMessage({ success: true });
|
||||
} else {
|
||||
port.postMessage({ success: false });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
window.postMessage({ type: WEB_EXTENSION_CHANNEL_EVENTS.ON_READY }, "*");
|
||||
});
|
||||
}
|
||||
|
||||
class BackgroundGateway implements Gateway {
|
||||
connect() {
|
||||
return {
|
||||
name: "Web clipper",
|
||||
id: "unknown-id"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
attachOnConnectListener();
|
||||
@@ -16,24 +16,35 @@ GNU General Public License for more details.
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import "../assets/16x16.png";
|
||||
import "../assets/32x32.png";
|
||||
import "../assets/48x48.png";
|
||||
import "../assets/64x64.png";
|
||||
import "../assets/128x128.png";
|
||||
import "../assets/256x256.png";
|
||||
|
||||
import "./polyfills";
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./app";
|
||||
import "./index.css";
|
||||
import { initializeFeatureChecks } from "@notesnook/web/src/utils/feature-check";
|
||||
import { initializeDatabase } from "./common/db";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { setI18nGlobal, Messages } from "@notesnook/intl";
|
||||
|
||||
declare let module: NodeModule & {
|
||||
hot?: { accept: () => void };
|
||||
};
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
if (module.hot) module.hot.accept();
|
||||
const locale = !import.meta.env.DEV
|
||||
? import("@notesnook/intl/locales/$pseudo-LOCALE.json")
|
||||
: import("@notesnook/intl/locales/$en.json");
|
||||
locale.then(({ default: locale }) => {
|
||||
i18n.load({
|
||||
en: locale.messages as unknown as Messages
|
||||
});
|
||||
i18n.activate("en");
|
||||
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
initializeFeatureChecks().then(() =>
|
||||
initializeDatabase("db").then(() => {
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
setI18nGlobal(i18n);
|
||||
|
||||
21
extensions/web-clipper/src/polyfills.ts
Normal file
21
extensions/web-clipper/src/polyfills.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Buffer } from "buffer";
|
||||
window.Buffer = Buffer;
|
||||
@@ -17,67 +17,15 @@ You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import create from "zustand";
|
||||
import { ItemReference, NotebookReference, User } from "../common/bridge";
|
||||
import { connectApi } from "../api";
|
||||
|
||||
interface AppStore {
|
||||
isLoggedIn: boolean;
|
||||
isLoggingIn: boolean;
|
||||
user?: User;
|
||||
notes: ItemReference[];
|
||||
notebooks: NotebookReference[];
|
||||
tags: ItemReference[];
|
||||
route: string;
|
||||
|
||||
login(openNew?: boolean): Promise<void>;
|
||||
navigate(route: string): void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppStore>((set) => ({
|
||||
isLoggedIn: false,
|
||||
isLoggingIn: false,
|
||||
notebooks: [],
|
||||
notes: [],
|
||||
tags: [],
|
||||
route: "/login",
|
||||
|
||||
navigate(route) {
|
||||
set({ route });
|
||||
},
|
||||
|
||||
async login(openNew = false) {
|
||||
set({ isLoggingIn: true });
|
||||
|
||||
const notesnook = await connectApi(openNew, () => {
|
||||
set({
|
||||
user: undefined,
|
||||
isLoggedIn: false,
|
||||
isLoggingIn: false,
|
||||
notes: [],
|
||||
notebooks: [],
|
||||
tags: []
|
||||
});
|
||||
});
|
||||
|
||||
if (!notesnook) {
|
||||
set({ isLoggingIn: false });
|
||||
throw new Error(
|
||||
"Please refresh the Notesnook web app to connect with the Web Clipper."
|
||||
);
|
||||
}
|
||||
|
||||
const user = await notesnook.login();
|
||||
const notes = await notesnook.getNotes();
|
||||
const notebooks = await notesnook.getNotebooks();
|
||||
const tags = await notesnook.getTags();
|
||||
|
||||
set({
|
||||
user: user || undefined,
|
||||
isLoggedIn: true,
|
||||
isLoggingIn: false,
|
||||
notes: notes,
|
||||
notebooks: notebooks,
|
||||
tags: tags
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -19,21 +19,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import { Button, Flex, Image, Text } from "@theme-ui/components";
|
||||
import { useEffect, useState } from "react";
|
||||
import Logo from "../../assets/logo.svg";
|
||||
import { useStore as useUserStore } from "@notesnook/web/src/stores/user-store";
|
||||
import Field from "@notesnook/web/src/components/field";
|
||||
import { HeadlessAuth } from "@notesnook/web/src/views/auth";
|
||||
import { useAppStore } from "../stores/app-store";
|
||||
|
||||
export function Login() {
|
||||
const [error, setError] = useState<string>();
|
||||
const isLoggingIn = useAppStore((s) => s.isLoggingIn);
|
||||
const login = useAppStore((s) => s.login);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await login().catch((e) => {
|
||||
console.error(e);
|
||||
setError(e.message);
|
||||
});
|
||||
})();
|
||||
}, [login]);
|
||||
const isLoggingIn = useUserStore((store) => store.isLoggingIn);
|
||||
const navigate = useAppStore((s) => s.navigate);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
@@ -41,37 +35,33 @@ export function Login() {
|
||||
flexDirection: "column",
|
||||
m: 2,
|
||||
my: 50,
|
||||
width: 300,
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
width: 300
|
||||
}}
|
||||
>
|
||||
<Image src={Logo} width={64} />
|
||||
<Text variant="heading" sx={{ textAlign: "center", mt: 2 }}>
|
||||
Notesnook Web Clipper
|
||||
</Text>
|
||||
{isLoggingIn ? (
|
||||
<Image src={Logo} width={50} sx={{ alignSelf: "center", mb: 4 }} />
|
||||
<HeadlessAuth
|
||||
route="login:email"
|
||||
isolated
|
||||
openURL={(url, context) => {
|
||||
if (context?.authenticated) {
|
||||
navigate("/");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* {isLoggingIn ? (
|
||||
<Text variant="body" sx={{ mt: 4 }}>
|
||||
Connecting with Notesnook...
|
||||
Logging you in...
|
||||
</Text>
|
||||
) : (
|
||||
<Button
|
||||
variant="accent"
|
||||
sx={{ px: 4, mt: 4, borderRadius: 100 }}
|
||||
onClick={async () =>
|
||||
await login(true).catch((e) => {
|
||||
setError(e.message);
|
||||
})
|
||||
}
|
||||
>
|
||||
Connect with Notesnook
|
||||
</Button>
|
||||
<>
|
||||
<Field />
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<Text variant="error" sx={{ mt: 2 }}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
)} */}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,10 +34,11 @@ import {
|
||||
import { usePersistentState } from "../hooks/use-persistent-state";
|
||||
import { deleteClip, getClip } from "../utils/storage";
|
||||
import { useAppStore } from "../stores/app-store";
|
||||
import { connectApi } from "../api";
|
||||
import { FlexScrollContainer } from "../components/scroll-container";
|
||||
import { DEFAULT_SETTINGS, SETTINGS_KEY } from "./settings";
|
||||
import type { Config } from "@notesnook/clipper/dist/types";
|
||||
import { useStore as useNNAppStore } from "@notesnook/web/src/stores/app-store";
|
||||
import { db } from "../common/db";
|
||||
|
||||
const ERROR_MAP: Record<string, string> = {
|
||||
"Could not establish connection. Receiving end does not exist.":
|
||||
@@ -110,9 +111,10 @@ export function Main() {
|
||||
const [error, setError] = useState<string>();
|
||||
// const [colorMode, setColorMode] = useColorMode();
|
||||
|
||||
const isPremium = useAppStore((s) => s.user?.pro);
|
||||
const navigate = useAppStore((s) => s.navigate);
|
||||
|
||||
const sync = useNNAppStore((store) => store.sync);
|
||||
const syncStatus = useNNAppStore((store) => store.syncStatus);
|
||||
const [settings] = usePersistentState<Config>(SETTINGS_KEY, DEFAULT_SETTINGS);
|
||||
const [title, setTitle] = useState<string>();
|
||||
const [hasPermission, setHasPermission] = useState<boolean>(false);
|
||||
@@ -125,7 +127,7 @@ export function Main() {
|
||||
"clipArea",
|
||||
"article"
|
||||
);
|
||||
const [note, setNote] = usePersistentState<ItemReference>("note");
|
||||
const [note, setNote] = usePersistentState<ItemReference | undefined>("note");
|
||||
const [refs, setRefs] = usePersistentState<SelectedReference[]>("refs", []);
|
||||
const [clipData, setClipData] = useState<ClipData>();
|
||||
const [clipperState, setClipperState] = useState<ClipperState>(
|
||||
@@ -149,15 +151,12 @@ export function Main() {
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (
|
||||
!isPremium &&
|
||||
(clipMode === "complete" || clipMode === "screenshot")
|
||||
) {
|
||||
if (clipMode === "complete" || clipMode === "screenshot") {
|
||||
setClipMode("simplified");
|
||||
return;
|
||||
}
|
||||
})();
|
||||
}, [isPremium, clipArea, clipMode]);
|
||||
}, [clipArea, clipMode]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -180,8 +179,8 @@ export function Main() {
|
||||
await clip(clipArea, clipMode, {
|
||||
...DEFAULT_SETTINGS,
|
||||
...settings,
|
||||
images: isPremium,
|
||||
inlineImages: isPremium
|
||||
images: true,
|
||||
inlineImages: true
|
||||
})
|
||||
);
|
||||
setClipperState(ClipperState.Clipped);
|
||||
@@ -320,11 +319,7 @@ export function Main() {
|
||||
setClipperState(ClipperState.Idle);
|
||||
setClipMode(item.id);
|
||||
}}
|
||||
disabled={
|
||||
isClipping ||
|
||||
clipperState === ClipperState.Clipped ||
|
||||
(item.pro && !isPremium)
|
||||
}
|
||||
disabled={isClipping || clipperState === ClipperState.Clipped}
|
||||
sx={{
|
||||
display: "flex",
|
||||
borderRadius: "default",
|
||||
@@ -451,7 +446,7 @@ export function Main() {
|
||||
)}
|
||||
<NotebookPicker
|
||||
selectedItems={refs?.filter((r) => r.type === "notebook") || []}
|
||||
onSelected={(items) => setRefs(items)}
|
||||
onSelected={(items) => setRefs(items || [])}
|
||||
/>
|
||||
<Box sx={{ mt: 1 }} />
|
||||
<TagPicker
|
||||
@@ -485,21 +480,21 @@ export function Main() {
|
||||
|
||||
if (!data) return;
|
||||
|
||||
const notesnook = await connectApi(false);
|
||||
if (!notesnook) {
|
||||
setError("You are not connected to Notesnook.");
|
||||
return;
|
||||
}
|
||||
await notesnook.saveClip({
|
||||
url,
|
||||
title,
|
||||
area: clipArea,
|
||||
mode: clipMode,
|
||||
note,
|
||||
refs,
|
||||
pageTitle: pageTitle.current,
|
||||
...data
|
||||
});
|
||||
// const notesnook = await connectApi(false);
|
||||
// if (!notesnook) {
|
||||
// setError("You are not connected to Notesnook.");
|
||||
// return;
|
||||
// }
|
||||
// await notesnook.saveClip({
|
||||
// url,
|
||||
// title,
|
||||
// area: clipArea,
|
||||
// mode: clipMode,
|
||||
// note,
|
||||
// refs,
|
||||
// pageTitle: pageTitle.current,
|
||||
// ...data
|
||||
// });
|
||||
|
||||
setClipData(undefined);
|
||||
|
||||
@@ -536,6 +531,21 @@ export function Main() {
|
||||
>
|
||||
<Icon path={Icons.settings} size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="icon"
|
||||
sx={{ p: 1 }}
|
||||
onClick={async () => {
|
||||
console.log(await db.user.getUser());
|
||||
await sync();
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
path={Icons.sync}
|
||||
rotate={syncStatus.key === "syncing"}
|
||||
size={16}
|
||||
/>
|
||||
{syncStatus.progress ? <Text>(${syncStatus.progress})</Text> : null}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</FlexScrollContainer>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"jsx": "react-jsx",
|
||||
"outDir": "./dist"
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src", "global.d.ts"]
|
||||
"include": ["src", "global.d.ts", "build-utils"]
|
||||
}
|
||||
|
||||
93
extensions/web-clipper/vite.config.ts
Normal file
93
extensions/web-clipper/vite.config.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { crx } from "@crxjs/vite-plugin";
|
||||
import path from "path";
|
||||
import { getManifest } from "./build-utils/manifest.js";
|
||||
import { version } from "./package.json";
|
||||
import { execSync } from "child_process";
|
||||
|
||||
const MANIFEST_VERSION = process.env.MANIFEST_VERSION || "2";
|
||||
const gitHash = (() => {
|
||||
try {
|
||||
return execSync("git rev-parse --short HEAD").toString().trim();
|
||||
} catch (e) {
|
||||
return process.env.GIT_HASH || "gitless";
|
||||
}
|
||||
})();
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react({
|
||||
jsxImportSource: "@emotion/react",
|
||||
babel: {
|
||||
plugins: ["@emotion/babel-plugin"]
|
||||
}
|
||||
}),
|
||||
crx({ manifest: getManifest(MANIFEST_VERSION) })
|
||||
],
|
||||
define: {
|
||||
APP_TITLE: `"Notesnook Web Clipper"`,
|
||||
GIT_HASH: `"${gitHash}"`,
|
||||
APP_VERSION: `"${version}"`,
|
||||
IS_DESKTOP_APP: false,
|
||||
PLATFORM: `"${process.env.PLATFORM}"`,
|
||||
IS_TESTING: false,
|
||||
IS_BETA: false,
|
||||
"process.env.NODE_ENV": JSON.stringify(
|
||||
process.env.NODE_ENV || "development"
|
||||
)
|
||||
},
|
||||
resolve: {
|
||||
dedupe: [
|
||||
"react",
|
||||
"react-dom",
|
||||
"@mdi/js",
|
||||
"@mdi/react",
|
||||
"@emotion/react",
|
||||
"react-modal",
|
||||
"dayjs",
|
||||
"@streetwriters/kysely"
|
||||
]
|
||||
},
|
||||
build: {
|
||||
outDir: "build",
|
||||
emptyOutDir: true,
|
||||
target: "esnext"
|
||||
},
|
||||
worker: {
|
||||
format: "es",
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames: "assets/[name]-[hash:12][extname]",
|
||||
chunkFileNames: "assets/[name]-[hash:12].js",
|
||||
inlineDynamicImports: true
|
||||
}
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3333,
|
||||
strictPort: true,
|
||||
hmr: {
|
||||
port: 3333
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -82,9 +82,13 @@ var options = {
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.wasm$/,
|
||||
type: "asset/resource" // ensures wasm is emitted as a file
|
||||
},
|
||||
{
|
||||
// look for .css or .scss files
|
||||
test: /\.(css|scss)$/,
|
||||
test: /\.(css)$/,
|
||||
// in the `src` directory
|
||||
use: [
|
||||
{
|
||||
@@ -92,12 +96,6 @@ var options = {
|
||||
},
|
||||
{
|
||||
loader: "css-loader"
|
||||
},
|
||||
{
|
||||
loader: "sass-loader",
|
||||
options: {
|
||||
sourceMap: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -133,7 +131,11 @@ var options = {
|
||||
alias: alias,
|
||||
extensions: fileExtensions
|
||||
.map((extension) => "." + extension)
|
||||
.concat([".js", ".jsx", ".ts", ".tsx", ".css"])
|
||||
.concat([".js", ".jsx", ".ts", ".tsx", ".css"]),
|
||||
fallback: {
|
||||
crypto: false,
|
||||
url: false
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
new CleanWebpackPlugin({
|
||||
|
||||
3
fastlane/metadata/android/en-US/changelogs/15394.txt
Normal file
3
fastlane/metadata/android/en-US/changelogs/15394.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
- Minor bug fixes and improvements
|
||||
|
||||
Thank you for using Notesnook!
|
||||
@@ -437,6 +437,17 @@ const features = {
|
||||
legacyPro: createLimit(true)
|
||||
}
|
||||
}),
|
||||
monographAnalytics: createFeature({
|
||||
id: "monographAnalytics",
|
||||
title: "Monographs analytics",
|
||||
availability: {
|
||||
free: createLimit(false),
|
||||
essential: createLimit(false),
|
||||
pro: createLimit(true),
|
||||
believer: createLimit(true),
|
||||
legacyPro: createLimit(true)
|
||||
}
|
||||
}),
|
||||
sms2FA: createFeature({
|
||||
id: "sms2FA",
|
||||
title: "2FA via SMS",
|
||||
|
||||
@@ -37,6 +37,9 @@ type EncryptedMonograph = MonographApiRequestBase & {
|
||||
type MonographApiRequest = (UnencryptedMonograph | EncryptedMonograph) & {
|
||||
userId: string;
|
||||
};
|
||||
export type MonographAnalytics = {
|
||||
totalViews: number;
|
||||
};
|
||||
|
||||
export type PublishOptions = { password?: string; selfDestruct?: boolean };
|
||||
export class Monographs {
|
||||
@@ -187,4 +190,17 @@ export class Monographs {
|
||||
if (!monographPasswordsKey) return "";
|
||||
return this.db.storage().decrypt(monographPasswordsKey, password);
|
||||
}
|
||||
|
||||
async analytics(monographId: string): Promise<MonographAnalytics> {
|
||||
try {
|
||||
const token = await this.db.tokenManager.getAccessToken();
|
||||
const analytics = (await http.get(
|
||||
`${Constants.API_HOST}/monographs/${monographId}/analytics`,
|
||||
token
|
||||
)) as MonographAnalytics;
|
||||
return analytics;
|
||||
} catch {
|
||||
return { totalViews: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -666,6 +666,15 @@ class UserManager {
|
||||
usesFallback: await this.usesFallbackPWHash(old_password)
|
||||
});
|
||||
|
||||
// retrieve user keys before deriving a new encryption key
|
||||
const oldUserKeys = {
|
||||
attachmentsKey: await this.getAttachmentsKey(),
|
||||
monographPasswordsKey: await this.getMonographPasswordsKey(),
|
||||
inboxKeys: (await this.hasInboxKeys())
|
||||
? await this.getInboxKeys()
|
||||
: undefined
|
||||
} as const;
|
||||
|
||||
await this.db.storage().deriveCryptoKey({
|
||||
password: new_password,
|
||||
salt
|
||||
@@ -678,27 +687,33 @@ class UserManager {
|
||||
const userEncryptionKey = await this.getEncryptionKey();
|
||||
if (userEncryptionKey) {
|
||||
const updateUserPayload: Partial<User> = {};
|
||||
const attachmentsKey = await this.getAttachmentsKey();
|
||||
if (attachmentsKey) {
|
||||
if (oldUserKeys.attachmentsKey) {
|
||||
user.attachmentsKey = await this.db
|
||||
.storage()
|
||||
.encrypt(userEncryptionKey, JSON.stringify(attachmentsKey));
|
||||
.encrypt(
|
||||
userEncryptionKey,
|
||||
JSON.stringify(oldUserKeys.attachmentsKey)
|
||||
);
|
||||
updateUserPayload.attachmentsKey = user.attachmentsKey;
|
||||
}
|
||||
const monographPasswordsKey = await this.getMonographPasswordsKey();
|
||||
if (monographPasswordsKey) {
|
||||
if (oldUserKeys.monographPasswordsKey) {
|
||||
user.monographPasswordsKey = await this.db
|
||||
.storage()
|
||||
.encrypt(userEncryptionKey, JSON.stringify(monographPasswordsKey));
|
||||
.encrypt(
|
||||
userEncryptionKey,
|
||||
JSON.stringify(oldUserKeys.monographPasswordsKey)
|
||||
);
|
||||
updateUserPayload.monographPasswordsKey = user.monographPasswordsKey;
|
||||
}
|
||||
const inboxKeys = await this.getInboxKeys();
|
||||
if (inboxKeys) {
|
||||
if (oldUserKeys.inboxKeys) {
|
||||
user.inboxKeys = {
|
||||
public: inboxKeys.publicKey,
|
||||
public: oldUserKeys.inboxKeys.publicKey,
|
||||
private: await this.db
|
||||
.storage()
|
||||
.encrypt(userEncryptionKey, JSON.stringify(inboxKeys.privateKey))
|
||||
.encrypt(
|
||||
userEncryptionKey,
|
||||
JSON.stringify(oldUserKeys.inboxKeys.privateKey)
|
||||
)
|
||||
};
|
||||
updateUserPayload.inboxKeys = user.inboxKeys;
|
||||
}
|
||||
|
||||
113
packages/editor/package-lock.json
generated
113
packages/editor/package-lock.json
generated
@@ -32,9 +32,6 @@
|
||||
"@tiptap/extension-placeholder": "2.6.6",
|
||||
"@tiptap/extension-subscript": "2.6.6",
|
||||
"@tiptap/extension-superscript": "2.6.6",
|
||||
"@tiptap/extension-table": "2.6.6",
|
||||
"@tiptap/extension-table-cell": "2.6.6",
|
||||
"@tiptap/extension-table-header": "2.6.6",
|
||||
"@tiptap/extension-table-row": "2.6.6",
|
||||
"@tiptap/extension-task-item": "2.6.6",
|
||||
"@tiptap/extension-task-list": "2.6.6",
|
||||
@@ -1535,6 +1532,39 @@
|
||||
"@styled-system/css": "^5.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@theme-ui/color-modes": {
|
||||
"version": "0.16.2",
|
||||
"resolved": "https://registry.npmjs.org/@theme-ui/color-modes/-/color-modes-0.16.2.tgz",
|
||||
"integrity": "sha512-jWEWx53lxNgWCT38i/kwLV2rsvJz8lVZgi5oImnVwYba9VejXD23q1ckbNFJHosQ8KKXY87ht0KPC6BQFIiHtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@theme-ui/core": "^0.16.2",
|
||||
"@theme-ui/css": "^0.16.2",
|
||||
"deepmerge": "^4.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@theme-ui/color-modes/node_modules/@theme-ui/core": {
|
||||
"version": "0.16.2",
|
||||
"resolved": "https://registry.npmjs.org/@theme-ui/core/-/core-0.16.2.tgz",
|
||||
"integrity": "sha512-bBd/ltbwO9vIUjF1jtlOX6XN0IIOdf1vzBp2JCKsSOqdfn84m+XL8OogIe/zOhQ+aM94Nrq4+32tFJc8sFav4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@theme-ui/css": "^0.16.2",
|
||||
"deepmerge": "^4.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@theme-ui/components": {
|
||||
"version": "0.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@theme-ui/components/-/components-0.16.1.tgz",
|
||||
@@ -1580,6 +1610,39 @@
|
||||
"@emotion/react": "^11.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@theme-ui/theme-provider": {
|
||||
"version": "0.16.2",
|
||||
"resolved": "https://registry.npmjs.org/@theme-ui/theme-provider/-/theme-provider-0.16.2.tgz",
|
||||
"integrity": "sha512-LRnVevODcGqO0JyLJ3wht+PV3ZoZcJ7XXLJAJWDoGeII4vZcPQKwVy4Lpz/juHsZppQxKcB3U+sQDGBnP25irQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@theme-ui/color-modes": "^0.16.2",
|
||||
"@theme-ui/core": "^0.16.2",
|
||||
"@theme-ui/css": "^0.16.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@theme-ui/theme-provider/node_modules/@theme-ui/core": {
|
||||
"version": "0.16.2",
|
||||
"resolved": "https://registry.npmjs.org/@theme-ui/core/-/core-0.16.2.tgz",
|
||||
"integrity": "sha512-bBd/ltbwO9vIUjF1jtlOX6XN0IIOdf1vzBp2JCKsSOqdfn84m+XL8OogIe/zOhQ+aM94Nrq4+32tFJc8sFav4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@theme-ui/css": "^0.16.2",
|
||||
"deepmerge": "^4.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.6.6.tgz",
|
||||
@@ -1778,43 +1841,6 @@
|
||||
"@tiptap/core": "^2.6.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.6.6.tgz",
|
||||
"integrity": "sha512-Ay/IClmB9R8MjnLobGnA9tI0+7ev4GUwvNf/JA2razI8CeaMCJ7CcAzG6pnIp4d7I6ELWYmAt3vwxoRlsAZcEw==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6",
|
||||
"@tiptap/pm": "^2.6.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table-cell": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.6.6.tgz",
|
||||
"integrity": "sha512-XakU9qnlYAf/ux4q7zgiJs2pvkjOl9mVzQw5j55aQHYLiw0gXomEgUbrkn7jhA7N6WP9PlngS3quwIDfyoqLvw==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table-header": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.6.6.tgz",
|
||||
"integrity": "sha512-BX2cVTrOZzIQAAWrNjD2Dzk/RpCJWUqgdW2bh27x0nJwKfMWfqLPoplTTuCZ+J9yK7rlNj3jEhKewe/yR1Tudw==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.6.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table-row": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.6.6.tgz",
|
||||
@@ -3490,8 +3516,7 @@
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.14.1",
|
||||
@@ -3611,7 +3636,6 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
@@ -5134,7 +5158,6 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -5155,7 +5178,6 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -5470,7 +5492,6 @@
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
|
||||
@@ -52,9 +52,6 @@
|
||||
"@tiptap/extension-placeholder": "2.6.6",
|
||||
"@tiptap/extension-subscript": "2.6.6",
|
||||
"@tiptap/extension-superscript": "2.6.6",
|
||||
"@tiptap/extension-table": "2.6.6",
|
||||
"@tiptap/extension-table-cell": "2.6.6",
|
||||
"@tiptap/extension-table-header": "2.6.6",
|
||||
"@tiptap/extension-table-row": "2.6.6",
|
||||
"@tiptap/extension-task-item": "2.6.6",
|
||||
"@tiptap/extension-task-list": "2.6.6",
|
||||
|
||||
@@ -34,6 +34,7 @@ type ResizerProps = {
|
||||
onResize: (width: number, height: number) => void;
|
||||
onResizeStop?: (width: number, height: number) => void;
|
||||
style?: React.CSSProperties;
|
||||
lockAspectRation?: boolean;
|
||||
};
|
||||
export function Resizer(props: PropsWithChildren<ResizerProps>) {
|
||||
const {
|
||||
@@ -45,7 +46,8 @@ export function Resizer(props: PropsWithChildren<ResizerProps>) {
|
||||
height,
|
||||
children,
|
||||
handleColor,
|
||||
style
|
||||
style,
|
||||
lockAspectRation = true
|
||||
} = props;
|
||||
|
||||
if (!enabled)
|
||||
@@ -109,7 +111,7 @@ export function Resizer(props: PropsWithChildren<ResizerProps>) {
|
||||
}
|
||||
}}
|
||||
onResizeStart={(e) => e.preventDefault()}
|
||||
lockAspectRatio={true}
|
||||
lockAspectRatio={lockAspectRation}
|
||||
>
|
||||
{children}
|
||||
</Resizable>
|
||||
|
||||
@@ -17,8 +17,8 @@ You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Box, Flex, Embed } from "@theme-ui/components";
|
||||
import { useRef, useState } from "react";
|
||||
import { Box, Flex } from "@theme-ui/components";
|
||||
import { useState } from "react";
|
||||
import { EmbedAlignmentOptions, EmbedAttributes } from "./embed.js";
|
||||
import { ReactNodeViewProps } from "../react/index.js";
|
||||
import { DesktopOnly } from "../../components/responsive/index.js";
|
||||
@@ -26,14 +26,15 @@ import { ToolbarGroup } from "../../toolbar/components/toolbar-group.js";
|
||||
import { Icons } from "../../toolbar/index.js";
|
||||
import { Icon } from "@notesnook/ui";
|
||||
import { Resizer } from "../../components/resizer/index.js";
|
||||
import { useThemeEngineStore } from "@notesnook/theme";
|
||||
|
||||
export function EmbedComponent(
|
||||
props: ReactNodeViewProps<EmbedAttributes & EmbedAlignmentOptions>
|
||||
) {
|
||||
const { editor, updateAttributes, selected, node } = props;
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const embedRef = useRef<HTMLIFrameElement>(null);
|
||||
const { src, width, textDirection } = node.attrs;
|
||||
const { src, width, height, textDirection } = node.attrs;
|
||||
const theme = useThemeEngineStore((store) => store.theme);
|
||||
|
||||
let align = node.attrs.align;
|
||||
if (!align) align = textDirection ? "right" : "left";
|
||||
@@ -43,7 +44,13 @@ export function EmbedComponent(
|
||||
sx={{
|
||||
justifyContent:
|
||||
align === "center" ? "center" : align === "left" ? "start" : "end",
|
||||
position: "relative"
|
||||
position: "relative",
|
||||
"& iframe": {
|
||||
border: selected
|
||||
? "2px solid var(--accent)"
|
||||
: "2px solid transparent",
|
||||
borderRadius: "default"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Resizer
|
||||
@@ -51,6 +58,8 @@ export function EmbedComponent(
|
||||
enabled={editor.isEditable}
|
||||
selected={selected}
|
||||
width={width}
|
||||
height={height}
|
||||
lockAspectRation={false}
|
||||
onResize={(width, height) => {
|
||||
updateAttributes(
|
||||
{
|
||||
@@ -113,21 +122,16 @@ export function EmbedComponent(
|
||||
)}
|
||||
</DesktopOnly>
|
||||
</Box>
|
||||
<Embed
|
||||
ref={embedRef}
|
||||
src={src}
|
||||
<iframe
|
||||
{...(isTwitterX(src)
|
||||
? {
|
||||
srcDoc: tweetToEmbed(src, theme.colorScheme === "dark")
|
||||
}
|
||||
: { src })}
|
||||
width={"100%"}
|
||||
height={"100%"}
|
||||
sandbox={getSandboxFeatures(src)}
|
||||
sx={{
|
||||
bg: "var(--background-secondary)",
|
||||
border: selected
|
||||
? "2px solid var(--accent)"
|
||||
: "2px solid transparent",
|
||||
borderRadius: "default"
|
||||
}}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
{...props}
|
||||
/>
|
||||
{isLoading && (
|
||||
<Flex
|
||||
@@ -168,3 +172,24 @@ function getSandboxFeatures(src: string) {
|
||||
}
|
||||
return features.join(" ");
|
||||
}
|
||||
|
||||
function isTwitterX(src: string) {
|
||||
try {
|
||||
const url = new URL(src);
|
||||
return (
|
||||
url.hostname === "twitter.com" ||
|
||||
url.hostname === "x.com" ||
|
||||
url.hostname.endsWith(".twitter.com") ||
|
||||
url.hostname.endsWith(".x.com")
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function tweetToEmbed(src: string, isDarkTheme: boolean) {
|
||||
src = src.replaceAll("x.com", "twitter.com");
|
||||
return `<blockquote class="twitter-tweet" data-dnt="true" ${
|
||||
isDarkTheme ? 'data-theme="dark"' : ""
|
||||
}><p lang="en" dir="ltr"><a href="${src}?ref_src=twsrc%5Etfw"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> `;
|
||||
}
|
||||
|
||||
@@ -3,3 +3,13 @@
|
||||
exports[`collapse heading > heading collapsed 1`] = `"<h1 data-collapsed="true">Main Heading</h1><p>paragraph.</p><h2>Subheading</h2><p>subheading paragraph</p><h1>Main heading 2</h1><p>paragraph another</p>"`;
|
||||
|
||||
exports[`collapse heading > heading uncollapsed 1`] = `"<h1>Main Heading</h1><p>paragraph.</p><h2>Subheading</h2><p>subheading paragraph</p><h1>Main heading 2</h1><p>paragraph another</p>"`;
|
||||
|
||||
exports[`replacing collapsed heading with another heading level should not unhide content 1`] = `"<h2 data-collapsed="true">A collapsed heading</h2><p data-hidden="true">Hidden paragraph</p>"`;
|
||||
|
||||
exports[`replacing collapsed heading with another node (blockquote) should unhide content 1`] = `"<blockquote><h1 data-collapsed="true">A collpased heading</h1></blockquote><p>Hidden paragraph</p>"`;
|
||||
|
||||
exports[`replacing collapsed heading with another node (bulletList) should unhide content 1`] = `"<ul><li><p>A collpased heading</p></li></ul><p>Hidden paragraph</p>"`;
|
||||
|
||||
exports[`replacing collapsed heading with another node (codeBlock) should unhide content 1`] = `"<pre><code>A collpased heading</code></pre><p>Hidden paragraph</p>"`;
|
||||
|
||||
exports[`replacing collapsed heading with another node (paragraph) should unhide content 1`] = `"<p>A collpased heading</p><p>Hidden paragraph</p>"`;
|
||||
|
||||
@@ -18,8 +18,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { test, expect } from "vitest";
|
||||
import { createEditor } from "../../../../test-utils/index.js";
|
||||
import { createEditor, h } from "../../../../test-utils/index.js";
|
||||
import { Heading } from "../heading.js";
|
||||
import { Editor } from "@tiptap/core";
|
||||
|
||||
test("collapse heading", () => {
|
||||
const { editor } = createEditor({
|
||||
@@ -52,3 +53,59 @@ test("collapse heading", () => {
|
||||
|
||||
expect(editor.getHTML()).toMatchSnapshot("heading uncollapsed");
|
||||
});
|
||||
|
||||
test("replacing collapsed heading with another heading level should not unhide content", () => {
|
||||
const el = h("div", [
|
||||
h("h1", ["A collapsed heading"], { "data-collapsed": "true" }),
|
||||
h("p", ["Hidden paragraph"], { "data-hidden": "true" })
|
||||
]);
|
||||
const { editor } = createEditor({
|
||||
extensions: {
|
||||
heading: Heading.configure({ levels: [1, 2, 3, 4, 5, 6] })
|
||||
},
|
||||
initialContent: el.outerHTML
|
||||
});
|
||||
|
||||
editor.commands.setTextSelection(0);
|
||||
editor.commands.setHeading({ level: 2 });
|
||||
|
||||
expect(editor.getHTML()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
const nodes: { name: string; setNode: (editor: Editor) => void }[] = [
|
||||
{
|
||||
name: "paragraph",
|
||||
setNode: (editor) => editor.commands.setParagraph()
|
||||
},
|
||||
{
|
||||
name: "codeBlock",
|
||||
setNode: (editor) => editor.commands.setCodeBlock()
|
||||
},
|
||||
{
|
||||
name: "bulletList",
|
||||
setNode: (editor) => editor.commands.toggleList("bulletList", "listItem")
|
||||
},
|
||||
{
|
||||
name: "blockquote",
|
||||
setNode: (editor) => editor.commands.toggleBlockquote()
|
||||
}
|
||||
];
|
||||
for (const { name, setNode } of nodes) {
|
||||
test(`replacing collapsed heading with another node (${name}) should unhide content`, () => {
|
||||
const el = h("div", [
|
||||
h("h1", ["A collpased heading"], { "data-collapsed": "true" }),
|
||||
h("p", ["Hidden paragraph"], { "data-hidden": "true" })
|
||||
]);
|
||||
const { editor } = createEditor({
|
||||
extensions: {
|
||||
heading: Heading.configure({ levels: [1, 2, 3, 4, 5, 6] })
|
||||
},
|
||||
initialContent: el.outerHTML
|
||||
});
|
||||
|
||||
editor.commands.setTextSelection(0);
|
||||
setNode(editor);
|
||||
|
||||
expect(editor.getHTML()).toMatchSnapshot();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,10 +23,8 @@ import {
|
||||
textblockTypeInputRule
|
||||
} from "@tiptap/core";
|
||||
import { Heading as TiptapHeading } from "@tiptap/extension-heading";
|
||||
import { isClickWithinBounds } from "../../utils/prosemirror.js";
|
||||
import { Plugin, PluginKey, Selection, Transaction } from "@tiptap/pm/state";
|
||||
import { Node } from "@tiptap/pm/model";
|
||||
import { useToolbarStore } from "../../toolbar/stores/toolbar-store.js";
|
||||
|
||||
const COLLAPSIBLE_BLOCK_TYPES = [
|
||||
"paragraph",
|
||||
@@ -168,6 +166,14 @@ export const Heading = TiptapHeading.extend({
|
||||
addNodeView() {
|
||||
return ({ node, getPos, editor, HTMLAttributes }) => {
|
||||
const heading = document.createElement(`h${node.attrs.level}`);
|
||||
const contentWrapper = document.createElement("div");
|
||||
const icon = document.createElement("span");
|
||||
|
||||
// providing a minWidth so that empty headings show the blinking cursor
|
||||
contentWrapper.style.minWidth = "1px";
|
||||
|
||||
icon.className = "heading-collapse-icon";
|
||||
icon.contentEditable = "false";
|
||||
|
||||
for (const attr in HTMLAttributes) {
|
||||
heading.setAttribute(attr, HTMLAttributes[attr]);
|
||||
@@ -176,50 +182,46 @@ export const Heading = TiptapHeading.extend({
|
||||
if (node.attrs.collapsed) heading.dataset.collapsed = "true";
|
||||
else delete heading.dataset.collapsed;
|
||||
|
||||
function onClick(e: MouseEvent | TouchEvent) {
|
||||
if (e instanceof MouseEvent && e.button !== 0) return;
|
||||
if (!(e.target instanceof HTMLHeadingElement)) return;
|
||||
function onIconClick(e: MouseEvent | TouchEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
const pos = typeof getPos === "function" ? getPos() : 0;
|
||||
if (typeof pos !== "number") return;
|
||||
|
||||
const resolvedPos = editor.state.doc.resolve(pos);
|
||||
const calloutAncestor = findParentNodeClosestToPos(
|
||||
resolvedPos,
|
||||
(node) => node.type.name === "callout"
|
||||
);
|
||||
if (calloutAncestor) return;
|
||||
|
||||
const forbiddenParents = ["callout"];
|
||||
if (
|
||||
isClickWithinBounds(
|
||||
e,
|
||||
resolvedPos,
|
||||
useToolbarStore.getState().isMobile ? "right" : "left"
|
||||
findParentNodeClosestToPos(resolvedPos, (node) =>
|
||||
forbiddenParents.includes(node.type.name)
|
||||
)
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
editor.commands.command(({ tr }) => {
|
||||
const currentNode = tr.doc.nodeAt(pos);
|
||||
if (currentNode && currentNode.type.name === "heading") {
|
||||
const shouldCollapse = !currentNode.attrs.collapsed;
|
||||
const headingLevel = currentNode.attrs.level;
|
||||
|
||||
tr.setNodeAttribute(pos, "collapsed", shouldCollapse);
|
||||
toggleNodesUnderHeading(tr, pos, headingLevel, shouldCollapse);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.commands.command(({ tr }) => {
|
||||
const currentNode = tr.doc.nodeAt(pos);
|
||||
if (currentNode && currentNode.type.name === "heading") {
|
||||
const shouldCollapse = !currentNode.attrs.collapsed;
|
||||
const headingLevel = currentNode.attrs.level;
|
||||
|
||||
tr.setNodeAttribute(pos, "collapsed", shouldCollapse);
|
||||
toggleNodesUnderPos(tr, pos, headingLevel, shouldCollapse);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
heading.onmousedown = onClick;
|
||||
heading.ontouchstart = onClick;
|
||||
icon.onmousedown = onIconClick;
|
||||
icon.ontouchend = onIconClick;
|
||||
|
||||
heading.appendChild(contentWrapper);
|
||||
heading.appendChild(icon);
|
||||
|
||||
return {
|
||||
dom: heading,
|
||||
contentDOM: heading,
|
||||
contentDOM: contentWrapper,
|
||||
update: (updatedNode) => {
|
||||
if (updatedNode.type !== this.type) {
|
||||
return false;
|
||||
@@ -253,17 +255,17 @@ export const Heading = TiptapHeading.extend({
|
||||
}
|
||||
});
|
||||
|
||||
function toggleNodesUnderHeading(
|
||||
function toggleNodesUnderPos(
|
||||
tr: Transaction,
|
||||
headingPos: number,
|
||||
pos: number,
|
||||
headingLevel: number,
|
||||
isCollapsing: boolean
|
||||
) {
|
||||
const { doc } = tr;
|
||||
const headingNode = doc.nodeAt(headingPos);
|
||||
if (!headingNode || headingNode.type.name !== "heading") return;
|
||||
const node = doc.nodeAt(pos);
|
||||
if (!node) return;
|
||||
|
||||
let nextPos = headingPos + headingNode.nodeSize;
|
||||
let nextPos = pos + node.nodeSize;
|
||||
const cursorPos = tr.selection.from;
|
||||
let shouldMoveCursor = false;
|
||||
let insideCollapsedHeading = false;
|
||||
@@ -318,8 +320,8 @@ function toggleNodesUnderHeading(
|
||||
}
|
||||
|
||||
if (shouldMoveCursor) {
|
||||
const headingEndPos = headingPos + headingNode.nodeSize - 1;
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(headingEndPos)));
|
||||
const endPos = pos + node.nodeSize - 1;
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(endPos)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,26 +366,27 @@ const headingUpdatePlugin = new Plugin({
|
||||
let modified = false;
|
||||
|
||||
newDoc.descendants((newNode, pos) => {
|
||||
if (newNode.type.name === "heading") {
|
||||
if (pos >= oldDoc.content.size) return;
|
||||
if (pos >= oldDoc.content.size) return;
|
||||
|
||||
const oldNode = oldDoc.nodeAt(pos);
|
||||
if (
|
||||
oldNode &&
|
||||
oldNode.type.name === "heading" &&
|
||||
oldNode.attrs.level !== newNode.attrs.level
|
||||
) {
|
||||
/**
|
||||
* if the level of a collapsed heading is changed,
|
||||
* we need to reset visibility of all the nodes under it as there
|
||||
* might be a heading of same or higher level previously
|
||||
* hidden under this heading
|
||||
*/
|
||||
if (newNode.attrs.collapsed) {
|
||||
toggleNodesUnderHeading(tr, pos, oldNode.attrs.level, false);
|
||||
toggleNodesUnderHeading(tr, pos, newNode.attrs.level, true);
|
||||
modified = true;
|
||||
}
|
||||
const oldNode = oldDoc.nodeAt(pos);
|
||||
if (
|
||||
oldNode &&
|
||||
oldNode.type.name === "heading" &&
|
||||
oldNode.attrs.level !== newNode.attrs.level
|
||||
) {
|
||||
/**
|
||||
* if the level of a collapsed heading is changed,
|
||||
* we need to reset visibility of all the nodes under it as there
|
||||
* might be a heading of same or higher level previously
|
||||
* hidden under this heading
|
||||
*/
|
||||
if (newNode.type.name === "heading" && newNode.attrs.collapsed) {
|
||||
toggleNodesUnderPos(tr, pos, oldNode.attrs.level, false);
|
||||
toggleNodesUnderPos(tr, pos, newNode.attrs.level, true);
|
||||
modified = true;
|
||||
} else if (newNode.type.name !== "heading" && oldNode.attrs.collapsed) {
|
||||
toggleNodesUnderPos(tr, pos, oldNode.attrs.level, false);
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -18,11 +18,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { isInTable } from "@tiptap/pm/tables";
|
||||
import { CodeBlock } from "../code-block/index.js";
|
||||
import { showLinkPopup } from "../../toolbar/popups/link-popup.js";
|
||||
import { isListActive } from "../../utils/list.js";
|
||||
import { tiptapKeys } from "@notesnook/common";
|
||||
import { isInTable } from "../table/prosemirror-tables/util.js";
|
||||
|
||||
export const KeyMap = Extension.create({
|
||||
name: "key-map",
|
||||
|
||||
@@ -24,7 +24,7 @@ export const ListItem = TiptapListItem.extend({
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
priority: 100,
|
||||
priority: 50,
|
||||
tag: `li`,
|
||||
getContent: ensureLeadingParagraph
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export type ReactNodeViewProps<TAttributes = Attrs> = {
|
||||
};
|
||||
|
||||
export type ReactNodeViewOptions<P> = {
|
||||
props?: P;
|
||||
props?: Partial<P>;
|
||||
component?: React.ComponentType<P>;
|
||||
componentKey?: (node: PMNode) => string;
|
||||
shouldUpdate?: ShouldUpdate;
|
||||
|
||||
@@ -17,18 +17,115 @@ You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import TiptapTableCell from "@tiptap/extension-table-cell";
|
||||
import { addStyleAttribute } from "./utils.js";
|
||||
import { Attribute } from "@tiptap/core";
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
|
||||
export const TableCell = TiptapTableCell.extend({
|
||||
addAttributes() {
|
||||
export interface TableCellOptions {
|
||||
/**
|
||||
* The HTML attributes for a table cell node.
|
||||
* @default {}
|
||||
* @example { class: 'foo' }
|
||||
*/
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* This extension allows you to create table cells.
|
||||
* @see https://www.tiptap.dev/api/nodes/table-cell
|
||||
*/
|
||||
export const TableCell = Node.create<TableCellOptions>({
|
||||
name: "tableCell",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
backgroundColor: addStyleAttribute("backgroundColor", "background-color"),
|
||||
color: addStyleAttribute("color", "color"),
|
||||
borderWidth: addStyleAttribute("borderWidth", "border-width", "px"),
|
||||
borderStyle: addStyleAttribute("borderStyle", "border-style"),
|
||||
borderColor: addStyleAttribute("borderColor", "border-color")
|
||||
HTMLAttributes: {}
|
||||
};
|
||||
},
|
||||
|
||||
content: "block+",
|
||||
|
||||
addAttributes() {
|
||||
return addTableCellAttributes();
|
||||
},
|
||||
|
||||
tableRole: "cell",
|
||||
|
||||
isolating: true,
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "td" }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"td",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||
0
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
export function addTableCellAttributes(): Record<string, Attribute> {
|
||||
return {
|
||||
colwidth: {
|
||||
default: null,
|
||||
parseHTML(element) {
|
||||
const widthAttr =
|
||||
element.getAttribute("data-colwidth") ||
|
||||
element.getAttribute("colwidth");
|
||||
const widths =
|
||||
widthAttr && /^\d+(,\d+)*$/.test(widthAttr)
|
||||
? widthAttr.split(",").map((s) => Number(s))
|
||||
: null;
|
||||
const colspan = Number(element.getAttribute("colspan") || 1);
|
||||
|
||||
// migrate to data-colwidth attribute
|
||||
if (element.hasAttribute("colwidth")) {
|
||||
element.setAttribute(
|
||||
"data-colwidth",
|
||||
element.getAttribute("colwidth")!
|
||||
);
|
||||
element.removeAttribute("colwidth");
|
||||
}
|
||||
|
||||
return widths && widths.length == colspan ? widths : null;
|
||||
},
|
||||
renderHTML(attributes) {
|
||||
if (!attributes.colwidth) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
"data-colwidth": attributes.colwidth.join(",")
|
||||
};
|
||||
}
|
||||
},
|
||||
colspan: {
|
||||
default: 1,
|
||||
parseHTML(element) {
|
||||
return Number(element.getAttribute("colspan") || 1);
|
||||
},
|
||||
renderHTML(attributes) {
|
||||
return {
|
||||
colspan: attributes.colspan || 1
|
||||
};
|
||||
}
|
||||
},
|
||||
rowspan: {
|
||||
default: 1,
|
||||
parseHTML(element) {
|
||||
return Number(element.getAttribute("rowspan") || 1);
|
||||
},
|
||||
renderHTML(attributes) {
|
||||
return {
|
||||
rowspan: attributes.rowspan || 1
|
||||
};
|
||||
}
|
||||
},
|
||||
backgroundColor: addStyleAttribute("backgroundColor", "background-color"),
|
||||
color: addStyleAttribute("color", "color"),
|
||||
borderWidth: addStyleAttribute("borderWidth", "border-width", "px"),
|
||||
borderStyle: addStyleAttribute("borderStyle", "border-style"),
|
||||
borderColor: addStyleAttribute("borderColor", "border-color")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,18 +17,50 @@ You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import TipTapTableHeader from "@tiptap/extension-table-header";
|
||||
import { addStyleAttribute } from "../table-cell/utils.js";
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { addTableCellAttributes } from "../table-cell/table-cell.js";
|
||||
|
||||
export const TableHeader = TipTapTableHeader.extend({
|
||||
addAttributes() {
|
||||
export interface TableHeaderOptions {
|
||||
/**
|
||||
* The HTML attributes for a table header node.
|
||||
* @default {}
|
||||
* @example { class: 'foo' }
|
||||
*/
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* This extension allows you to create table headers.
|
||||
* @see https://www.tiptap.dev/api/nodes/table-header
|
||||
*/
|
||||
export const TableHeader = Node.create<TableHeaderOptions>({
|
||||
name: "tableHeader",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
backgroundColor: addStyleAttribute("backgroundColor", "background-color"),
|
||||
color: addStyleAttribute("color", "color"),
|
||||
borderWidth: addStyleAttribute("borderWidth", "border-width", "px"),
|
||||
borderStyle: addStyleAttribute("borderStyle", "border-style"),
|
||||
borderColor: addStyleAttribute("borderColor", "border-color")
|
||||
HTMLAttributes: {}
|
||||
};
|
||||
},
|
||||
|
||||
content: "block+",
|
||||
|
||||
addAttributes() {
|
||||
return addTableCellAttributes();
|
||||
},
|
||||
|
||||
tableRole: "header_cell",
|
||||
|
||||
isolating: true,
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "th" }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"th",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||
0
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
49
packages/editor/src/extensions/table/TableView.ts
Normal file
49
packages/editor/src/extensions/table/TableView.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { NodeView } from "@tiptap/pm/view";
|
||||
import { updateColumnsOnResize } from "./prosemirror-tables/tableview.js";
|
||||
|
||||
export class TableView implements NodeView {
|
||||
node: ProseMirrorNode;
|
||||
|
||||
cellMinWidth: number;
|
||||
|
||||
dom: HTMLElement;
|
||||
|
||||
table: HTMLTableElement;
|
||||
|
||||
colgroup: HTMLTableColElement;
|
||||
|
||||
contentDOM: HTMLElement;
|
||||
|
||||
constructor(node: ProseMirrorNode, cellMinWidth: number) {
|
||||
this.node = node;
|
||||
this.cellMinWidth = cellMinWidth;
|
||||
this.dom = document.createElement("div");
|
||||
this.dom.className = "tableWrapper";
|
||||
this.table = this.dom.appendChild(document.createElement("table"));
|
||||
this.colgroup = this.table.appendChild(document.createElement("colgroup"));
|
||||
updateColumnsOnResize(node, this.colgroup, this.table, cellMinWidth);
|
||||
this.contentDOM = this.table.appendChild(document.createElement("tbody"));
|
||||
}
|
||||
|
||||
update(node: ProseMirrorNode) {
|
||||
if (node.type !== this.node.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.node = node;
|
||||
updateColumnsOnResize(node, this.colgroup, this.table, this.cellMinWidth);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
ignoreMutation(
|
||||
mutation: MutationRecord | { type: "selection"; target: Element }
|
||||
) {
|
||||
return (
|
||||
mutation.type === "attributes" &&
|
||||
(mutation.target === this.table ||
|
||||
this.colgroup.contains(mutation.target))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { selectedRect, TableRect } from "@tiptap/pm/tables";
|
||||
import { EditorState, TextSelection, Transaction } from "prosemirror-state";
|
||||
import { Node } from "prosemirror-model";
|
||||
import { selectedRect, TableRect } from "./prosemirror-tables/commands.js";
|
||||
|
||||
type TableCell = {
|
||||
cell: Node;
|
||||
|
||||
@@ -23,7 +23,6 @@ import { Node as ProsemirrorNode } from "prosemirror-model";
|
||||
import { Editor } from "../../types.js";
|
||||
import { Editor as TiptapEditor } from "@tiptap/core";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { updateColumnsOnResize } from "@tiptap/pm/tables";
|
||||
import { EditorView, NodeView } from "prosemirror-view";
|
||||
import {
|
||||
InsertColumnRight,
|
||||
@@ -37,14 +36,17 @@ import {
|
||||
findSelectedDOMNode,
|
||||
hasSameAttributes
|
||||
} from "../../utils/prosemirror.js";
|
||||
import { DesktopOnly } from "../../components/responsive/index.js";
|
||||
import { DesktopOnly, MobileOnly } from "../../components/responsive/index.js";
|
||||
import { TextDirections } from "../text-direction/index.js";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import SimpleBar from "simplebar-react";
|
||||
import { useIsMobile } from "../../toolbar/stores/toolbar-store.js";
|
||||
import { updateColumnsOnResize } from "./prosemirror-tables/tableview.js";
|
||||
|
||||
export function TableComponent(props: ReactNodeViewProps) {
|
||||
const { editor, node, forwardRef } = props;
|
||||
export function TableComponent(
|
||||
props: ReactNodeViewProps & { cellMinWidth: number }
|
||||
) {
|
||||
const { editor, node, forwardRef, cellMinWidth } = props;
|
||||
const colgroupRef = useRef<HTMLTableColElement>(null);
|
||||
const tableRef = useRef<HTMLTableElement>();
|
||||
const { textDirection } = node.attrs;
|
||||
@@ -53,24 +55,37 @@ export function TableComponent(props: ReactNodeViewProps) {
|
||||
useEffect(() => {
|
||||
if (!colgroupRef.current || !tableRef.current) return;
|
||||
|
||||
updateColumnsOnResize(node, colgroupRef.current, tableRef.current, 50);
|
||||
}, [node]);
|
||||
|
||||
const renderScrollContent = useCallback(() => {
|
||||
return (
|
||||
<div dir={textDirection}>
|
||||
<table
|
||||
ref={(ref) => {
|
||||
forwardRef?.(ref);
|
||||
tableRef.current = ref || undefined;
|
||||
}}
|
||||
>
|
||||
<colgroup ref={colgroupRef} />
|
||||
{/* <tbody /> */}
|
||||
</table>
|
||||
</div>
|
||||
updateColumnsOnResize(
|
||||
node,
|
||||
colgroupRef.current,
|
||||
tableRef.current,
|
||||
cellMinWidth
|
||||
);
|
||||
}, [forwardRef, textDirection]);
|
||||
}, [node, cellMinWidth]);
|
||||
|
||||
// useEffect(() => {
|
||||
// function transactionListener({
|
||||
// editor
|
||||
// }: {
|
||||
// transaction: Transaction;
|
||||
// editor: TiptapEditor;
|
||||
// }) {
|
||||
// if (!colgroupRef.current || !tableRef.current) return;
|
||||
|
||||
// const cellsInRow = tableRef.current?.rows[0]?.cells.length || 0;
|
||||
// if (colgroupRef.current?.childElementCount !== cellsInRow)
|
||||
// updateColumns(
|
||||
// selectedRect(editor.state).table,
|
||||
// colgroupRef.current,
|
||||
// tableRef.current,
|
||||
// cellMinWidth
|
||||
// );
|
||||
// }
|
||||
// editor.on("transaction", transactionListener);
|
||||
// return () => {
|
||||
// editor.off("transaction", transactionListener);
|
||||
// };
|
||||
// }, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -85,33 +100,59 @@ export function TableComponent(props: ReactNodeViewProps) {
|
||||
table={tableRef}
|
||||
textDirection={textDirection}
|
||||
/>
|
||||
<SimpleBar autoHide style={{ overflowY: "hidden" }}>
|
||||
<div dir={textDirection}>
|
||||
<table
|
||||
ref={(ref) => {
|
||||
forwardRef?.(ref);
|
||||
tableRef.current = ref || undefined;
|
||||
}}
|
||||
>
|
||||
<colgroup ref={colgroupRef} />
|
||||
{/* <tbody /> */}
|
||||
</table>
|
||||
</div>
|
||||
</SimpleBar>
|
||||
</DesktopOnly>
|
||||
|
||||
{isMobile ? (
|
||||
<ScrollContainer>{renderScrollContent()}</ScrollContainer>
|
||||
) : (
|
||||
<SimpleBar>{renderScrollContent()}</SimpleBar>
|
||||
)}
|
||||
<MobileOnly>
|
||||
<div dir={textDirection}>
|
||||
<table
|
||||
ref={(ref) => {
|
||||
forwardRef?.(ref);
|
||||
tableRef.current = ref || undefined;
|
||||
}}
|
||||
>
|
||||
<colgroup ref={colgroupRef} />
|
||||
{/* <tbody /> */}
|
||||
</table>
|
||||
</div>
|
||||
</MobileOnly>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableNodeView(editor: TiptapEditor) {
|
||||
class TableNode
|
||||
extends ReactNodeView<ReactNodeViewProps<unknown>>
|
||||
extends ReactNodeView<
|
||||
ReactNodeViewProps<unknown> & { cellMinWidth: number }
|
||||
>
|
||||
implements NodeView
|
||||
{
|
||||
constructor(node: ProsemirrorNode) {
|
||||
constructor(node: ProsemirrorNode, cellMinWidth: number) {
|
||||
super(
|
||||
node,
|
||||
editor,
|
||||
() => 0, // todo
|
||||
{
|
||||
component: TableComponent,
|
||||
props: { cellMinWidth },
|
||||
forceEnableSelection: true,
|
||||
shouldUpdate: (prev, next) => {
|
||||
return (
|
||||
!hasSameAttributes(prev.attrs, next.attrs) ||
|
||||
prev.childCount !== next.childCount
|
||||
prev.childCount !== next.childCount ||
|
||||
// compare columns
|
||||
prev.firstChild?.childCount !== next.firstChild?.childCount
|
||||
);
|
||||
},
|
||||
contentDOMFactory: () => {
|
||||
@@ -194,6 +235,7 @@ function TableRowToolbar(props: TableToolbarProps) {
|
||||
bg: "background",
|
||||
flexWrap: "nowrap",
|
||||
borderRadius: "default",
|
||||
border: "1px solid var(--border)",
|
||||
flexDirection: "column",
|
||||
opacity: 0.4,
|
||||
":hover": {
|
||||
@@ -272,6 +314,7 @@ function TableColumnToolbar(props: TableToolbarProps) {
|
||||
bg: "background",
|
||||
flexWrap: "nowrap",
|
||||
borderRadius: "default",
|
||||
border: "1px solid var(--border)",
|
||||
opacity: 0.4,
|
||||
":hover": {
|
||||
opacity: 1
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
// This file defines a ProseMirror selection subclass that models
|
||||
// table cell selections. The table plugin needs to be active to wire
|
||||
// in the user interaction part of table selections (so that you
|
||||
// actually get such selections when you select across cells).
|
||||
|
||||
import { Fragment, Node, ResolvedPos, Slice } from "prosemirror-model";
|
||||
import {
|
||||
EditorState,
|
||||
NodeSelection,
|
||||
Selection,
|
||||
SelectionRange,
|
||||
TextSelection,
|
||||
Transaction
|
||||
} from "prosemirror-state";
|
||||
import { Decoration, DecorationSet, DecorationSource } from "prosemirror-view";
|
||||
|
||||
import { Mappable } from "prosemirror-transform";
|
||||
import { TableMap } from "./tablemap.js";
|
||||
import { CellAttrs, inSameTable, pointsAtCell, removeColSpan } from "./util.js";
|
||||
import { findParentNodeOfTypeClosestToPos } from "../../../utils/prosemirror.js";
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface CellSelectionJSON {
|
||||
type: string;
|
||||
anchor: number;
|
||||
head: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A [`Selection`](http://prosemirror.net/docs/ref/#state.Selection)
|
||||
* subclass that represents a cell selection spanning part of a table.
|
||||
* With the plugin enabled, these will be created when the user
|
||||
* selects across cells, and will be drawn by giving selected cells a
|
||||
* `selectedCell` CSS class.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class CellSelection extends Selection {
|
||||
// A resolved position pointing _in front of_ the anchor cell (the one
|
||||
// that doesn't move when extending the selection).
|
||||
public $anchorCell: ResolvedPos;
|
||||
|
||||
// A resolved position pointing in front of the head cell (the one
|
||||
// moves when extending the selection).
|
||||
public $headCell: ResolvedPos;
|
||||
|
||||
// A table selection is identified by its anchor and head cells. The
|
||||
// positions given to this constructor should point _before_ two
|
||||
// cells in the same table. They may be the same, to select a single
|
||||
// cell.
|
||||
constructor($anchorCell: ResolvedPos, $headCell: ResolvedPos = $anchorCell) {
|
||||
const table = $anchorCell.node(-1);
|
||||
const map = TableMap.get(table);
|
||||
const tableStart = $anchorCell.start(-1);
|
||||
const rect = map.rectBetween(
|
||||
$anchorCell.pos - tableStart,
|
||||
$headCell.pos - tableStart
|
||||
);
|
||||
|
||||
const doc = $anchorCell.node(0);
|
||||
const cells = map
|
||||
.cellsInRect(rect)
|
||||
.filter((p) => p != $headCell.pos - tableStart);
|
||||
// Make the head cell the first range, so that it counts as the
|
||||
// primary part of the selection
|
||||
cells.unshift($headCell.pos - tableStart);
|
||||
const ranges = cells.map((pos) => {
|
||||
const cell = table.nodeAt(pos);
|
||||
if (!cell) {
|
||||
throw RangeError(`No cell with offset ${pos} found`);
|
||||
}
|
||||
const from = tableStart + pos + 1;
|
||||
return new SelectionRange(
|
||||
doc.resolve(from),
|
||||
doc.resolve(from + cell.content.size)
|
||||
);
|
||||
});
|
||||
super(ranges[0].$from, ranges[0].$to, ranges);
|
||||
this.$anchorCell = $anchorCell;
|
||||
this.$headCell = $headCell;
|
||||
}
|
||||
|
||||
public map(doc: Node, mapping: Mappable): CellSelection | Selection {
|
||||
const $anchorCell = doc.resolve(mapping.map(this.$anchorCell.pos));
|
||||
const $headCell = doc.resolve(mapping.map(this.$headCell.pos));
|
||||
if (
|
||||
pointsAtCell($anchorCell) &&
|
||||
pointsAtCell($headCell) &&
|
||||
inSameTable($anchorCell, $headCell)
|
||||
) {
|
||||
const tableChanged = this.$anchorCell.node(-1) != $anchorCell.node(-1);
|
||||
if (tableChanged && this.isRowSelection())
|
||||
return CellSelection.rowSelection($anchorCell, $headCell);
|
||||
else if (tableChanged && this.isColSelection())
|
||||
return CellSelection.colSelection($anchorCell, $headCell);
|
||||
else return new CellSelection($anchorCell, $headCell);
|
||||
}
|
||||
return TextSelection.between($anchorCell, $headCell);
|
||||
}
|
||||
|
||||
// Returns a rectangular slice of table rows containing the selected
|
||||
// cells.
|
||||
public content(): Slice {
|
||||
const table = this.$anchorCell.node(-1);
|
||||
const map = TableMap.get(table);
|
||||
const tableStart = this.$anchorCell.start(-1);
|
||||
|
||||
const rect = map.rectBetween(
|
||||
this.$anchorCell.pos - tableStart,
|
||||
this.$headCell.pos - tableStart
|
||||
);
|
||||
const seen: Record<number, boolean> = {};
|
||||
const rows = [];
|
||||
for (let row = rect.top; row < rect.bottom; row++) {
|
||||
const rowContent = [];
|
||||
for (
|
||||
let index = row * map.width + rect.left, col = rect.left;
|
||||
col < rect.right;
|
||||
col++, index++
|
||||
) {
|
||||
const pos = map.map[index];
|
||||
if (seen[pos]) continue;
|
||||
seen[pos] = true;
|
||||
|
||||
const cellRect = map.findCell(pos);
|
||||
let cell = table.nodeAt(pos);
|
||||
if (!cell) {
|
||||
throw RangeError(`No cell with offset ${pos} found`);
|
||||
}
|
||||
|
||||
const extraLeft = rect.left - cellRect.left;
|
||||
const extraRight = cellRect.right - rect.right;
|
||||
|
||||
if (extraLeft > 0 || extraRight > 0) {
|
||||
let attrs = cell.attrs as CellAttrs;
|
||||
if (extraLeft > 0) {
|
||||
attrs = removeColSpan(attrs, 0, extraLeft);
|
||||
}
|
||||
if (extraRight > 0) {
|
||||
attrs = removeColSpan(
|
||||
attrs,
|
||||
attrs.colspan - extraRight,
|
||||
extraRight
|
||||
);
|
||||
}
|
||||
if (cellRect.left < rect.left) {
|
||||
cell = cell.type.createAndFill(attrs);
|
||||
if (!cell) {
|
||||
throw RangeError(
|
||||
`Could not create cell with attrs ${JSON.stringify(attrs)}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
cell = cell.type.create(attrs, cell.content);
|
||||
}
|
||||
}
|
||||
if (cellRect.top < rect.top || cellRect.bottom > rect.bottom) {
|
||||
const attrs = {
|
||||
...cell.attrs,
|
||||
rowspan:
|
||||
Math.min(cellRect.bottom, rect.bottom) -
|
||||
Math.max(cellRect.top, rect.top)
|
||||
};
|
||||
if (cellRect.top < rect.top) {
|
||||
cell = cell.type.createAndFill(attrs)!;
|
||||
} else {
|
||||
cell = cell.type.create(attrs, cell.content);
|
||||
}
|
||||
}
|
||||
rowContent.push(cell);
|
||||
}
|
||||
rows.push(table.child(row).copy(Fragment.from(rowContent)));
|
||||
}
|
||||
|
||||
const fragment =
|
||||
this.isColSelection() && this.isRowSelection() ? table : rows;
|
||||
return new Slice(Fragment.from(fragment), 1, 1);
|
||||
}
|
||||
|
||||
public replace(tr: Transaction, content: Slice = Slice.empty): void {
|
||||
const mapFrom = tr.steps.length,
|
||||
ranges = this.ranges;
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
const { $from, $to } = ranges[i],
|
||||
mapping = tr.mapping.slice(mapFrom);
|
||||
tr.replace(
|
||||
mapping.map($from.pos),
|
||||
mapping.map($to.pos),
|
||||
i ? Slice.empty : content
|
||||
);
|
||||
}
|
||||
const sel = Selection.findFrom(
|
||||
tr.doc.resolve(tr.mapping.slice(mapFrom).map(this.to)),
|
||||
-1
|
||||
);
|
||||
if (sel) tr.setSelection(sel);
|
||||
}
|
||||
|
||||
public replaceWith(tr: Transaction, node: Node): void {
|
||||
this.replace(tr, new Slice(Fragment.from(node), 0, 0));
|
||||
}
|
||||
|
||||
public forEachCell(f: (node: Node, pos: number) => void): void {
|
||||
const table = this.$anchorCell.node(-1);
|
||||
const map = TableMap.get(table);
|
||||
const tableStart = this.$anchorCell.start(-1);
|
||||
|
||||
const cells = map.cellsInRect(
|
||||
map.rectBetween(
|
||||
this.$anchorCell.pos - tableStart,
|
||||
this.$headCell.pos - tableStart
|
||||
)
|
||||
);
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
f(table.nodeAt(cells[i])!, tableStart + cells[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// True if this selection goes all the way from the top to the
|
||||
// bottom of the table.
|
||||
public isColSelection(): boolean {
|
||||
const anchorTop = this.$anchorCell.index(-1);
|
||||
const headTop = this.$headCell.index(-1);
|
||||
if (Math.min(anchorTop, headTop) > 0) return false;
|
||||
|
||||
const anchorBottom = anchorTop + this.$anchorCell.nodeAfter!.attrs.rowspan;
|
||||
const headBottom = headTop + this.$headCell.nodeAfter!.attrs.rowspan;
|
||||
|
||||
return (
|
||||
Math.max(anchorBottom, headBottom) == this.$headCell.node(-1).childCount
|
||||
);
|
||||
}
|
||||
|
||||
// Returns the smallest column selection that covers the given anchor
|
||||
// and head cell.
|
||||
public static colSelection(
|
||||
$anchorCell: ResolvedPos,
|
||||
$headCell: ResolvedPos = $anchorCell
|
||||
): CellSelection {
|
||||
const table = $anchorCell.node(-1);
|
||||
const map = TableMap.get(table);
|
||||
const tableStart = $anchorCell.start(-1);
|
||||
|
||||
const anchorRect = map.findCell($anchorCell.pos - tableStart);
|
||||
const headRect = map.findCell($headCell.pos - tableStart);
|
||||
const doc = $anchorCell.node(0);
|
||||
|
||||
if (anchorRect.top <= headRect.top) {
|
||||
if (anchorRect.top > 0)
|
||||
$anchorCell = doc.resolve(tableStart + map.map[anchorRect.left]);
|
||||
if (headRect.bottom < map.height)
|
||||
$headCell = doc.resolve(
|
||||
tableStart +
|
||||
map.map[map.width * (map.height - 1) + headRect.right - 1]
|
||||
);
|
||||
} else {
|
||||
if (headRect.top > 0)
|
||||
$headCell = doc.resolve(tableStart + map.map[headRect.left]);
|
||||
if (anchorRect.bottom < map.height)
|
||||
$anchorCell = doc.resolve(
|
||||
tableStart +
|
||||
map.map[map.width * (map.height - 1) + anchorRect.right - 1]
|
||||
);
|
||||
}
|
||||
return new CellSelection($anchorCell, $headCell);
|
||||
}
|
||||
|
||||
// True if this selection goes all the way from the left to the
|
||||
// right of the table.
|
||||
public isRowSelection(): boolean {
|
||||
const table = this.$anchorCell.node(-1);
|
||||
const map = TableMap.get(table);
|
||||
const tableStart = this.$anchorCell.start(-1);
|
||||
|
||||
const anchorLeft = map.colCount(this.$anchorCell.pos - tableStart);
|
||||
const headLeft = map.colCount(this.$headCell.pos - tableStart);
|
||||
if (Math.min(anchorLeft, headLeft) > 0) return false;
|
||||
|
||||
const anchorRight = anchorLeft + this.$anchorCell.nodeAfter!.attrs.colspan;
|
||||
const headRight = headLeft + this.$headCell.nodeAfter!.attrs.colspan;
|
||||
return Math.max(anchorRight, headRight) == map.width;
|
||||
}
|
||||
|
||||
public eq(other: unknown): boolean {
|
||||
return (
|
||||
other instanceof CellSelection &&
|
||||
other.$anchorCell.pos == this.$anchorCell.pos &&
|
||||
other.$headCell.pos == this.$headCell.pos
|
||||
);
|
||||
}
|
||||
|
||||
// Returns the smallest row selection that covers the given anchor
|
||||
// and head cell.
|
||||
public static rowSelection(
|
||||
$anchorCell: ResolvedPos,
|
||||
$headCell: ResolvedPos = $anchorCell
|
||||
): CellSelection {
|
||||
const table = $anchorCell.node(-1);
|
||||
const map = TableMap.get(table);
|
||||
const tableStart = $anchorCell.start(-1);
|
||||
|
||||
const anchorRect = map.findCell($anchorCell.pos - tableStart);
|
||||
const headRect = map.findCell($headCell.pos - tableStart);
|
||||
const doc = $anchorCell.node(0);
|
||||
|
||||
if (anchorRect.left <= headRect.left) {
|
||||
if (anchorRect.left > 0)
|
||||
$anchorCell = doc.resolve(
|
||||
tableStart + map.map[anchorRect.top * map.width]
|
||||
);
|
||||
if (headRect.right < map.width)
|
||||
$headCell = doc.resolve(
|
||||
tableStart + map.map[map.width * (headRect.top + 1) - 1]
|
||||
);
|
||||
} else {
|
||||
if (headRect.left > 0)
|
||||
$headCell = doc.resolve(tableStart + map.map[headRect.top * map.width]);
|
||||
if (anchorRect.right < map.width)
|
||||
$anchorCell = doc.resolve(
|
||||
tableStart + map.map[map.width * (anchorRect.top + 1) - 1]
|
||||
);
|
||||
}
|
||||
return new CellSelection($anchorCell, $headCell);
|
||||
}
|
||||
|
||||
public toJSON(): CellSelectionJSON {
|
||||
return {
|
||||
type: "cell",
|
||||
anchor: this.$anchorCell.pos,
|
||||
head: this.$headCell.pos
|
||||
};
|
||||
}
|
||||
|
||||
static fromJSON(doc: Node, json: CellSelectionJSON): CellSelection {
|
||||
return new CellSelection(doc.resolve(json.anchor), doc.resolve(json.head));
|
||||
}
|
||||
|
||||
static create(
|
||||
doc: Node,
|
||||
anchorCell: number,
|
||||
headCell: number = anchorCell
|
||||
): CellSelection {
|
||||
return new CellSelection(doc.resolve(anchorCell), doc.resolve(headCell));
|
||||
}
|
||||
|
||||
getBookmark(): CellBookmark {
|
||||
return new CellBookmark(this.$anchorCell.pos, this.$headCell.pos);
|
||||
}
|
||||
}
|
||||
|
||||
CellSelection.prototype.visible = false;
|
||||
|
||||
Selection.jsonID("cell", CellSelection);
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export class CellBookmark {
|
||||
constructor(public anchor: number, public head: number) {}
|
||||
|
||||
map(mapping: Mappable): CellBookmark {
|
||||
return new CellBookmark(mapping.map(this.anchor), mapping.map(this.head));
|
||||
}
|
||||
|
||||
resolve(doc: Node): CellSelection | Selection {
|
||||
const $anchorCell = doc.resolve(this.anchor),
|
||||
$headCell = doc.resolve(this.head);
|
||||
if (
|
||||
$anchorCell.parent.type.spec.tableRole == "row" &&
|
||||
$headCell.parent.type.spec.tableRole == "row" &&
|
||||
$anchorCell.index() < $anchorCell.parent.childCount &&
|
||||
$headCell.index() < $headCell.parent.childCount &&
|
||||
inSameTable($anchorCell, $headCell)
|
||||
)
|
||||
return new CellSelection($anchorCell, $headCell);
|
||||
else return Selection.near($headCell, 1);
|
||||
}
|
||||
}
|
||||
|
||||
export function drawCellSelection(state: EditorState): DecorationSource | null {
|
||||
if (!(state.selection instanceof CellSelection)) return null;
|
||||
const cells: Decoration[] = [];
|
||||
state.selection.forEachCell((node, pos) => {
|
||||
cells.push(
|
||||
Decoration.node(pos, pos + node.nodeSize, { class: "selectedCell" })
|
||||
);
|
||||
});
|
||||
return DecorationSet.create(state.doc, cells);
|
||||
}
|
||||
|
||||
function isCellBoundarySelection({ $from, $to }: TextSelection) {
|
||||
if ($from.pos == $to.pos || $from.pos < $to.pos - 6) return false; // Cheap elimination
|
||||
let afterFrom = $from.pos;
|
||||
let beforeTo = $to.pos;
|
||||
let depth = $from.depth;
|
||||
for (; depth >= 0; depth--, afterFrom++)
|
||||
if ($from.after(depth + 1) < $from.end(depth)) break;
|
||||
for (let d = $to.depth; d >= 0; d--, beforeTo--)
|
||||
if ($to.before(d + 1) > $to.start(d)) break;
|
||||
return (
|
||||
afterFrom == beforeTo &&
|
||||
/row|table/.test($from.node(depth).type.spec.tableRole)
|
||||
);
|
||||
}
|
||||
|
||||
function isTextSelectionAcrossCells({ $from, $to }: TextSelection) {
|
||||
let fromCellBoundaryNode: Node | undefined;
|
||||
let toCellBoundaryNode: Node | undefined;
|
||||
|
||||
for (let i = $from.depth; i > 0; i--) {
|
||||
const node = $from.node(i);
|
||||
if (
|
||||
node.type.spec.tableRole === "cell" ||
|
||||
node.type.spec.tableRole === "header_cell"
|
||||
) {
|
||||
fromCellBoundaryNode = node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = $to.depth; i > 0; i--) {
|
||||
const node = $to.node(i);
|
||||
if (
|
||||
node.type.spec.tableRole === "cell" ||
|
||||
node.type.spec.tableRole === "header_cell"
|
||||
) {
|
||||
toCellBoundaryNode = node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return fromCellBoundaryNode !== toCellBoundaryNode && $to.parentOffset === 0;
|
||||
}
|
||||
|
||||
export function normalizeSelection(
|
||||
state: EditorState,
|
||||
tr: Transaction | undefined,
|
||||
oldState: EditorState,
|
||||
allowTableNodeSelection: boolean
|
||||
): Transaction | undefined {
|
||||
const sel = (tr || state).selection;
|
||||
const doc = (tr || state).doc;
|
||||
let normalize: Selection | undefined;
|
||||
let role: string | undefined;
|
||||
if (sel instanceof NodeSelection && (role = sel.node.type.spec.tableRole)) {
|
||||
if (role == "cell" || role == "header_cell") {
|
||||
normalize = CellSelection.create(doc, sel.from);
|
||||
} else if (role == "row") {
|
||||
const $cell = doc.resolve(sel.from + 1);
|
||||
normalize = CellSelection.rowSelection($cell, $cell);
|
||||
} else if (!allowTableNodeSelection) {
|
||||
const map = TableMap.get(sel.node);
|
||||
const start = sel.from + 1;
|
||||
const lastCell = start + map.map[map.width * map.height - 1];
|
||||
normalize = CellSelection.create(doc, start + 1, lastCell);
|
||||
}
|
||||
} else if (sel instanceof TextSelection && isCellBoundarySelection(sel)) {
|
||||
normalize = TextSelection.create(doc, sel.from);
|
||||
} else if (sel instanceof TextSelection && isTextSelectionAcrossCells(sel)) {
|
||||
normalize = TextSelection.create(doc, sel.$from.start(), sel.$from.end());
|
||||
}
|
||||
if (normalize) (tr || (tr = state.tr)).setSelection(normalize);
|
||||
return tr;
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { Attrs, Node as ProsemirrorNode } from "prosemirror-model";
|
||||
import { EditorState, Plugin, PluginKey, Transaction } from "prosemirror-state";
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
NodeView
|
||||
} from "prosemirror-view";
|
||||
import { tableNodeTypes } from "./schema.js";
|
||||
import { TableMap } from "./tablemap.js";
|
||||
import { TableView, updateColumnsOnResize } from "./tableview.js";
|
||||
import { cellAround, CellAttrs, getClientX } from "./util.js";
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const columnResizingPluginKey = new PluginKey<ResizeState>(
|
||||
"tableColumnResizing"
|
||||
);
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type ColumnResizingOptions = {
|
||||
/**
|
||||
* Minimum width of a cell /column. The column cannot be resized smaller than this.
|
||||
*/
|
||||
cellMinWidth?: number;
|
||||
/**
|
||||
* The default minWidth of a cell / column when it doesn't have an explicit width (i.e.: it has not been resized manually)
|
||||
*/
|
||||
defaultCellMinWidth?: number;
|
||||
/**
|
||||
* A custom node view for the rendering table nodes. By default, the plugin
|
||||
* uses the {@link TableView} class. You can explicitly set this to `null` to
|
||||
* not use a custom node view.
|
||||
*/
|
||||
View?:
|
||||
| (new (
|
||||
node: ProsemirrorNode,
|
||||
cellMinWidth: number,
|
||||
view: EditorView
|
||||
) => NodeView)
|
||||
| null;
|
||||
showResizeHandleOnSelection?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type Dragging = { startX: number; startWidth: number };
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function columnResizing({
|
||||
cellMinWidth = 25,
|
||||
defaultCellMinWidth = 100,
|
||||
View = TableView,
|
||||
showResizeHandleOnSelection = false
|
||||
}: ColumnResizingOptions = {}): Plugin {
|
||||
const plugin = new Plugin<ResizeState>({
|
||||
key: columnResizingPluginKey,
|
||||
state: {
|
||||
init(_, state) {
|
||||
const nodeViews = plugin.spec?.props?.nodeViews;
|
||||
const tableName = tableNodeTypes(state.schema).table.name;
|
||||
if (View && nodeViews) {
|
||||
nodeViews[tableName] = (node, view) => {
|
||||
return new View(node, defaultCellMinWidth, view);
|
||||
};
|
||||
}
|
||||
|
||||
return { dragging: false, decorations: DecorationSet.empty };
|
||||
},
|
||||
apply(tr, prev, _, state) {
|
||||
return createResizeState(tr, state, prev, showResizeHandleOnSelection);
|
||||
}
|
||||
},
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
touchstart: (view, event) => {
|
||||
handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth);
|
||||
},
|
||||
mousedown: (view, event) => {
|
||||
handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth);
|
||||
}
|
||||
},
|
||||
|
||||
decorations: (state) => {
|
||||
const pluginState = columnResizingPluginKey.getState(state);
|
||||
return pluginState?.decorations || DecorationSet.empty;
|
||||
},
|
||||
|
||||
nodeViews: {}
|
||||
}
|
||||
});
|
||||
return plugin;
|
||||
}
|
||||
|
||||
type ResizeState = {
|
||||
dragging: Dragging | false;
|
||||
decorations: DecorationSet;
|
||||
};
|
||||
|
||||
function createResizeState(
|
||||
tr: Transaction,
|
||||
state: EditorState,
|
||||
prevState: ResizeState,
|
||||
showResizeHandleOnSelection: boolean
|
||||
): ResizeState {
|
||||
const action = tr.getMeta(columnResizingPluginKey);
|
||||
const copy: ResizeState = { ...prevState };
|
||||
copy.decorations = tr.docChanged
|
||||
? copy.decorations.map(tr.mapping, tr.doc)
|
||||
: copy.decorations;
|
||||
|
||||
if (!copy.dragging) {
|
||||
const cell = edgeCell(state, state.selection.from, "right");
|
||||
if (cell === -1) {
|
||||
copy.decorations = DecorationSet.empty;
|
||||
} else {
|
||||
const handles = createColumnResizeHandles(
|
||||
state,
|
||||
cell,
|
||||
prevState,
|
||||
showResizeHandleOnSelection
|
||||
);
|
||||
if (handles) {
|
||||
copy.decorations = handles;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (action && action.setDragging !== undefined)
|
||||
copy.dragging = action.setDragging;
|
||||
return copy;
|
||||
}
|
||||
|
||||
function handleMouseDown(
|
||||
view: EditorView,
|
||||
event: MouseEvent | TouchEvent,
|
||||
cellMinWidth: number,
|
||||
defaultCellMinWidth: number
|
||||
): boolean {
|
||||
if (!view.editable) return false;
|
||||
|
||||
const win = view.dom.ownerDocument.defaultView ?? window;
|
||||
|
||||
const pluginState = columnResizingPluginKey.getState(view.state);
|
||||
if (!pluginState || pluginState.dragging) return false;
|
||||
|
||||
if (
|
||||
event.target instanceof HTMLElement &&
|
||||
event.target.closest(".column-resize-handle") == null
|
||||
) {
|
||||
console.log("No handle target");
|
||||
return false;
|
||||
}
|
||||
|
||||
const clientX = getClientX(event);
|
||||
if (clientX === null) return false;
|
||||
|
||||
const activeHandle = edgeCell(
|
||||
view.state,
|
||||
view.posAtDOM(event.target as Node, 0),
|
||||
"right"
|
||||
);
|
||||
const cell = view.state.doc.nodeAt(activeHandle);
|
||||
if (!cell) {
|
||||
console.log("No cell at handle");
|
||||
return false;
|
||||
}
|
||||
|
||||
const width = currentColWidth(view, activeHandle, cell.attrs);
|
||||
view.dispatch(
|
||||
view.state.tr.setMeta(columnResizingPluginKey, {
|
||||
setDragging: { startX: clientX, startWidth: width }
|
||||
})
|
||||
);
|
||||
|
||||
function finish(event: MouseEvent | TouchEvent) {
|
||||
win.removeEventListener("mouseup", finish);
|
||||
win.removeEventListener("mousemove", move);
|
||||
win.removeEventListener("touchend", finish);
|
||||
win.removeEventListener("touchcancel", finish);
|
||||
win.removeEventListener("touchmove", move);
|
||||
|
||||
const clientX = getClientX(event);
|
||||
if (clientX === null) {
|
||||
console.log("No clientX on finish", event);
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginState = columnResizingPluginKey.getState(view.state);
|
||||
if (pluginState?.dragging) {
|
||||
if (event instanceof TouchEvent)
|
||||
(view as any).domObserver.connectSelection();
|
||||
updateColumnWidth(
|
||||
view,
|
||||
activeHandle,
|
||||
draggedWidth(pluginState.dragging, clientX, cellMinWidth)
|
||||
);
|
||||
view.dispatch(
|
||||
view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function move(event: MouseEvent | TouchEvent): void {
|
||||
if (event instanceof MouseEvent && !event.which) return finish(event);
|
||||
const clientX = getClientX(event);
|
||||
if (clientX === null) return;
|
||||
const pluginState = columnResizingPluginKey.getState(view.state);
|
||||
if (pluginState?.dragging) {
|
||||
const dragged = draggedWidth(pluginState.dragging, clientX, cellMinWidth);
|
||||
displayColumnWidth(view, activeHandle, dragged, defaultCellMinWidth);
|
||||
}
|
||||
}
|
||||
|
||||
displayColumnWidth(view, activeHandle, width, defaultCellMinWidth);
|
||||
|
||||
win.addEventListener("mouseup", finish);
|
||||
win.addEventListener("mousemove", move);
|
||||
win.addEventListener("touchend", finish);
|
||||
win.addEventListener("touchcancel", finish);
|
||||
win.addEventListener("touchmove", move);
|
||||
event.preventDefault();
|
||||
if (event instanceof TouchEvent)
|
||||
(view as any).domObserver.disconnectSelection();
|
||||
return true;
|
||||
}
|
||||
|
||||
function currentColWidth(
|
||||
view: EditorView,
|
||||
cellPos: number,
|
||||
{ colspan, colwidth }: Attrs
|
||||
): number {
|
||||
const width = colwidth && colwidth[colwidth.length - 1];
|
||||
if (width) return width;
|
||||
const dom = view.domAtPos(cellPos);
|
||||
const node = dom.node.childNodes[dom.offset] as HTMLElement;
|
||||
let domWidth = node.offsetWidth,
|
||||
parts = colspan;
|
||||
if (colwidth)
|
||||
for (let i = 0; i < colspan; i++)
|
||||
if (colwidth[i]) {
|
||||
domWidth -= colwidth[i];
|
||||
parts--;
|
||||
}
|
||||
return domWidth / parts;
|
||||
}
|
||||
|
||||
function edgeCell(
|
||||
state: EditorState,
|
||||
pos: number,
|
||||
side: "left" | "right"
|
||||
): number {
|
||||
const $cell = cellAround(state.doc.resolve(pos));
|
||||
if (!$cell) return -1;
|
||||
if (side == "right") return $cell.pos;
|
||||
const map = TableMap.get($cell.node(-1)),
|
||||
start = $cell.start(-1);
|
||||
const index = map.map.indexOf($cell.pos - start);
|
||||
return index % map.width == 0 ? -1 : start + map.map[index - 1];
|
||||
}
|
||||
|
||||
function draggedWidth(
|
||||
dragging: Dragging,
|
||||
clientX: number,
|
||||
resizeMinWidth: number
|
||||
): number {
|
||||
const offset = clientX - dragging.startX;
|
||||
return Math.max(resizeMinWidth, dragging.startWidth + offset);
|
||||
}
|
||||
|
||||
function updateColumnWidth(
|
||||
view: EditorView,
|
||||
cell: number,
|
||||
width: number
|
||||
): void {
|
||||
const $cell = view.state.doc.resolve(cell);
|
||||
const table = $cell.node(-1),
|
||||
map = TableMap.get(table),
|
||||
start = $cell.start(-1);
|
||||
const col =
|
||||
map.colCount($cell.pos - start) + $cell.nodeAfter!.attrs.colspan - 1;
|
||||
const tr = view.state.tr;
|
||||
for (let row = 0; row < map.height; row++) {
|
||||
const mapIndex = row * map.width + col;
|
||||
// Rowspanning cell that has already been handled
|
||||
if (row && map.map[mapIndex] == map.map[mapIndex - map.width]) continue;
|
||||
const pos = map.map[mapIndex];
|
||||
const attrs = table.nodeAt(pos)!.attrs as CellAttrs;
|
||||
const index = attrs.colspan == 1 ? 0 : col - map.colCount(pos);
|
||||
if (attrs.colwidth && attrs.colwidth[index] == width) continue;
|
||||
const colwidth = attrs.colwidth
|
||||
? attrs.colwidth.slice()
|
||||
: zeroes(attrs.colspan);
|
||||
colwidth[index] = width;
|
||||
tr.setNodeAttribute(start + pos, "colwidth", colwidth);
|
||||
}
|
||||
if (tr.docChanged) view.dispatch(tr);
|
||||
}
|
||||
|
||||
function displayColumnWidth(
|
||||
view: EditorView,
|
||||
cell: number,
|
||||
width: number,
|
||||
defaultCellMinWidth: number
|
||||
): void {
|
||||
const $cell = view.state.doc.resolve(cell);
|
||||
const table = $cell.node(-1),
|
||||
start = $cell.start(-1);
|
||||
const col =
|
||||
TableMap.get(table).colCount($cell.pos - start) +
|
||||
$cell.nodeAfter!.attrs.colspan -
|
||||
1;
|
||||
let dom: Node | null = view.domAtPos($cell.start(-1)).node;
|
||||
while (dom && dom.nodeName != "TABLE") {
|
||||
dom = dom.parentNode;
|
||||
}
|
||||
if (!dom) return;
|
||||
const tableElement = dom as HTMLTableElement;
|
||||
updateColumnsOnResize(
|
||||
table,
|
||||
dom.firstChild as HTMLTableColElement,
|
||||
tableElement,
|
||||
defaultCellMinWidth,
|
||||
col,
|
||||
width
|
||||
);
|
||||
}
|
||||
|
||||
function zeroes(n: number): 0[] {
|
||||
return Array(n).fill(0);
|
||||
}
|
||||
|
||||
export function createColumnResizeHandles(
|
||||
state: EditorState,
|
||||
activeCellPos: number,
|
||||
resizeState: ResizeState,
|
||||
showResizeHandleOnSelection: boolean
|
||||
): DecorationSet | null {
|
||||
if (activeCellPos === -1) return null;
|
||||
const decorations = [];
|
||||
const activeCell = state.doc.resolve(activeCellPos);
|
||||
const table = activeCell.node(-1);
|
||||
if (!table) return null;
|
||||
|
||||
const map = TableMap.get(table);
|
||||
const start = activeCell.start(-1);
|
||||
const totalCells = map.height * map.width;
|
||||
const cellIndex = map.map.indexOf(activeCell.pos - start);
|
||||
|
||||
const oldDecorations = resizeState.decorations.find();
|
||||
if (oldDecorations.length === totalCells) {
|
||||
const activeCellDecoration = oldDecorations.find(
|
||||
(c) => c.spec.index === cellIndex
|
||||
);
|
||||
if (!activeCellDecoration?.spec.active && showResizeHandleOnSelection) {
|
||||
const oldActiveIndex = oldDecorations.findIndex((d) => d.spec.active);
|
||||
const oldActive = oldDecorations[oldActiveIndex];
|
||||
if (oldActive)
|
||||
oldDecorations[oldActiveIndex] = Decoration.widget(
|
||||
oldActive.from,
|
||||
createResizeHandle(false),
|
||||
{
|
||||
active: false,
|
||||
index: oldActive.spec.index
|
||||
}
|
||||
);
|
||||
|
||||
oldDecorations[cellIndex] = Decoration.widget(
|
||||
oldDecorations[cellIndex].from,
|
||||
createResizeHandle(true),
|
||||
{
|
||||
active: true,
|
||||
index: cellIndex
|
||||
}
|
||||
);
|
||||
|
||||
return DecorationSet.create(state.doc, oldDecorations);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let i = 0; i < totalCells; i++) {
|
||||
const cellPos = map.map[i];
|
||||
const pos = start + cellPos + table.nodeAt(cellPos)!.nodeSize - 1;
|
||||
decorations.push(
|
||||
Decoration.widget(
|
||||
pos,
|
||||
createResizeHandle(showResizeHandleOnSelection && cellIndex === i),
|
||||
{
|
||||
active: showResizeHandleOnSelection && cellIndex === i,
|
||||
index: i
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
return DecorationSet.create(state.doc, decorations);
|
||||
}
|
||||
|
||||
function createResizeHandle(active: boolean) {
|
||||
const dom = document.createElement("div");
|
||||
dom.className = "column-resize-handle";
|
||||
if (active) dom.classList.add("active");
|
||||
dom.onmouseenter = () => {
|
||||
dom.classList.add("active");
|
||||
};
|
||||
dom.onmouseleave = () => {
|
||||
dom.classList.remove("active");
|
||||
};
|
||||
return dom;
|
||||
}
|
||||
1006
packages/editor/src/extensions/table/prosemirror-tables/commands.ts
Normal file
1006
packages/editor/src/extensions/table/prosemirror-tables/commands.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,381 @@
|
||||
// Utilities used for copy/paste handling.
|
||||
//
|
||||
// This module handles pasting cell content into tables, or pasting
|
||||
// anything into a cell selection, as replacing a block of cells with
|
||||
// the content of the selection. When pasting cells into a cell, that
|
||||
// involves placing the block of pasted content so that its top left
|
||||
// aligns with the selection cell, optionally extending the table to
|
||||
// the right or bottom to make sure it is large enough. Pasting into a
|
||||
// cell selection is different, here the cells in the selection are
|
||||
// clipped to the selection's rectangle, optionally repeating the
|
||||
// pasted cells when they are smaller than the selection.
|
||||
|
||||
import { Fragment, Node, NodeType, Schema, Slice } from "prosemirror-model";
|
||||
import { Transform } from "prosemirror-transform";
|
||||
|
||||
import { EditorState, Transaction } from "prosemirror-state";
|
||||
import { CellSelection } from "./cellselection.js";
|
||||
import { tableNodeTypes } from "./schema.js";
|
||||
import { ColWidths, Rect, TableMap } from "./tablemap.js";
|
||||
import { CellAttrs, removeColSpan } from "./util.js";
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export type Area = { width: number; height: number; rows: Fragment[] };
|
||||
|
||||
// Utilities to help with copying and pasting table cells
|
||||
|
||||
/**
|
||||
* Get a rectangular area of cells from a slice, or null if the outer
|
||||
* nodes of the slice aren't table cells or rows.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function pastedCells(slice: Slice): Area | null {
|
||||
if (!slice.size) return null;
|
||||
let { content, openStart, openEnd } = slice;
|
||||
while (
|
||||
content.childCount == 1 &&
|
||||
((openStart > 0 && openEnd > 0) ||
|
||||
content.child(0).type.spec.tableRole == "table")
|
||||
) {
|
||||
openStart--;
|
||||
openEnd--;
|
||||
content = content.child(0).content;
|
||||
}
|
||||
const first = content.child(0);
|
||||
const role = first.type.spec.tableRole;
|
||||
const schema = first.type.schema,
|
||||
rows = [];
|
||||
if (role == "row") {
|
||||
for (let i = 0; i < content.childCount; i++) {
|
||||
let cells = content.child(i).content;
|
||||
const left = i ? 0 : Math.max(0, openStart - 1);
|
||||
const right = i < content.childCount - 1 ? 0 : Math.max(0, openEnd - 1);
|
||||
if (left || right)
|
||||
cells = fitSlice(
|
||||
tableNodeTypes(schema).row,
|
||||
new Slice(cells, left, right)
|
||||
).content;
|
||||
rows.push(cells);
|
||||
}
|
||||
} else if (role == "cell" || role == "header_cell") {
|
||||
rows.push(
|
||||
openStart || openEnd
|
||||
? fitSlice(
|
||||
tableNodeTypes(schema).row,
|
||||
new Slice(content, openStart, openEnd)
|
||||
).content
|
||||
: content
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return ensureRectangular(schema, rows);
|
||||
}
|
||||
|
||||
// Compute the width and height of a set of cells, and make sure each
|
||||
// row has the same number of cells.
|
||||
function ensureRectangular(schema: Schema, rows: Fragment[]): Area {
|
||||
const widths: ColWidths = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
for (let j = row.childCount - 1; j >= 0; j--) {
|
||||
const { rowspan, colspan } = row.child(j).attrs;
|
||||
for (let r = i; r < i + rowspan; r++)
|
||||
widths[r] = (widths[r] || 0) + colspan;
|
||||
}
|
||||
}
|
||||
let width = 0;
|
||||
for (let r = 0; r < widths.length; r++) width = Math.max(width, widths[r]);
|
||||
for (let r = 0; r < widths.length; r++) {
|
||||
if (r >= rows.length) rows.push(Fragment.empty);
|
||||
if (widths[r] < width) {
|
||||
const empty = tableNodeTypes(schema).cell.createAndFill()!;
|
||||
const cells = [];
|
||||
for (let i = widths[r]; i < width; i++) {
|
||||
cells.push(empty);
|
||||
}
|
||||
rows[r] = rows[r].append(Fragment.from(cells));
|
||||
}
|
||||
}
|
||||
return { height: rows.length, width, rows };
|
||||
}
|
||||
|
||||
export function fitSlice(nodeType: NodeType, slice: Slice): Node {
|
||||
const node = nodeType.createAndFill()!;
|
||||
const tr = new Transform(node).replace(0, node.content.size, slice);
|
||||
return tr.doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clip or extend (repeat) the given set of cells to cover the given
|
||||
* width and height. Will clip rowspan/colspan cells at the edges when
|
||||
* they stick out.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function clipCells(
|
||||
{ width, height, rows }: Area,
|
||||
newWidth: number,
|
||||
newHeight: number
|
||||
): Area {
|
||||
if (width != newWidth) {
|
||||
const added: number[] = [];
|
||||
const newRows: Fragment[] = [];
|
||||
for (let row = 0; row < rows.length; row++) {
|
||||
const frag = rows[row],
|
||||
cells = [];
|
||||
for (let col = added[row] || 0, i = 0; col < newWidth; i++) {
|
||||
let cell = frag.child(i % frag.childCount);
|
||||
if (col + cell.attrs.colspan > newWidth)
|
||||
cell = cell.type.createChecked(
|
||||
removeColSpan(
|
||||
cell.attrs as CellAttrs,
|
||||
cell.attrs.colspan,
|
||||
col + cell.attrs.colspan - newWidth
|
||||
),
|
||||
cell.content
|
||||
);
|
||||
cells.push(cell);
|
||||
col += cell.attrs.colspan;
|
||||
for (let j = 1; j < cell.attrs.rowspan; j++)
|
||||
added[row + j] = (added[row + j] || 0) + cell.attrs.colspan;
|
||||
}
|
||||
newRows.push(Fragment.from(cells));
|
||||
}
|
||||
rows = newRows;
|
||||
width = newWidth;
|
||||
}
|
||||
|
||||
if (height != newHeight) {
|
||||
const newRows = [];
|
||||
for (let row = 0, i = 0; row < newHeight; row++, i++) {
|
||||
const cells = [],
|
||||
source = rows[i % height];
|
||||
for (let j = 0; j < source.childCount; j++) {
|
||||
let cell = source.child(j);
|
||||
if (row + cell.attrs.rowspan > newHeight)
|
||||
cell = cell.type.create(
|
||||
{
|
||||
...cell.attrs,
|
||||
rowspan: Math.max(1, newHeight - cell.attrs.rowspan)
|
||||
},
|
||||
cell.content
|
||||
);
|
||||
cells.push(cell);
|
||||
}
|
||||
newRows.push(Fragment.from(cells));
|
||||
}
|
||||
rows = newRows;
|
||||
height = newHeight;
|
||||
}
|
||||
|
||||
return { width, height, rows };
|
||||
}
|
||||
|
||||
// Make sure a table has at least the given width and height. Return
|
||||
// true if something was changed.
|
||||
function growTable(
|
||||
tr: Transaction,
|
||||
map: TableMap,
|
||||
table: Node,
|
||||
start: number,
|
||||
width: number,
|
||||
height: number,
|
||||
mapFrom: number
|
||||
): boolean {
|
||||
const schema = tr.doc.type.schema;
|
||||
const types = tableNodeTypes(schema);
|
||||
let empty;
|
||||
let emptyHead;
|
||||
if (width > map.width) {
|
||||
for (let row = 0, rowEnd = 0; row < map.height; row++) {
|
||||
const rowNode = table.child(row);
|
||||
rowEnd += rowNode.nodeSize;
|
||||
const cells: Node[] = [];
|
||||
let add: Node;
|
||||
if (rowNode.lastChild == null || rowNode.lastChild.type == types.cell)
|
||||
add = empty || (empty = types.cell.createAndFill()!);
|
||||
else add = emptyHead || (emptyHead = types.header_cell.createAndFill()!);
|
||||
for (let i = map.width; i < width; i++) cells.push(add);
|
||||
tr.insert(tr.mapping.slice(mapFrom).map(rowEnd - 1 + start), cells);
|
||||
}
|
||||
}
|
||||
if (height > map.height) {
|
||||
const cells = [];
|
||||
for (
|
||||
let i = 0, start = (map.height - 1) * map.width;
|
||||
i < Math.max(map.width, width);
|
||||
i++
|
||||
) {
|
||||
const header =
|
||||
i >= map.width
|
||||
? false
|
||||
: table.nodeAt(map.map[start + i])!.type == types.header_cell;
|
||||
cells.push(
|
||||
header
|
||||
? emptyHead || (emptyHead = types.header_cell.createAndFill()!)
|
||||
: empty || (empty = types.cell.createAndFill()!)
|
||||
);
|
||||
}
|
||||
|
||||
const emptyRow = types.row.create(null, Fragment.from(cells)),
|
||||
rows = [];
|
||||
for (let i = map.height; i < height; i++) rows.push(emptyRow);
|
||||
tr.insert(tr.mapping.slice(mapFrom).map(start + table.nodeSize - 2), rows);
|
||||
}
|
||||
return !!(empty || emptyHead);
|
||||
}
|
||||
|
||||
// Make sure the given line (left, top) to (right, top) doesn't cross
|
||||
// any rowspan cells by splitting cells that cross it. Return true if
|
||||
// something changed.
|
||||
function isolateHorizontal(
|
||||
tr: Transaction,
|
||||
map: TableMap,
|
||||
table: Node,
|
||||
start: number,
|
||||
left: number,
|
||||
right: number,
|
||||
top: number,
|
||||
mapFrom: number
|
||||
): boolean {
|
||||
if (top == 0 || top == map.height) return false;
|
||||
let found = false;
|
||||
for (let col = left; col < right; col++) {
|
||||
const index = top * map.width + col,
|
||||
pos = map.map[index];
|
||||
if (map.map[index - map.width] == pos) {
|
||||
found = true;
|
||||
const cell = table.nodeAt(pos)!;
|
||||
const { top: cellTop, left: cellLeft } = map.findCell(pos);
|
||||
tr.setNodeMarkup(tr.mapping.slice(mapFrom).map(pos + start), null, {
|
||||
...cell.attrs,
|
||||
rowspan: top - cellTop
|
||||
});
|
||||
tr.insert(
|
||||
tr.mapping.slice(mapFrom).map(map.positionAt(top, cellLeft, table)),
|
||||
cell.type.createAndFill({
|
||||
...cell.attrs,
|
||||
rowspan: cellTop + cell.attrs.rowspan - top
|
||||
})!
|
||||
);
|
||||
col += cell.attrs.colspan - 1;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// Make sure the given line (left, top) to (left, bottom) doesn't
|
||||
// cross any colspan cells by splitting cells that cross it. Return
|
||||
// true if something changed.
|
||||
function isolateVertical(
|
||||
tr: Transaction,
|
||||
map: TableMap,
|
||||
table: Node,
|
||||
start: number,
|
||||
top: number,
|
||||
bottom: number,
|
||||
left: number,
|
||||
mapFrom: number
|
||||
): boolean {
|
||||
if (left == 0 || left == map.width) return false;
|
||||
let found = false;
|
||||
for (let row = top; row < bottom; row++) {
|
||||
const index = row * map.width + left,
|
||||
pos = map.map[index];
|
||||
if (map.map[index - 1] == pos) {
|
||||
found = true;
|
||||
const cell = table.nodeAt(pos)!;
|
||||
const cellLeft = map.colCount(pos);
|
||||
const updatePos = tr.mapping.slice(mapFrom).map(pos + start);
|
||||
tr.setNodeMarkup(
|
||||
updatePos,
|
||||
null,
|
||||
removeColSpan(
|
||||
cell.attrs as CellAttrs,
|
||||
left - cellLeft,
|
||||
cell.attrs.colspan - (left - cellLeft)
|
||||
)
|
||||
);
|
||||
tr.insert(
|
||||
updatePos + cell.nodeSize,
|
||||
cell.type.createAndFill(
|
||||
removeColSpan(cell.attrs as CellAttrs, 0, left - cellLeft)
|
||||
)!
|
||||
);
|
||||
row += cell.attrs.rowspan - 1;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the given set of cells (as returned by `pastedCells`) into a
|
||||
* table, at the position pointed at by rect.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function insertCells(
|
||||
state: EditorState,
|
||||
dispatch: (tr: Transaction) => void,
|
||||
tableStart: number,
|
||||
rect: Rect,
|
||||
cells: Area
|
||||
): void {
|
||||
let table = tableStart ? state.doc.nodeAt(tableStart - 1) : state.doc;
|
||||
if (!table) {
|
||||
throw new Error("No table found");
|
||||
}
|
||||
let map = TableMap.get(table);
|
||||
const { top, left } = rect;
|
||||
const right = left + cells.width,
|
||||
bottom = top + cells.height;
|
||||
const tr = state.tr;
|
||||
let mapFrom = 0;
|
||||
|
||||
function recomp(): void {
|
||||
table = tableStart ? tr.doc.nodeAt(tableStart - 1) : tr.doc;
|
||||
if (!table) {
|
||||
throw new Error("No table found");
|
||||
}
|
||||
map = TableMap.get(table);
|
||||
mapFrom = tr.mapping.maps.length;
|
||||
}
|
||||
|
||||
// Prepare the table to be large enough and not have any cells
|
||||
// crossing the boundaries of the rectangle that we want to
|
||||
// insert into. If anything about it changes, recompute the table
|
||||
// map so that subsequent operations can see the current shape.
|
||||
if (growTable(tr, map, table, tableStart, right, bottom, mapFrom)) recomp();
|
||||
if (isolateHorizontal(tr, map, table, tableStart, left, right, top, mapFrom))
|
||||
recomp();
|
||||
if (
|
||||
isolateHorizontal(tr, map, table, tableStart, left, right, bottom, mapFrom)
|
||||
)
|
||||
recomp();
|
||||
if (isolateVertical(tr, map, table, tableStart, top, bottom, left, mapFrom))
|
||||
recomp();
|
||||
if (isolateVertical(tr, map, table, tableStart, top, bottom, right, mapFrom))
|
||||
recomp();
|
||||
|
||||
for (let row = top; row < bottom; row++) {
|
||||
const from = map.positionAt(row, left, table),
|
||||
to = map.positionAt(row, right, table);
|
||||
tr.replace(
|
||||
tr.mapping.slice(mapFrom).map(from + tableStart),
|
||||
tr.mapping.slice(mapFrom).map(to + tableStart),
|
||||
new Slice(cells.rows[row - top], 0, 0)
|
||||
);
|
||||
}
|
||||
recomp();
|
||||
tr.setSelection(
|
||||
new CellSelection(
|
||||
tr.doc.resolve(tableStart + map.positionAt(top, left, table)),
|
||||
tr.doc.resolve(tableStart + map.positionAt(bottom - 1, right - 1, table))
|
||||
)
|
||||
);
|
||||
dispatch(tr);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// This file defines helpers for normalizing tables, making sure no
|
||||
// cells overlap (which can happen, if you have the wrong col- and
|
||||
// rowspans) and that each row has the same width. Uses the problems
|
||||
// reported by `TableMap`.
|
||||
|
||||
import { Node } from "prosemirror-model";
|
||||
import { EditorState, PluginKey, Transaction } from "prosemirror-state";
|
||||
import { tableNodeTypes, TableRole } from "./schema.js";
|
||||
import { TableMap } from "./tablemap.js";
|
||||
import { CellAttrs, removeColSpan } from "./util.js";
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const fixTablesKey = new PluginKey<{ fixTables: boolean }>("fix-tables");
|
||||
|
||||
/**
|
||||
* Helper for iterating through the nodes in a document that changed
|
||||
* compared to the given previous document. Useful for avoiding
|
||||
* duplicate work on each transaction.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
function changedDescendants(
|
||||
old: Node,
|
||||
cur: Node,
|
||||
offset: number,
|
||||
f: (node: Node, pos: number) => void
|
||||
): void {
|
||||
const oldSize = old.childCount,
|
||||
curSize = cur.childCount;
|
||||
outer: for (let i = 0, j = 0; i < curSize; i++) {
|
||||
const child = cur.child(i);
|
||||
for (let scan = j, e = Math.min(oldSize, i + 3); scan < e; scan++) {
|
||||
if (old.child(scan) == child) {
|
||||
j = scan + 1;
|
||||
offset += child.nodeSize;
|
||||
continue outer;
|
||||
}
|
||||
}
|
||||
f(child, offset);
|
||||
if (j < oldSize && old.child(j).sameMarkup(child))
|
||||
changedDescendants(old.child(j), child, offset + 1, f);
|
||||
else child.nodesBetween(0, child.content.size, f, offset + 1);
|
||||
offset += child.nodeSize;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect all tables in the given state's document and return a
|
||||
* transaction that fixes them, if necessary. If `oldState` was
|
||||
* provided, that is assumed to hold a previous, known-good state,
|
||||
* which will be used to avoid re-scanning unchanged parts of the
|
||||
* document.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function fixTables(
|
||||
state: EditorState,
|
||||
oldState?: EditorState
|
||||
): Transaction | undefined {
|
||||
let tr: Transaction | undefined;
|
||||
const check = (node: Node, pos: number) => {
|
||||
if (node.type.spec.tableRole == "table")
|
||||
tr = fixTable(state, node, pos, tr);
|
||||
};
|
||||
if (!oldState) state.doc.descendants(check);
|
||||
else if (oldState.doc != state.doc)
|
||||
changedDescendants(oldState.doc, state.doc, 0, check);
|
||||
return tr;
|
||||
}
|
||||
|
||||
// Fix the given table, if necessary. Will append to the transaction
|
||||
// it was given, if non-null, or create a new one if necessary.
|
||||
export function fixTable(
|
||||
state: EditorState,
|
||||
table: Node,
|
||||
tablePos: number,
|
||||
tr: Transaction | undefined
|
||||
): Transaction | undefined {
|
||||
const map = TableMap.get(table);
|
||||
if (!map.problems) return tr;
|
||||
if (!tr) tr = state.tr;
|
||||
|
||||
// Track which rows we must add cells to, so that we can adjust that
|
||||
// when fixing collisions.
|
||||
const mustAdd: number[] = [];
|
||||
for (let i = 0; i < map.height; i++) mustAdd.push(0);
|
||||
for (let i = 0; i < map.problems.length; i++) {
|
||||
const prob = map.problems[i];
|
||||
if (prob.type == "collision") {
|
||||
const cell = table.nodeAt(prob.pos);
|
||||
if (!cell) continue;
|
||||
const attrs = cell.attrs as CellAttrs;
|
||||
for (let j = 0; j < attrs.rowspan; j++) mustAdd[prob.row + j] += prob.n;
|
||||
tr.setNodeMarkup(
|
||||
tr.mapping.map(tablePos + 1 + prob.pos),
|
||||
null,
|
||||
removeColSpan(attrs, attrs.colspan - prob.n, prob.n)
|
||||
);
|
||||
} else if (prob.type == "missing") {
|
||||
mustAdd[prob.row] += prob.n;
|
||||
} else if (prob.type == "overlong_rowspan") {
|
||||
const cell = table.nodeAt(prob.pos);
|
||||
if (!cell) continue;
|
||||
tr.setNodeMarkup(tr.mapping.map(tablePos + 1 + prob.pos), null, {
|
||||
...cell.attrs,
|
||||
rowspan: cell.attrs.rowspan - prob.n
|
||||
});
|
||||
} else if (prob.type == "colwidth mismatch") {
|
||||
const cell = table.nodeAt(prob.pos);
|
||||
if (!cell) continue;
|
||||
tr.setNodeMarkup(tr.mapping.map(tablePos + 1 + prob.pos), null, {
|
||||
...cell.attrs,
|
||||
colwidth: prob.colwidth
|
||||
});
|
||||
} else if (prob.type == "zero_sized") {
|
||||
const pos = tr.mapping.map(tablePos);
|
||||
tr.delete(pos, pos + table.nodeSize);
|
||||
}
|
||||
}
|
||||
let first, last;
|
||||
for (let i = 0; i < mustAdd.length; i++)
|
||||
if (mustAdd[i]) {
|
||||
if (first == null) first = i;
|
||||
last = i;
|
||||
}
|
||||
// Add the necessary cells, using a heuristic for whether to add the
|
||||
// cells at the start or end of the rows (if it looks like a 'bite'
|
||||
// was taken out of the table, add cells at the start of the row
|
||||
// after the bite. Otherwise add them at the end).
|
||||
for (let i = 0, pos = tablePos + 1; i < map.height; i++) {
|
||||
const row = table.child(i);
|
||||
const end = pos + row.nodeSize;
|
||||
const add = mustAdd[i];
|
||||
if (add > 0) {
|
||||
let role: TableRole = "cell";
|
||||
if (row.firstChild) {
|
||||
role = row.firstChild.type.spec.tableRole;
|
||||
}
|
||||
const nodes: Node[] = [];
|
||||
for (let j = 0; j < add; j++) {
|
||||
const node = tableNodeTypes(state.schema)[role].createAndFill();
|
||||
|
||||
if (node) nodes.push(node);
|
||||
}
|
||||
const side = (i == 0 || first == i - 1) && last == i ? pos + 1 : end - 1;
|
||||
tr.insert(tr.mapping.map(side), nodes);
|
||||
}
|
||||
pos = end;
|
||||
}
|
||||
return tr.setMeta(fixTablesKey, { fixTables: true });
|
||||
}
|
||||
101
packages/editor/src/extensions/table/prosemirror-tables/index.ts
Normal file
101
packages/editor/src/extensions/table/prosemirror-tables/index.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// This file defines a plugin that handles the drawing of cell
|
||||
// selections and the basic user interactions for creating and working
|
||||
// with such selections. It also makes sure that, after each
|
||||
// transaction, the shapes of tables are normalized to be rectangular
|
||||
// and not contain overlapping cells.
|
||||
|
||||
import { Plugin } from "prosemirror-state";
|
||||
|
||||
import { drawCellSelection, normalizeSelection } from "./cellselection.js";
|
||||
import { fixTables, fixTablesKey } from "./fixtables.js";
|
||||
import {
|
||||
handleKeyDown,
|
||||
handleMouseDown,
|
||||
handlePaste,
|
||||
handleTripleClick
|
||||
} from "./input.js";
|
||||
import { tableEditingKey } from "./util.js";
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type TableEditingOptions = {
|
||||
/**
|
||||
* Whether to allow table node selection.
|
||||
*
|
||||
* By default, any node selection wrapping a table will be converted into a
|
||||
* CellSelection wrapping all cells in the table. You can pass `true` to allow
|
||||
* the selection to remain a NodeSelection.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
allowTableNodeSelection?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a [plugin](http://prosemirror.net/docs/ref/#state.Plugin)
|
||||
* that, when added to an editor, enables cell-selection, handles
|
||||
* cell-based copy/paste, and makes sure tables stay well-formed (each
|
||||
* row has the same width, and cells don't overlap).
|
||||
*
|
||||
* You should probably put this plugin near the end of your array of
|
||||
* plugins, since it handles mouse and arrow key events in tables
|
||||
* rather broadly, and other plugins, like the gap cursor or the
|
||||
* column-width dragging plugin, might want to get a turn first to
|
||||
* perform more specific behavior.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function tableEditing({
|
||||
allowTableNodeSelection = false
|
||||
}: TableEditingOptions = {}): Plugin {
|
||||
return new Plugin({
|
||||
key: tableEditingKey,
|
||||
|
||||
// This piece of state is used to remember when a mouse-drag
|
||||
// cell-selection is happening, so that it can continue even as
|
||||
// transactions (which might move its anchor cell) come in.
|
||||
state: {
|
||||
init() {
|
||||
return null;
|
||||
},
|
||||
apply(tr, cur) {
|
||||
const set = tr.getMeta(tableEditingKey);
|
||||
if (set != null) return set == -1 ? null : set;
|
||||
if (cur == null || !tr.docChanged) return cur;
|
||||
const { deleted, pos } = tr.mapping.mapResult(cur);
|
||||
return deleted ? null : pos;
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
decorations: drawCellSelection,
|
||||
|
||||
handleDOMEvents: {
|
||||
mousedown: handleMouseDown,
|
||||
touchstart: handleMouseDown
|
||||
},
|
||||
|
||||
createSelectionBetween(view) {
|
||||
return tableEditingKey.getState(view.state) != null
|
||||
? view.state.selection
|
||||
: null;
|
||||
},
|
||||
|
||||
handleTripleClick,
|
||||
|
||||
handleKeyDown,
|
||||
|
||||
handlePaste
|
||||
},
|
||||
|
||||
appendTransaction(transactions, oldState, state) {
|
||||
return normalizeSelection(
|
||||
state,
|
||||
fixTables(state, oldState),
|
||||
oldState,
|
||||
allowTableNodeSelection
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
315
packages/editor/src/extensions/table/prosemirror-tables/input.ts
Normal file
315
packages/editor/src/extensions/table/prosemirror-tables/input.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
// This file defines a number of helpers for wiring up user input to
|
||||
// table-related functionality.
|
||||
|
||||
import { keydownHandler } from "prosemirror-keymap";
|
||||
import { Fragment, ResolvedPos, Slice } from "prosemirror-model";
|
||||
import {
|
||||
Command,
|
||||
EditorState,
|
||||
Selection,
|
||||
TextSelection,
|
||||
Transaction
|
||||
} from "prosemirror-state";
|
||||
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import { CellSelection } from "./cellselection.js";
|
||||
import { deleteCellSelection } from "./commands.js";
|
||||
import { clipCells, fitSlice, insertCells, pastedCells } from "./copypaste.js";
|
||||
import { tableNodeTypes } from "./schema.js";
|
||||
import { TableMap } from "./tablemap.js";
|
||||
import {
|
||||
cellAround,
|
||||
getClientX,
|
||||
getClientY,
|
||||
inSameTable,
|
||||
isInTable,
|
||||
nextCell,
|
||||
selectionCell,
|
||||
tableEditingKey
|
||||
} from "./util.js";
|
||||
import { columnResizingPluginKey } from "./columnresizing.js";
|
||||
|
||||
type Axis = "horiz" | "vert";
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type Direction = -1 | 1;
|
||||
|
||||
export const handleKeyDown = keydownHandler({
|
||||
ArrowLeft: arrow("horiz", -1),
|
||||
ArrowRight: arrow("horiz", 1),
|
||||
ArrowUp: arrow("vert", -1),
|
||||
ArrowDown: arrow("vert", 1),
|
||||
|
||||
"Shift-ArrowLeft": shiftArrow("horiz", -1),
|
||||
"Shift-ArrowRight": shiftArrow("horiz", 1),
|
||||
"Shift-ArrowUp": shiftArrow("vert", -1),
|
||||
"Shift-ArrowDown": shiftArrow("vert", 1),
|
||||
|
||||
Backspace: deleteCellSelection,
|
||||
"Mod-Backspace": deleteCellSelection,
|
||||
Delete: deleteCellSelection,
|
||||
"Mod-Delete": deleteCellSelection
|
||||
});
|
||||
|
||||
function maybeSetSelection(
|
||||
state: EditorState,
|
||||
dispatch: undefined | ((tr: Transaction) => void),
|
||||
selection: Selection
|
||||
): boolean {
|
||||
if (selection.eq(state.selection)) return false;
|
||||
if (dispatch) dispatch(state.tr.setSelection(selection).scrollIntoView());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function arrow(axis: Axis, dir: Direction): Command {
|
||||
return (state, dispatch, view) => {
|
||||
if (!view) return false;
|
||||
const sel = state.selection;
|
||||
if (sel instanceof CellSelection) {
|
||||
return maybeSetSelection(
|
||||
state,
|
||||
dispatch,
|
||||
Selection.near(sel.$headCell, dir)
|
||||
);
|
||||
}
|
||||
if (axis != "horiz" && !sel.empty) return false;
|
||||
const end = atEndOfCell(view, axis, dir);
|
||||
if (end == null) return false;
|
||||
if (axis == "horiz") {
|
||||
return maybeSetSelection(
|
||||
state,
|
||||
dispatch,
|
||||
Selection.near(state.doc.resolve(sel.head + dir), dir)
|
||||
);
|
||||
} else {
|
||||
const $cell = state.doc.resolve(end);
|
||||
const $next = nextCell($cell, axis, dir);
|
||||
let newSel;
|
||||
if ($next) newSel = Selection.near($next, 1);
|
||||
else if (dir < 0)
|
||||
newSel = Selection.near(state.doc.resolve($cell.before(-1)), -1);
|
||||
else newSel = Selection.near(state.doc.resolve($cell.after(-1)), 1);
|
||||
return maybeSetSelection(state, dispatch, newSel);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function shiftArrow(axis: Axis, dir: Direction): Command {
|
||||
return (state, dispatch, view) => {
|
||||
if (!view) return false;
|
||||
const sel = state.selection;
|
||||
let cellSel: CellSelection;
|
||||
if (sel instanceof CellSelection) {
|
||||
cellSel = sel;
|
||||
} else {
|
||||
const end = atEndOfCell(view, axis, dir);
|
||||
if (end == null) return false;
|
||||
cellSel = new CellSelection(state.doc.resolve(end));
|
||||
}
|
||||
|
||||
const $head = nextCell(cellSel.$headCell, axis, dir);
|
||||
if (!$head) return false;
|
||||
return maybeSetSelection(
|
||||
state,
|
||||
dispatch,
|
||||
new CellSelection(cellSel.$anchorCell, $head)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function handleTripleClick(view: EditorView, pos: number): boolean {
|
||||
const doc = view.state.doc,
|
||||
$cell = cellAround(doc.resolve(pos));
|
||||
if (!$cell) return false;
|
||||
view.dispatch(view.state.tr.setSelection(new CellSelection($cell)));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function handlePaste(
|
||||
view: EditorView,
|
||||
_: ClipboardEvent,
|
||||
slice: Slice
|
||||
): boolean {
|
||||
if (!isInTable(view.state)) return false;
|
||||
let cells = pastedCells(slice);
|
||||
const sel = view.state.selection;
|
||||
if (sel instanceof CellSelection) {
|
||||
if (!cells)
|
||||
cells = {
|
||||
width: 1,
|
||||
height: 1,
|
||||
rows: [
|
||||
Fragment.from(fitSlice(tableNodeTypes(view.state.schema).cell, slice))
|
||||
]
|
||||
};
|
||||
const table = sel.$anchorCell.node(-1);
|
||||
const start = sel.$anchorCell.start(-1);
|
||||
const rect = TableMap.get(table).rectBetween(
|
||||
sel.$anchorCell.pos - start,
|
||||
sel.$headCell.pos - start
|
||||
);
|
||||
cells = clipCells(cells, rect.right - rect.left, rect.bottom - rect.top);
|
||||
insertCells(view.state, view.dispatch, start, rect, cells);
|
||||
return true;
|
||||
} else if (cells) {
|
||||
const $cell = selectionCell(view.state);
|
||||
const start = $cell.start(-1);
|
||||
insertCells(
|
||||
view.state,
|
||||
view.dispatch,
|
||||
start,
|
||||
TableMap.get($cell.node(-1)).findCell($cell.pos - start),
|
||||
cells
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleMouseDown(
|
||||
view: EditorView,
|
||||
startEvent: MouseEvent | TouchEvent
|
||||
): void {
|
||||
if (startEvent.ctrlKey || startEvent.metaKey) return;
|
||||
if (columnResizingPluginKey.getState(view.state)?.dragging) return;
|
||||
|
||||
const startDOMCell = domInCell(view, startEvent.target as Node);
|
||||
let $anchor;
|
||||
if (startEvent.shiftKey && view.state.selection instanceof CellSelection) {
|
||||
// Adding to an existing cell selection
|
||||
setCellSelection(view.state.selection.$anchorCell, startEvent);
|
||||
startEvent.preventDefault();
|
||||
} else if (
|
||||
startEvent.shiftKey &&
|
||||
startDOMCell &&
|
||||
($anchor = cellAround(view.state.selection.$anchor)) != null &&
|
||||
cellUnderMouse(view, startEvent)?.pos != $anchor.pos
|
||||
) {
|
||||
// Adding to a selection that starts in another cell (causing a
|
||||
// cell selection to be created).
|
||||
setCellSelection($anchor, startEvent);
|
||||
startEvent.preventDefault();
|
||||
} else if (startEvent instanceof TouchEvent) {
|
||||
const selectedCell = view.domAtPos(view.state.selection.from).node;
|
||||
if (startDOMCell?.contains(selectedCell)) {
|
||||
$anchor = cellUnderMouse(view, startEvent);
|
||||
if (!$anchor) return;
|
||||
setCellSelection($anchor, startEvent);
|
||||
}
|
||||
} else if (!startDOMCell) {
|
||||
// Not in a cell, let the default behavior happen.
|
||||
return;
|
||||
}
|
||||
|
||||
// Create and dispatch a cell selection between the given anchor and
|
||||
// the position under the mouse.
|
||||
function setCellSelection(
|
||||
$anchor: ResolvedPos,
|
||||
event: MouseEvent | DragEvent | TouchEvent
|
||||
): void {
|
||||
let $head = cellUnderMouse(view, event);
|
||||
const starting = tableEditingKey.getState(view.state) == null;
|
||||
if (!$head || !inSameTable($anchor, $head)) {
|
||||
if (starting) $head = $anchor;
|
||||
else return;
|
||||
}
|
||||
const selection = new CellSelection($anchor, $head);
|
||||
if (starting || !view.state.selection.eq(selection)) {
|
||||
const tr = view.state.tr.setSelection(selection);
|
||||
if (starting) tr.setMeta(tableEditingKey, $anchor.pos);
|
||||
view.dispatch(tr);
|
||||
}
|
||||
}
|
||||
|
||||
// Stop listening to mouse motion events.
|
||||
function stop(): void {
|
||||
view.root.removeEventListener("mouseup", stop);
|
||||
view.root.removeEventListener("dragstart", stop);
|
||||
view.root.removeEventListener("mousemove", move);
|
||||
view.root.removeEventListener("touchmove", move);
|
||||
view.root.removeEventListener("touchend", stop);
|
||||
view.root.removeEventListener("touchcancel", stop);
|
||||
if (tableEditingKey.getState(view.state) != null) {
|
||||
(view as any).domObserver.suppressSelectionUpdates();
|
||||
view.dispatch(view.state.tr.setMeta(tableEditingKey, -1));
|
||||
}
|
||||
}
|
||||
|
||||
function move(_event: Event): void {
|
||||
const event = _event as MouseEvent | DragEvent | TouchEvent;
|
||||
const anchor = tableEditingKey.getState(view.state);
|
||||
let $anchor;
|
||||
if (anchor != null) {
|
||||
// Continuing an existing cross-cell selection
|
||||
$anchor = view.state.doc.resolve(anchor);
|
||||
} else if (domInCell(view, event.target as Node) != startDOMCell) {
|
||||
// Moving out of the initial cell -- start a new cell selection
|
||||
$anchor = cellUnderMouse(view, startEvent);
|
||||
if (!$anchor) return stop();
|
||||
}
|
||||
if ($anchor) setCellSelection($anchor, event);
|
||||
}
|
||||
|
||||
view.root.addEventListener("mouseup", stop);
|
||||
view.root.addEventListener("dragstart", stop);
|
||||
view.root.addEventListener("mousemove", move);
|
||||
view.root.addEventListener("touchmove", move);
|
||||
view.root.addEventListener("touchend", stop);
|
||||
view.root.addEventListener("touchcancel", stop);
|
||||
}
|
||||
|
||||
// Check whether the cursor is at the end of a cell (so that further
|
||||
// motion would move out of the cell)
|
||||
function atEndOfCell(view: EditorView, axis: Axis, dir: number): null | number {
|
||||
if (!(view.state.selection instanceof TextSelection)) return null;
|
||||
const { $head } = view.state.selection;
|
||||
for (let d = $head.depth - 1; d >= 0; d--) {
|
||||
const parent = $head.node(d),
|
||||
index = dir < 0 ? $head.index(d) : $head.indexAfter(d);
|
||||
if (index != (dir < 0 ? 0 : parent.childCount)) return null;
|
||||
if (
|
||||
parent.type.spec.tableRole == "cell" ||
|
||||
parent.type.spec.tableRole == "header_cell"
|
||||
) {
|
||||
const cellPos = $head.before(d);
|
||||
const dirStr: "up" | "down" | "left" | "right" =
|
||||
axis == "vert" ? (dir > 0 ? "down" : "up") : dir > 0 ? "right" : "left";
|
||||
return view.endOfTextblock(dirStr) ? cellPos : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function domInCell(view: EditorView, dom: Node | null): Node | null {
|
||||
for (; dom && dom != view.dom; dom = dom.parentNode) {
|
||||
if (dom.nodeName == "TD" || dom.nodeName == "TH") {
|
||||
return dom;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function cellUnderMouse(
|
||||
view: EditorView,
|
||||
event: MouseEvent | DragEvent | TouchEvent
|
||||
): ResolvedPos | null {
|
||||
const clientX = getClientX(event);
|
||||
const clientY = getClientY(event);
|
||||
if (clientX == null || clientY == null) return null;
|
||||
|
||||
const mousePos = view.posAtCoords({
|
||||
left: clientX,
|
||||
top: clientY
|
||||
});
|
||||
if (!mousePos) return null;
|
||||
return mousePos ? cellAround(view.state.doc.resolve(mousePos.pos)) : null;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// Helper for creating a schema that supports tables.
|
||||
|
||||
import {
|
||||
AttributeSpec,
|
||||
Attrs,
|
||||
Node,
|
||||
NodeSpec,
|
||||
NodeType,
|
||||
Schema
|
||||
} from "prosemirror-model";
|
||||
import { CellAttrs, MutableAttrs } from "./util.js";
|
||||
|
||||
function getCellAttrs(dom: HTMLElement | string, extraAttrs: Attrs): Attrs {
|
||||
if (typeof dom === "string") {
|
||||
return {};
|
||||
}
|
||||
|
||||
const widthAttr = dom.getAttribute("data-colwidth");
|
||||
const widths =
|
||||
widthAttr && /^\d+(,\d+)*$/.test(widthAttr)
|
||||
? widthAttr.split(",").map((s) => Number(s))
|
||||
: null;
|
||||
const colspan = Number(dom.getAttribute("colspan") || 1);
|
||||
const result: MutableAttrs = {
|
||||
colspan,
|
||||
rowspan: Number(dom.getAttribute("rowspan") || 1),
|
||||
colwidth: widths && widths.length == colspan ? widths : null
|
||||
} satisfies CellAttrs;
|
||||
for (const prop in extraAttrs) {
|
||||
const getter = extraAttrs[prop].getFromDOM;
|
||||
const value = getter && getter(dom);
|
||||
if (value != null) {
|
||||
result[prop] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function setCellAttrs(node: Node, extraAttrs: Attrs): Attrs {
|
||||
const attrs: MutableAttrs = {};
|
||||
if (node.attrs.colspan != 1) attrs.colspan = node.attrs.colspan;
|
||||
if (node.attrs.rowspan != 1) attrs.rowspan = node.attrs.rowspan;
|
||||
if (node.attrs.colwidth)
|
||||
attrs["data-colwidth"] = node.attrs.colwidth.join(",");
|
||||
for (const prop in extraAttrs) {
|
||||
const setter = extraAttrs[prop].setDOMAttr;
|
||||
if (setter) setter(node.attrs[prop], attrs);
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type getFromDOM = (dom: HTMLElement) => unknown;
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type setDOMAttr = (value: unknown, attrs: MutableAttrs) => void;
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface CellAttributes {
|
||||
/**
|
||||
* The attribute's default value.
|
||||
*/
|
||||
default: unknown;
|
||||
|
||||
/**
|
||||
* A function or type name used to validate values of this attribute.
|
||||
*
|
||||
* See [validate](https://prosemirror.net/docs/ref/#model.AttributeSpec.validate).
|
||||
*/
|
||||
validate?: string | ((value: unknown) => void);
|
||||
|
||||
/**
|
||||
* A function to read the attribute's value from a DOM node.
|
||||
*/
|
||||
getFromDOM?: getFromDOM;
|
||||
|
||||
/**
|
||||
* A function to add the attribute's value to an attribute
|
||||
* object that's used to render the cell's DOM.
|
||||
*/
|
||||
setDOMAttr?: setDOMAttr;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface TableNodesOptions {
|
||||
/**
|
||||
* The content expression for table cells.
|
||||
*/
|
||||
cellContent: string;
|
||||
|
||||
/**
|
||||
* Additional attributes to add to cells. Maps attribute names to
|
||||
* objects with the following properties:
|
||||
*/
|
||||
cellAttributes: { [key: string]: CellAttributes };
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type TableNodes = Record<
|
||||
"table_row" | "table_cell" | "table_header",
|
||||
NodeSpec
|
||||
>;
|
||||
|
||||
function validateColwidth(value: unknown) {
|
||||
if (value === null) {
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
throw new TypeError("colwidth must be null or an array");
|
||||
}
|
||||
for (const item of value) {
|
||||
if (typeof item !== "number") {
|
||||
throw new TypeError("colwidth must be null or an array of numbers");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function creates a set of [node
|
||||
* specs](http://prosemirror.net/docs/ref/#model.SchemaSpec.nodes) for
|
||||
* `table`, `table_row`, and `table_cell` nodes types as used by this
|
||||
* module. The result can then be added to the set of nodes when
|
||||
* creating a schema.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function tableNodes(options: TableNodesOptions): TableNodes {
|
||||
const extraAttrs = options.cellAttributes || {};
|
||||
const cellAttrs: Record<string, AttributeSpec> = {
|
||||
colspan: { default: 1, validate: "number" },
|
||||
rowspan: { default: 1, validate: "number" },
|
||||
colwidth: { default: null, validate: validateColwidth }
|
||||
};
|
||||
for (const prop in extraAttrs)
|
||||
cellAttrs[prop] = {
|
||||
default: extraAttrs[prop].default,
|
||||
validate: extraAttrs[prop].validate
|
||||
};
|
||||
|
||||
return {
|
||||
// table: {
|
||||
// content: "table_row+",
|
||||
// tableRole: "table",
|
||||
// isolating: true,
|
||||
// group: options.tableGroup,
|
||||
// parseDOM: [{ tag: "table" }],
|
||||
// toDOM() {
|
||||
// return ["table", ["tbody", 0]];
|
||||
// }
|
||||
// },
|
||||
table_row: {
|
||||
content: "(table_cell | table_header)*",
|
||||
tableRole: "row",
|
||||
parseDOM: [{ tag: "tr" }],
|
||||
toDOM() {
|
||||
return ["tr", 0];
|
||||
}
|
||||
},
|
||||
table_cell: {
|
||||
content: options.cellContent,
|
||||
attrs: cellAttrs,
|
||||
tableRole: "cell",
|
||||
isolating: true,
|
||||
parseDOM: [
|
||||
{ tag: "td", getAttrs: (dom) => getCellAttrs(dom, extraAttrs) }
|
||||
],
|
||||
toDOM(node) {
|
||||
return ["td", setCellAttrs(node, extraAttrs), 0];
|
||||
}
|
||||
},
|
||||
table_header: {
|
||||
content: options.cellContent,
|
||||
attrs: cellAttrs,
|
||||
tableRole: "header_cell",
|
||||
isolating: true,
|
||||
parseDOM: [
|
||||
{ tag: "th", getAttrs: (dom) => getCellAttrs(dom, extraAttrs) }
|
||||
],
|
||||
toDOM(node) {
|
||||
return ["th", setCellAttrs(node, extraAttrs), 0];
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type TableRole = "table" | "row" | "cell" | "header_cell";
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function tableNodeTypes(schema: Schema): Record<TableRole, NodeType> {
|
||||
let result = schema.cached.tableNodeTypes;
|
||||
if (!result) {
|
||||
result = schema.cached.tableNodeTypes = {};
|
||||
for (const name in schema.nodes) {
|
||||
const type = schema.nodes[name],
|
||||
role = type.spec.tableRole;
|
||||
if (role) result[role] = type;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
// Because working with row and column-spanning cells is not quite
|
||||
// trivial, this code builds up a descriptive structure for a given
|
||||
// table node. The structures are cached with the (persistent) table
|
||||
// nodes as key, so that they only have to be recomputed when the
|
||||
// content of the table changes.
|
||||
//
|
||||
// This does mean that they have to store table-relative, not
|
||||
// document-relative positions. So code that uses them will typically
|
||||
// compute the start position of the table and offset positions passed
|
||||
// to or gotten from this structure by that amount.
|
||||
import { Attrs, Node } from "prosemirror-model";
|
||||
import { CellAttrs } from "./util.js";
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type ColWidths = number[];
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type Problem =
|
||||
| {
|
||||
type: "colwidth mismatch";
|
||||
pos: number;
|
||||
colwidth: ColWidths;
|
||||
}
|
||||
| {
|
||||
type: "collision";
|
||||
pos: number;
|
||||
row: number;
|
||||
n: number;
|
||||
}
|
||||
| {
|
||||
type: "missing";
|
||||
row: number;
|
||||
n: number;
|
||||
}
|
||||
| {
|
||||
type: "overlong_rowspan";
|
||||
pos: number;
|
||||
n: number;
|
||||
}
|
||||
| {
|
||||
type: "zero_sized";
|
||||
};
|
||||
|
||||
let readFromCache: (key: Node) => TableMap | undefined;
|
||||
let addToCache: (key: Node, value: TableMap) => TableMap;
|
||||
|
||||
// Prefer using a weak map to cache table maps. Fall back on a
|
||||
// fixed-size cache if that's not supported.
|
||||
if (typeof WeakMap != "undefined") {
|
||||
// eslint-disable-next-line
|
||||
let cache = new WeakMap<Node, TableMap>();
|
||||
readFromCache = (key) => cache.get(key);
|
||||
addToCache = (key, value) => {
|
||||
cache.set(key, value);
|
||||
return value;
|
||||
};
|
||||
} else {
|
||||
const cache: (Node | TableMap)[] = [];
|
||||
const cacheSize = 10;
|
||||
let cachePos = 0;
|
||||
readFromCache = (key) => {
|
||||
for (let i = 0; i < cache.length; i += 2)
|
||||
if (cache[i] == key) return cache[i + 1] as TableMap;
|
||||
};
|
||||
addToCache = (key, value) => {
|
||||
if (cachePos == cacheSize) cachePos = 0;
|
||||
cache[cachePos++] = key;
|
||||
return (cache[cachePos++] = value);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface Rect {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A table map describes the structure of a given table. To avoid
|
||||
* recomputing them all the time, they are cached per table node. To
|
||||
* be able to do that, positions saved in the map are relative to the
|
||||
* start of the table, rather than the start of the document.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class TableMap {
|
||||
constructor(
|
||||
/**
|
||||
* The number of columns
|
||||
*/
|
||||
public width: number,
|
||||
/**
|
||||
* The number of rows
|
||||
*/
|
||||
public height: number,
|
||||
/**
|
||||
* A width * height array with the start position of
|
||||
* the cell covering that part of the table in each slot
|
||||
*/
|
||||
public map: number[],
|
||||
/**
|
||||
* An optional array of problems (cell overlap or non-rectangular
|
||||
* shape) for the table, used by the table normalizer.
|
||||
*/
|
||||
public problems: Problem[] | null
|
||||
) {}
|
||||
|
||||
// Find the dimensions of the cell at the given position.
|
||||
findCell(pos: number): Rect {
|
||||
for (let i = 0; i < this.map.length; i++) {
|
||||
const curPos = this.map[i];
|
||||
if (curPos != pos) continue;
|
||||
|
||||
const left = i % this.width;
|
||||
const top = (i / this.width) | 0;
|
||||
let right = left + 1;
|
||||
let bottom = top + 1;
|
||||
|
||||
for (let j = 1; right < this.width && this.map[i + j] == curPos; j++) {
|
||||
right++;
|
||||
}
|
||||
for (
|
||||
let j = 1;
|
||||
bottom < this.height && this.map[i + this.width * j] == curPos;
|
||||
j++
|
||||
) {
|
||||
bottom++;
|
||||
}
|
||||
|
||||
return { left, top, right, bottom };
|
||||
}
|
||||
throw new RangeError(`No cell with offset ${pos} found`);
|
||||
}
|
||||
|
||||
// Find the left side of the cell at the given position.
|
||||
colCount(pos: number): number {
|
||||
for (let i = 0; i < this.map.length; i++) {
|
||||
if (this.map[i] == pos) {
|
||||
return i % this.width;
|
||||
}
|
||||
}
|
||||
throw new RangeError(`No cell with offset ${pos} found`);
|
||||
}
|
||||
|
||||
// Find the next cell in the given direction, starting from the cell
|
||||
// at `pos`, if any.
|
||||
nextCell(pos: number, axis: "horiz" | "vert", dir: number): null | number {
|
||||
const { left, right, top, bottom } = this.findCell(pos);
|
||||
if (axis == "horiz") {
|
||||
if (dir < 0 ? left == 0 : right == this.width) return null;
|
||||
return this.map[top * this.width + (dir < 0 ? left - 1 : right)];
|
||||
} else {
|
||||
if (dir < 0 ? top == 0 : bottom == this.height) return null;
|
||||
return this.map[left + this.width * (dir < 0 ? top - 1 : bottom)];
|
||||
}
|
||||
}
|
||||
|
||||
// Get the rectangle spanning the two given cells.
|
||||
rectBetween(a: number, b: number): Rect {
|
||||
const {
|
||||
left: leftA,
|
||||
right: rightA,
|
||||
top: topA,
|
||||
bottom: bottomA
|
||||
} = this.findCell(a);
|
||||
const {
|
||||
left: leftB,
|
||||
right: rightB,
|
||||
top: topB,
|
||||
bottom: bottomB
|
||||
} = this.findCell(b);
|
||||
return {
|
||||
left: Math.min(leftA, leftB),
|
||||
top: Math.min(topA, topB),
|
||||
right: Math.max(rightA, rightB),
|
||||
bottom: Math.max(bottomA, bottomB)
|
||||
};
|
||||
}
|
||||
|
||||
// Return the position of all cells that have the top left corner in
|
||||
// the given rectangle.
|
||||
cellsInRect(rect: Rect): number[] {
|
||||
const result: number[] = [];
|
||||
const seen: Record<number, boolean> = {};
|
||||
for (let row = rect.top; row < rect.bottom; row++) {
|
||||
for (let col = rect.left; col < rect.right; col++) {
|
||||
const index = row * this.width + col;
|
||||
const pos = this.map[index];
|
||||
|
||||
if (seen[pos]) continue;
|
||||
seen[pos] = true;
|
||||
|
||||
if (
|
||||
(col == rect.left && col && this.map[index - 1] == pos) ||
|
||||
(row == rect.top && row && this.map[index - this.width] == pos)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
result.push(pos);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Return the position at which the cell at the given row and column
|
||||
// starts, or would start, if a cell started there.
|
||||
positionAt(row: number, col: number, table: Node): number {
|
||||
for (let i = 0, rowStart = 0; ; i++) {
|
||||
const rowEnd = rowStart + table.child(i).nodeSize;
|
||||
if (i == row) {
|
||||
let index = col + row * this.width;
|
||||
const rowEndIndex = (row + 1) * this.width;
|
||||
// Skip past cells from previous rows (via rowspan)
|
||||
while (index < rowEndIndex && this.map[index] < rowStart) index++;
|
||||
return index == rowEndIndex ? rowEnd - 1 : this.map[index];
|
||||
}
|
||||
rowStart = rowEnd;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the table map for the given table node.
|
||||
static get(table: Node): TableMap {
|
||||
return readFromCache(table) || addToCache(table, computeMap(table));
|
||||
}
|
||||
}
|
||||
|
||||
// Compute a table map.
|
||||
function computeMap(table: Node): TableMap {
|
||||
if (table.type.spec.tableRole != "table")
|
||||
throw new RangeError("Not a table node: " + table.type.name);
|
||||
const width = findWidth(table),
|
||||
height = table.childCount;
|
||||
const map = [];
|
||||
let mapPos = 0;
|
||||
let problems: Problem[] | null = null;
|
||||
const colWidths: ColWidths = [];
|
||||
for (let i = 0, e = width * height; i < e; i++) map[i] = 0;
|
||||
|
||||
for (let row = 0, pos = 0; row < height; row++) {
|
||||
const rowNode = table.child(row);
|
||||
pos++;
|
||||
for (let i = 0; ; i++) {
|
||||
while (mapPos < map.length && map[mapPos] != 0) mapPos++;
|
||||
if (i == rowNode.childCount) break;
|
||||
const cellNode = rowNode.child(i);
|
||||
const { colspan, rowspan, colwidth } = cellNode.attrs;
|
||||
for (let h = 0; h < rowspan; h++) {
|
||||
if (h + row >= height) {
|
||||
(problems || (problems = [])).push({
|
||||
type: "overlong_rowspan",
|
||||
pos,
|
||||
n: rowspan - h
|
||||
});
|
||||
break;
|
||||
}
|
||||
const start = mapPos + h * width;
|
||||
for (let w = 0; w < colspan; w++) {
|
||||
if (map[start + w] == 0) map[start + w] = pos;
|
||||
else
|
||||
(problems || (problems = [])).push({
|
||||
type: "collision",
|
||||
row,
|
||||
pos,
|
||||
n: colspan - w
|
||||
});
|
||||
const colW = colwidth && colwidth[w];
|
||||
if (colW) {
|
||||
const widthIndex = ((start + w) % width) * 2,
|
||||
prev = colWidths[widthIndex];
|
||||
if (
|
||||
prev == null ||
|
||||
(prev != colW && colWidths[widthIndex + 1] == 1)
|
||||
) {
|
||||
colWidths[widthIndex] = colW;
|
||||
colWidths[widthIndex + 1] = 1;
|
||||
} else if (prev == colW) {
|
||||
colWidths[widthIndex + 1]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
mapPos += colspan;
|
||||
pos += cellNode.nodeSize;
|
||||
}
|
||||
const expectedPos = (row + 1) * width;
|
||||
let missing = 0;
|
||||
while (mapPos < expectedPos) if (map[mapPos++] == 0) missing++;
|
||||
if (missing)
|
||||
(problems || (problems = [])).push({ type: "missing", row, n: missing });
|
||||
pos++;
|
||||
}
|
||||
|
||||
if (width === 0 || height === 0)
|
||||
(problems || (problems = [])).push({ type: "zero_sized" });
|
||||
|
||||
const tableMap = new TableMap(width, height, map, problems);
|
||||
let badWidths = false;
|
||||
|
||||
// For columns that have defined widths, but whose widths disagree
|
||||
// between rows, fix up the cells whose width doesn't match the
|
||||
// computed one.
|
||||
for (let i = 0; !badWidths && i < colWidths.length; i += 2)
|
||||
if (colWidths[i] != null && colWidths[i + 1] < height) badWidths = true;
|
||||
if (badWidths) findBadColWidths(tableMap, colWidths, table);
|
||||
|
||||
return tableMap;
|
||||
}
|
||||
|
||||
function findWidth(table: Node): number {
|
||||
let width = -1;
|
||||
let hasRowSpan = false;
|
||||
for (let row = 0; row < table.childCount; row++) {
|
||||
const rowNode = table.child(row);
|
||||
let rowWidth = 0;
|
||||
if (hasRowSpan)
|
||||
for (let j = 0; j < row; j++) {
|
||||
const prevRow = table.child(j);
|
||||
for (let i = 0; i < prevRow.childCount; i++) {
|
||||
const cell = prevRow.child(i);
|
||||
if (j + cell.attrs.rowspan > row) rowWidth += cell.attrs.colspan;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < rowNode.childCount; i++) {
|
||||
const cell = rowNode.child(i);
|
||||
rowWidth += cell.attrs.colspan;
|
||||
if (cell.attrs.rowspan > 1) hasRowSpan = true;
|
||||
}
|
||||
if (width == -1) width = rowWidth;
|
||||
else if (width != rowWidth) width = Math.max(width, rowWidth);
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
function findBadColWidths(
|
||||
map: TableMap,
|
||||
colWidths: ColWidths,
|
||||
table: Node
|
||||
): void {
|
||||
if (!map.problems) map.problems = [];
|
||||
const seen: Record<number, boolean> = {};
|
||||
for (let i = 0; i < map.map.length; i++) {
|
||||
const pos = map.map[i];
|
||||
if (seen[pos]) continue;
|
||||
seen[pos] = true;
|
||||
const node = table.nodeAt(pos);
|
||||
if (!node) {
|
||||
throw new RangeError(`No cell with offset ${pos} found`);
|
||||
}
|
||||
|
||||
let updated = null;
|
||||
const attrs = node.attrs as CellAttrs;
|
||||
for (let j = 0; j < attrs.colspan; j++) {
|
||||
const col = (i + j) % map.width;
|
||||
const colWidth = colWidths[col * 2];
|
||||
if (
|
||||
colWidth != null &&
|
||||
(!attrs.colwidth || attrs.colwidth[j] != colWidth)
|
||||
)
|
||||
(updated || (updated = freshColWidth(attrs)))[j] = colWidth;
|
||||
}
|
||||
if (updated)
|
||||
map.problems.unshift({
|
||||
type: "colwidth mismatch",
|
||||
pos,
|
||||
colwidth: updated
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function freshColWidth(attrs: Attrs): ColWidths {
|
||||
if (attrs.colwidth) return attrs.colwidth.slice();
|
||||
const result: ColWidths = [];
|
||||
for (let i = 0; i < attrs.colspan; i++) result.push(0);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Node } from "prosemirror-model";
|
||||
import { NodeView } from "prosemirror-view";
|
||||
import { CellAttrs } from "./util.js";
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export class TableView implements NodeView {
|
||||
public dom: HTMLDivElement;
|
||||
public table: HTMLTableElement;
|
||||
public colgroup: HTMLTableColElement;
|
||||
public contentDOM: HTMLTableSectionElement;
|
||||
|
||||
constructor(public node: Node, public defaultCellMinWidth: number) {
|
||||
this.dom = document.createElement("div");
|
||||
this.dom.className = "tableWrapper";
|
||||
this.table = this.dom.appendChild(document.createElement("table"));
|
||||
this.table.style.setProperty(
|
||||
"--default-cell-min-width",
|
||||
`${defaultCellMinWidth}px`
|
||||
);
|
||||
this.colgroup = this.table.appendChild(document.createElement("colgroup"));
|
||||
updateColumnsOnResize(node, this.colgroup, this.table, defaultCellMinWidth);
|
||||
this.contentDOM = this.table.appendChild(document.createElement("tbody"));
|
||||
}
|
||||
|
||||
update(node: Node): boolean {
|
||||
if (node.type != this.node.type) return false;
|
||||
this.node = node;
|
||||
updateColumnsOnResize(
|
||||
node,
|
||||
this.colgroup,
|
||||
this.table,
|
||||
this.defaultCellMinWidth
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
ignoreMutation(record: any): boolean {
|
||||
return (
|
||||
record.type == "attributes" &&
|
||||
(record.target == this.table || this.colgroup.contains(record.target))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function updateColumnsOnResize(
|
||||
node: Node,
|
||||
colgroup: HTMLTableColElement,
|
||||
table: HTMLTableElement,
|
||||
defaultCellMinWidth: number,
|
||||
overrideCol?: number,
|
||||
overrideValue?: number
|
||||
): void {
|
||||
let totalWidth = 0;
|
||||
let fixedWidth = true;
|
||||
let nextDOM = colgroup.firstChild as HTMLElement;
|
||||
const row = node.firstChild;
|
||||
if (!row) return;
|
||||
|
||||
for (let i = 0, col = 0; i < row.childCount; i++) {
|
||||
const { colspan, colwidth } = row.child(i).attrs as CellAttrs;
|
||||
for (let j = 0; j < colspan; j++, col++) {
|
||||
const hasWidth =
|
||||
overrideCol == col ? overrideValue : colwidth && colwidth[j];
|
||||
const cssWidth = hasWidth ? hasWidth + "px" : "";
|
||||
totalWidth += hasWidth || defaultCellMinWidth;
|
||||
if (!hasWidth) fixedWidth = false;
|
||||
if (!nextDOM) {
|
||||
const col = document.createElement("col");
|
||||
col.style.width = cssWidth;
|
||||
colgroup.appendChild(col);
|
||||
} else {
|
||||
if (nextDOM.style.width != cssWidth) {
|
||||
nextDOM.style.width = cssWidth;
|
||||
}
|
||||
nextDOM = nextDOM.nextSibling as HTMLElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (nextDOM) {
|
||||
const after = nextDOM.nextSibling;
|
||||
nextDOM.parentNode?.removeChild(nextDOM);
|
||||
nextDOM = after as HTMLElement;
|
||||
}
|
||||
|
||||
if (fixedWidth) {
|
||||
table.style.width = totalWidth + "px";
|
||||
table.style.minWidth = "";
|
||||
} else {
|
||||
table.style.width = "";
|
||||
table.style.minWidth = totalWidth + "px";
|
||||
}
|
||||
}
|
||||
218
packages/editor/src/extensions/table/prosemirror-tables/util.ts
Normal file
218
packages/editor/src/extensions/table/prosemirror-tables/util.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
// Various helper function for working with tables
|
||||
|
||||
import { EditorState, NodeSelection, PluginKey } from "prosemirror-state";
|
||||
|
||||
import { Attrs, Node, ResolvedPos } from "prosemirror-model";
|
||||
import { CellSelection } from "./cellselection.js";
|
||||
import { tableNodeTypes } from "./schema.js";
|
||||
import { Rect, TableMap } from "./tablemap.js";
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type MutableAttrs = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface CellAttrs {
|
||||
colspan: number;
|
||||
rowspan: number;
|
||||
colwidth: number[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const tableEditingKey = new PluginKey<number>("selectingCells");
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function cellAround($pos: ResolvedPos): ResolvedPos | null {
|
||||
for (let d = $pos.depth - 1; d > 0; d--)
|
||||
if ($pos.node(d).type.spec.tableRole == "row")
|
||||
return $pos.node(0).resolve($pos.before(d + 1));
|
||||
return null;
|
||||
}
|
||||
|
||||
export function cellWrapping($pos: ResolvedPos): null | Node {
|
||||
for (let d = $pos.depth; d > 0; d--) {
|
||||
// Sometimes the cell can be in the same depth.
|
||||
const role = $pos.node(d).type.spec.tableRole;
|
||||
if (role === "cell" || role === "header_cell") return $pos.node(d);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function isInTable(state: EditorState): boolean {
|
||||
const $head = state.selection.$head;
|
||||
for (let d = $head.depth; d > 0; d--)
|
||||
if ($head.node(d).type.spec.tableRole == "row") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function selectionCell(state: EditorState): ResolvedPos {
|
||||
const sel = state.selection as CellSelection | NodeSelection;
|
||||
if ("$anchorCell" in sel && sel.$anchorCell) {
|
||||
return sel.$anchorCell.pos > sel.$headCell.pos
|
||||
? sel.$anchorCell
|
||||
: sel.$headCell;
|
||||
} else if (
|
||||
"node" in sel &&
|
||||
sel.node &&
|
||||
sel.node.type.spec.tableRole == "cell"
|
||||
) {
|
||||
return sel.$anchor;
|
||||
}
|
||||
const $cell = cellAround(sel.$head) || cellNear(sel.$head);
|
||||
if ($cell) {
|
||||
return $cell;
|
||||
}
|
||||
throw new RangeError(`No cell found around position ${sel.head}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function cellNear($pos: ResolvedPos): ResolvedPos | undefined {
|
||||
for (
|
||||
let after = $pos.nodeAfter, pos = $pos.pos;
|
||||
after;
|
||||
after = after.firstChild, pos++
|
||||
) {
|
||||
const role = after.type.spec.tableRole;
|
||||
if (role == "cell" || role == "header_cell") return $pos.doc.resolve(pos);
|
||||
}
|
||||
for (
|
||||
let before = $pos.nodeBefore, pos = $pos.pos;
|
||||
before;
|
||||
before = before.lastChild, pos--
|
||||
) {
|
||||
const role = before.type.spec.tableRole;
|
||||
if (role == "cell" || role == "header_cell")
|
||||
return $pos.doc.resolve(pos - before.nodeSize);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function pointsAtCell($pos: ResolvedPos): boolean {
|
||||
return $pos.parent.type.spec.tableRole == "row" && !!$pos.nodeAfter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function moveCellForward($pos: ResolvedPos): ResolvedPos {
|
||||
return $pos.node(0).resolve($pos.pos + $pos.nodeAfter!.nodeSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function inSameTable($cellA: ResolvedPos, $cellB: ResolvedPos): boolean {
|
||||
return (
|
||||
$cellA.depth == $cellB.depth &&
|
||||
$cellA.pos >= $cellB.start(-1) &&
|
||||
$cellA.pos <= $cellB.end(-1)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function findCell($pos: ResolvedPos): Rect {
|
||||
return TableMap.get($pos.node(-1)).findCell($pos.pos - $pos.start(-1));
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function colCount($pos: ResolvedPos): number {
|
||||
return TableMap.get($pos.node(-1)).colCount($pos.pos - $pos.start(-1));
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function nextCell(
|
||||
$pos: ResolvedPos,
|
||||
axis: "horiz" | "vert",
|
||||
dir: number
|
||||
): ResolvedPos | null {
|
||||
const table = $pos.node(-1);
|
||||
const map = TableMap.get(table);
|
||||
const tableStart = $pos.start(-1);
|
||||
|
||||
const moved = map.nextCell($pos.pos - tableStart, axis, dir);
|
||||
return moved == null ? null : $pos.node(0).resolve(tableStart + moved);
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function removeColSpan(attrs: CellAttrs, pos: number, n = 1): CellAttrs {
|
||||
const result: CellAttrs = { ...attrs, colspan: attrs.colspan - n };
|
||||
|
||||
if (result.colwidth) {
|
||||
result.colwidth = result.colwidth.slice();
|
||||
result.colwidth.splice(pos, n);
|
||||
if (!result.colwidth.some((w) => w > 0)) result.colwidth = null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function addColSpan(attrs: CellAttrs, pos: number, n = 1): Attrs {
|
||||
const result = { ...attrs, colspan: attrs.colspan + n };
|
||||
if (result.colwidth) {
|
||||
result.colwidth = result.colwidth.slice();
|
||||
for (let i = 0; i < n; i++) result.colwidth.splice(pos, 0, 0);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function columnIsHeader(
|
||||
map: TableMap,
|
||||
table: Node,
|
||||
col: number
|
||||
): boolean {
|
||||
const headerCell = tableNodeTypes(table.type.schema).header_cell;
|
||||
for (let row = 0; row < map.height; row++)
|
||||
if (table.nodeAt(map.map[col + row * map.width])!.type != headerCell)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getClientX(event: MouseEvent | TouchEvent): number | null {
|
||||
return event instanceof MouseEvent
|
||||
? event.clientX
|
||||
: event.touches.length > 0
|
||||
? event.touches[0].clientX
|
||||
: event.changedTouches.length > 0
|
||||
? event.changedTouches[0].clientX
|
||||
: null;
|
||||
}
|
||||
|
||||
export function getClientY(event: MouseEvent | TouchEvent): number | null {
|
||||
return event instanceof MouseEvent
|
||||
? event.clientY
|
||||
: event.touches.length > 0
|
||||
? event.touches[0].clientY
|
||||
: event.changedTouches.length > 0
|
||||
? event.changedTouches[0].clientY
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Node } from "prosemirror-model";
|
||||
import { TableMap } from "../tablemap.js";
|
||||
|
||||
/**
|
||||
* This function will transform the table node into a matrix of rows and columns
|
||||
* respecting merged cells, for example this table:
|
||||
*
|
||||
* ```
|
||||
* ┌──────┬──────┬─────────────┐
|
||||
* │ A1 │ B1 │ C1 │
|
||||
* ├──────┼──────┴──────┬──────┤
|
||||
* │ A2 │ B2 │ │
|
||||
* ├──────┼─────────────┤ D1 │
|
||||
* │ A3 │ B3 │ C3 │ │
|
||||
* └──────┴──────┴──────┴──────┘
|
||||
* ```
|
||||
*
|
||||
* will be converted to the below:
|
||||
*
|
||||
* ```javascript
|
||||
* [
|
||||
* [A1, B1, C1, null],
|
||||
* [A2, B2, null, D1],
|
||||
* [A3, B3, C3, null],
|
||||
* ]
|
||||
* ```
|
||||
* @internal
|
||||
*/
|
||||
export function convertTableNodeToArrayOfRows(
|
||||
tableNode: Node
|
||||
): (Node | null)[][] {
|
||||
const map = TableMap.get(tableNode);
|
||||
const rows: (Node | null)[][] = [];
|
||||
const rowCount = map.height;
|
||||
const colCount = map.width;
|
||||
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
|
||||
const row: (Node | null)[] = [];
|
||||
for (let colIndex = 0; colIndex < colCount; colIndex++) {
|
||||
const cellIndex = rowIndex * colCount + colIndex;
|
||||
const cellPos = map.map[cellIndex];
|
||||
if (rowIndex > 0) {
|
||||
const topCellIndex = cellIndex - colCount;
|
||||
const topCellPos = map.map[topCellIndex];
|
||||
if (cellPos === topCellPos) {
|
||||
row.push(null);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (colIndex > 0) {
|
||||
const leftCellIndex = cellIndex - 1;
|
||||
const leftCellPos = map.map[leftCellIndex];
|
||||
if (cellPos === leftCellPos) {
|
||||
row.push(null);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
row.push(tableNode.nodeAt(cellPos));
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an array of rows to a table node.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function convertArrayOfRowsToTableNode(
|
||||
tableNode: Node,
|
||||
arrayOfNodes: (Node | null)[][]
|
||||
): Node {
|
||||
const newRows: Node[] = [];
|
||||
const map = TableMap.get(tableNode);
|
||||
const rowCount = map.height;
|
||||
const colCount = map.width;
|
||||
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
|
||||
const oldRow: Node = tableNode.child(rowIndex);
|
||||
const newCells: Node[] = [];
|
||||
|
||||
for (let colIndex = 0; colIndex < colCount; colIndex++) {
|
||||
const cell = arrayOfNodes[rowIndex][colIndex];
|
||||
if (!cell) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cellPos = map.map[rowIndex * map.width + colIndex];
|
||||
const oldCell = tableNode.nodeAt(cellPos);
|
||||
if (!oldCell) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newCell = oldCell.type.createChecked(
|
||||
cell.attrs,
|
||||
cell.content,
|
||||
cell.marks
|
||||
);
|
||||
newCells.push(newCell);
|
||||
}
|
||||
|
||||
const newRow = oldRow.type.createChecked(
|
||||
oldRow.attrs,
|
||||
newCells,
|
||||
oldRow.marks
|
||||
);
|
||||
newRows.push(newRow);
|
||||
}
|
||||
|
||||
const newTable = tableNode.type.createChecked(
|
||||
tableNode.attrs,
|
||||
newRows,
|
||||
tableNode.marks
|
||||
);
|
||||
return newTable;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Selection } from "prosemirror-state";
|
||||
|
||||
import { TableMap } from "../tablemap.js";
|
||||
import { FindNodeResult, findTable } from "./query.js";
|
||||
|
||||
/**
|
||||
* Returns an array of cells in a column at the specified column index.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function getCellsInColumn(
|
||||
columnIndex: number,
|
||||
selection: Selection
|
||||
): FindNodeResult[] | undefined {
|
||||
const table = findTable(selection.$from);
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
|
||||
const map = TableMap.get(table.node);
|
||||
|
||||
if (columnIndex < 0 || columnIndex > map.width - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cells = map.cellsInRect({
|
||||
left: columnIndex,
|
||||
right: columnIndex + 1,
|
||||
top: 0,
|
||||
bottom: map.height
|
||||
});
|
||||
|
||||
return cells.map((nodePos) => {
|
||||
const node = table.node.nodeAt(nodePos)!;
|
||||
const pos = nodePos + table.start;
|
||||
return { pos, start: pos + 1, node, depth: table.depth + 2 };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of cells in a row at the specified row index.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function getCellsInRow(
|
||||
rowIndex: number,
|
||||
selection: Selection
|
||||
): FindNodeResult[] | undefined {
|
||||
const table = findTable(selection.$from);
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
|
||||
const map = TableMap.get(table.node);
|
||||
|
||||
if (rowIndex < 0 || rowIndex > map.height - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cells = map.cellsInRect({
|
||||
left: 0,
|
||||
right: map.width,
|
||||
top: rowIndex,
|
||||
bottom: rowIndex + 1
|
||||
});
|
||||
|
||||
return cells.map((nodePos) => {
|
||||
const node = table.node.nodeAt(nodePos)!;
|
||||
const pos = nodePos + table.start;
|
||||
return { pos, start: pos + 1, node, depth: table.depth + 2 };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { Node } from "prosemirror-model";
|
||||
import type { Transaction } from "prosemirror-state";
|
||||
|
||||
import {
|
||||
convertArrayOfRowsToTableNode,
|
||||
convertTableNodeToArrayOfRows
|
||||
} from "./convert.js";
|
||||
import { getSelectionRangeInColumn } from "./selection-range.js";
|
||||
import { moveRowInArrayOfRows } from "./move-row-in-array-of-rows.js";
|
||||
import { findTable } from "./query.js";
|
||||
import { transpose } from "./transpose.js";
|
||||
import { TableMap } from "../tablemap.js";
|
||||
import { CellSelection } from "../cellselection.js";
|
||||
|
||||
/**
|
||||
* Parameters for moving a column in a table.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface MoveColumnParams {
|
||||
tr: Transaction;
|
||||
originIndex: number;
|
||||
targetIndex: number;
|
||||
select: boolean;
|
||||
pos: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a column from index `origin` to index `target`.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function moveColumn(moveColParams: MoveColumnParams): boolean {
|
||||
const { tr, originIndex, targetIndex, select, pos } = moveColParams;
|
||||
const $pos = tr.doc.resolve(pos);
|
||||
const table = findTable($pos);
|
||||
if (!table) return false;
|
||||
|
||||
const indexesOriginColumn = getSelectionRangeInColumn(
|
||||
tr,
|
||||
originIndex
|
||||
)?.indexes;
|
||||
const indexesTargetColumn = getSelectionRangeInColumn(
|
||||
tr,
|
||||
targetIndex
|
||||
)?.indexes;
|
||||
|
||||
if (!indexesOriginColumn || !indexesTargetColumn) return false;
|
||||
|
||||
if (indexesOriginColumn.includes(targetIndex)) return false;
|
||||
|
||||
const newTable = moveTableColumn(
|
||||
table.node,
|
||||
indexesOriginColumn,
|
||||
indexesTargetColumn,
|
||||
0
|
||||
);
|
||||
|
||||
tr.replaceWith(table.pos, table.pos + table.node.nodeSize, newTable);
|
||||
|
||||
if (!select) return true;
|
||||
|
||||
const map = TableMap.get(newTable);
|
||||
const start = table.start;
|
||||
const index = targetIndex;
|
||||
const lastCell = map.positionAt(map.height - 1, index, newTable);
|
||||
const $lastCell = tr.doc.resolve(start + lastCell);
|
||||
|
||||
const firstCell = map.positionAt(0, index, newTable);
|
||||
const $firstCell = tr.doc.resolve(start + firstCell);
|
||||
|
||||
tr.setSelection(CellSelection.colSelection($lastCell, $firstCell));
|
||||
return true;
|
||||
}
|
||||
|
||||
function moveTableColumn(
|
||||
table: Node,
|
||||
indexesOrigin: number[],
|
||||
indexesTarget: number[],
|
||||
direction: -1 | 1 | 0
|
||||
) {
|
||||
let rows = transpose(convertTableNodeToArrayOfRows(table));
|
||||
|
||||
rows = moveRowInArrayOfRows(rows, indexesOrigin, indexesTarget, direction);
|
||||
rows = transpose(rows);
|
||||
|
||||
return convertArrayOfRowsToTableNode(table, rows);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Move a row in an array of rows.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function moveRowInArrayOfRows<T>(
|
||||
rows: T[],
|
||||
indexesOrigin: number[],
|
||||
indexesTarget: number[],
|
||||
directionOverride: -1 | 1 | 0,
|
||||
): T[] {
|
||||
const direction = indexesOrigin[0] > indexesTarget[0] ? -1 : 1;
|
||||
|
||||
const rowsExtracted = rows.splice(indexesOrigin[0], indexesOrigin.length);
|
||||
const positionOffset = rowsExtracted.length % 2 === 0 ? 1 : 0;
|
||||
let target: number;
|
||||
|
||||
if (directionOverride === -1 && direction === 1) {
|
||||
target = indexesTarget[0] - 1;
|
||||
} else if (directionOverride === 1 && direction === -1) {
|
||||
target = indexesTarget[indexesTarget.length - 1] - positionOffset + 1;
|
||||
} else {
|
||||
target =
|
||||
direction === -1
|
||||
? indexesTarget[0]
|
||||
: indexesTarget[indexesTarget.length - 1] - positionOffset;
|
||||
}
|
||||
|
||||
rows.splice(target, 0, ...rowsExtracted);
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Node } from "prosemirror-model";
|
||||
import type { Transaction } from "prosemirror-state";
|
||||
|
||||
import {
|
||||
convertArrayOfRowsToTableNode,
|
||||
convertTableNodeToArrayOfRows
|
||||
} from "./convert.js";
|
||||
import { getSelectionRangeInRow } from "./selection-range.js";
|
||||
import { moveRowInArrayOfRows } from "./move-row-in-array-of-rows.js";
|
||||
import { findTable } from "./query.js";
|
||||
import { TableMap } from "../tablemap.js";
|
||||
import { CellSelection } from "../cellselection.js";
|
||||
|
||||
/**
|
||||
* Parameters for moving a row in a table.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface MoveRowParams {
|
||||
tr: Transaction;
|
||||
originIndex: number;
|
||||
targetIndex: number;
|
||||
select: boolean;
|
||||
pos: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a row from index `origin` to index `target`.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function moveRow(moveRowParams: MoveRowParams): boolean {
|
||||
const { tr, originIndex, targetIndex, select, pos } = moveRowParams;
|
||||
const $pos = tr.doc.resolve(pos);
|
||||
const table = findTable($pos);
|
||||
if (!table) return false;
|
||||
|
||||
const indexesOriginRow = getSelectionRangeInRow(tr, originIndex)?.indexes;
|
||||
const indexesTargetRow = getSelectionRangeInRow(tr, targetIndex)?.indexes;
|
||||
|
||||
if (!indexesOriginRow || !indexesTargetRow) return false;
|
||||
|
||||
if (indexesOriginRow.includes(targetIndex)) return false;
|
||||
|
||||
const newTable = moveTableRow(
|
||||
table.node,
|
||||
indexesOriginRow,
|
||||
indexesTargetRow,
|
||||
0
|
||||
);
|
||||
|
||||
tr.replaceWith(table.pos, table.pos + table.node.nodeSize, newTable);
|
||||
|
||||
if (!select) return true;
|
||||
|
||||
const map = TableMap.get(newTable);
|
||||
const start = table.start;
|
||||
const index = targetIndex;
|
||||
const lastCell = map.positionAt(index, map.width - 1, newTable);
|
||||
const $lastCell = tr.doc.resolve(start + lastCell);
|
||||
|
||||
const firstCell = map.positionAt(index, 0, newTable);
|
||||
const $firstCell = tr.doc.resolve(start + firstCell);
|
||||
|
||||
tr.setSelection(CellSelection.rowSelection($lastCell, $firstCell));
|
||||
return true;
|
||||
}
|
||||
|
||||
function moveTableRow(
|
||||
table: Node,
|
||||
indexesOrigin: number[],
|
||||
indexesTarget: number[],
|
||||
direction: -1 | 1 | 0
|
||||
) {
|
||||
let rows = convertTableNodeToArrayOfRows(table);
|
||||
|
||||
rows = moveRowInArrayOfRows(rows, indexesOrigin, indexesTarget, direction);
|
||||
|
||||
return convertArrayOfRowsToTableNode(table, rows);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Node, ResolvedPos } from "prosemirror-model";
|
||||
import type { Selection } from "prosemirror-state";
|
||||
import { CellSelection } from "../cellselection.js";
|
||||
import { cellAround, cellNear, inSameTable } from "../util.js";
|
||||
|
||||
/**
|
||||
* Checks if the given object is a `CellSelection` instance.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
function isCellSelection(value: unknown): value is CellSelection {
|
||||
return value instanceof CellSelection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the closest table node for a given position.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function findTable($pos: ResolvedPos): FindNodeResult | null {
|
||||
return findParentNode((node) => node.type.spec.tableRole === "table", $pos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find the anchor and head cell in the same table by using the given
|
||||
* anchor and head as hit points, or fallback to the selection's anchor and
|
||||
* head.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function findCellRange(
|
||||
selection: Selection,
|
||||
anchorHit?: number,
|
||||
headHit?: number
|
||||
): [ResolvedPos, ResolvedPos] | null {
|
||||
if (anchorHit == null && headHit == null && isCellSelection(selection)) {
|
||||
return [selection.$anchorCell, selection.$headCell];
|
||||
}
|
||||
|
||||
const anchor: number = anchorHit ?? headHit ?? selection.anchor;
|
||||
const head: number = headHit ?? anchorHit ?? selection.head;
|
||||
|
||||
const doc = selection.$head.doc;
|
||||
|
||||
const $anchorCell = findCellPos(doc, anchor);
|
||||
const $headCell = findCellPos(doc, head);
|
||||
|
||||
if ($anchorCell && $headCell && inSameTable($anchorCell, $headCell)) {
|
||||
return [$anchorCell, $headCell];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find a resolved pos of a cell by using the given pos as a hit point.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function findCellPos(doc: Node, pos: number): ResolvedPos | undefined {
|
||||
const $pos = doc.resolve(pos);
|
||||
return cellAround($pos) || cellNear($pos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of finding a parent node.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface FindNodeResult {
|
||||
/**
|
||||
* The closest parent node that satisfies the predicate.
|
||||
*/
|
||||
node: Node;
|
||||
|
||||
/**
|
||||
* The position directly before the node.
|
||||
*/
|
||||
pos: number;
|
||||
|
||||
/**
|
||||
* The position at the start of the node.
|
||||
*/
|
||||
start: number;
|
||||
|
||||
/**
|
||||
* The depth of the node.
|
||||
*/
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the closest parent node that satisfies the predicate.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
function findParentNode(
|
||||
/**
|
||||
* The predicate to test the parent node.
|
||||
*/
|
||||
predicate: (node: Node) => boolean,
|
||||
/**
|
||||
* The position to start searching from.
|
||||
*/
|
||||
$pos: ResolvedPos
|
||||
): FindNodeResult | null {
|
||||
for (let depth = $pos.depth; depth >= 0; depth -= 1) {
|
||||
const node = $pos.node(depth);
|
||||
|
||||
if (predicate(node)) {
|
||||
const pos = depth === 0 ? 0 : $pos.before(depth);
|
||||
const start = $pos.start(depth);
|
||||
return { node, pos, start, depth };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { ResolvedPos } from "prosemirror-model";
|
||||
import type { Transaction } from "prosemirror-state";
|
||||
|
||||
import { getCellsInColumn, getCellsInRow } from "./get-cells.js";
|
||||
|
||||
export type CellSelectionRange = {
|
||||
$anchor: ResolvedPos;
|
||||
$head: ResolvedPos;
|
||||
// an array of column/row indexes
|
||||
indexes: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a range of rectangular selection spanning all merged cells around a
|
||||
* column at index `columnIndex`.
|
||||
*
|
||||
* Original implementation from Atlassian (Apache License 2.0)
|
||||
*
|
||||
* https://bitbucket.org/atlassian/atlassian-frontend-mirror/src/5f91cb871e8248bc3bae5ddc30bb9fd9200fadbb/editor/editor-tables/src/utils/get-selection-range-in-column.ts#editor/editor-tables/src/utils/get-selection-range-in-column.ts
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function getSelectionRangeInColumn(
|
||||
tr: Transaction,
|
||||
startColIndex: number,
|
||||
endColIndex: number = startColIndex
|
||||
): CellSelectionRange | undefined {
|
||||
let startIndex = startColIndex;
|
||||
let endIndex = endColIndex;
|
||||
|
||||
// looking for selection start column (startIndex)
|
||||
for (let i = startColIndex; i >= 0; i--) {
|
||||
const cells = getCellsInColumn(i, tr.selection);
|
||||
if (cells) {
|
||||
cells.forEach((cell) => {
|
||||
const maybeEndIndex = cell.node.attrs.colspan + i - 1;
|
||||
if (maybeEndIndex >= startIndex) {
|
||||
startIndex = i;
|
||||
}
|
||||
if (maybeEndIndex > endIndex) {
|
||||
endIndex = maybeEndIndex;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// looking for selection end column (endIndex)
|
||||
for (let i = startColIndex; i <= endIndex; i++) {
|
||||
const cells = getCellsInColumn(i, tr.selection);
|
||||
if (cells) {
|
||||
cells.forEach((cell) => {
|
||||
const maybeEndIndex = cell.node.attrs.colspan + i - 1;
|
||||
if (cell.node.attrs.colspan > 1 && maybeEndIndex > endIndex) {
|
||||
endIndex = maybeEndIndex;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// filter out columns without cells (where all rows have colspan > 1 in the same column)
|
||||
const indexes = [];
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
const maybeCells = getCellsInColumn(i, tr.selection);
|
||||
if (maybeCells && maybeCells.length > 0) {
|
||||
indexes.push(i);
|
||||
}
|
||||
}
|
||||
startIndex = indexes[0];
|
||||
endIndex = indexes[indexes.length - 1];
|
||||
|
||||
const firstSelectedColumnCells = getCellsInColumn(startIndex, tr.selection);
|
||||
const firstRowCells = getCellsInRow(0, tr.selection);
|
||||
if (!firstSelectedColumnCells || !firstRowCells) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $anchor = tr.doc.resolve(
|
||||
firstSelectedColumnCells[firstSelectedColumnCells.length - 1].pos
|
||||
);
|
||||
|
||||
let headCell;
|
||||
for (let i = endIndex; i >= startIndex; i--) {
|
||||
const columnCells = getCellsInColumn(i, tr.selection);
|
||||
if (columnCells && columnCells.length > 0) {
|
||||
for (let j = firstRowCells.length - 1; j >= 0; j--) {
|
||||
if (firstRowCells[j].pos === columnCells[0].pos) {
|
||||
headCell = columnCells[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (headCell) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!headCell) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $head = tr.doc.resolve(headCell.pos);
|
||||
return { $anchor, $head, indexes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a range of rectangular selection spanning all merged cells around a
|
||||
* row at index `rowIndex`.
|
||||
*
|
||||
* Original implementation from Atlassian (Apache License 2.0)
|
||||
*
|
||||
* https://bitbucket.org/atlassian/atlassian-frontend-mirror/src/5f91cb871e8248bc3bae5ddc30bb9fd9200fadbb/editor/editor-tables/src/utils/get-selection-range-in-row.ts#editor/editor-tables/src/utils/get-selection-range-in-row.ts
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function getSelectionRangeInRow(
|
||||
tr: Transaction,
|
||||
startRowIndex: number,
|
||||
endRowIndex: number = startRowIndex
|
||||
): CellSelectionRange | undefined {
|
||||
let startIndex = startRowIndex;
|
||||
let endIndex = endRowIndex;
|
||||
|
||||
// looking for selection start row (startIndex)
|
||||
for (let i = startRowIndex; i >= 0; i--) {
|
||||
const cells = getCellsInRow(i, tr.selection);
|
||||
if (cells) {
|
||||
cells.forEach((cell) => {
|
||||
const maybeEndIndex = cell.node.attrs.rowspan + i - 1;
|
||||
if (maybeEndIndex >= startIndex) {
|
||||
startIndex = i;
|
||||
}
|
||||
if (maybeEndIndex > endIndex) {
|
||||
endIndex = maybeEndIndex;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// looking for selection end row (endIndex)
|
||||
for (let i = startRowIndex; i <= endIndex; i++) {
|
||||
const cells = getCellsInRow(i, tr.selection);
|
||||
if (cells) {
|
||||
cells.forEach((cell) => {
|
||||
const maybeEndIndex = cell.node.attrs.rowspan + i - 1;
|
||||
if (cell.node.attrs.rowspan > 1 && maybeEndIndex > endIndex) {
|
||||
endIndex = maybeEndIndex;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// filter out rows without cells (where all columns have rowspan > 1 in the same row)
|
||||
const indexes = [];
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
const maybeCells = getCellsInRow(i, tr.selection);
|
||||
if (maybeCells && maybeCells.length > 0) {
|
||||
indexes.push(i);
|
||||
}
|
||||
}
|
||||
startIndex = indexes[0];
|
||||
endIndex = indexes[indexes.length - 1];
|
||||
|
||||
const firstSelectedRowCells = getCellsInRow(startIndex, tr.selection);
|
||||
const firstColumnCells = getCellsInColumn(0, tr.selection);
|
||||
if (!firstSelectedRowCells || !firstColumnCells) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $anchor = tr.doc.resolve(
|
||||
firstSelectedRowCells[firstSelectedRowCells.length - 1].pos
|
||||
);
|
||||
|
||||
let headCell;
|
||||
for (let i = endIndex; i >= startIndex; i--) {
|
||||
const rowCells = getCellsInRow(i, tr.selection);
|
||||
if (rowCells && rowCells.length > 0) {
|
||||
for (let j = firstColumnCells.length - 1; j >= 0; j--) {
|
||||
if (firstColumnCells[j].pos === rowCells[0].pos) {
|
||||
headCell = rowCells[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (headCell) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!headCell) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $head = tr.doc.resolve(headCell.pos);
|
||||
return { $anchor, $head, indexes };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Transposes a 2D array by flipping columns to rows.
|
||||
*
|
||||
* Transposition is a familiar algebra concept where the matrix is flipped
|
||||
* along its diagonal. For more details, see:
|
||||
* https://en.wikipedia.org/wiki/Transpose
|
||||
*
|
||||
* @example
|
||||
* ```javascript
|
||||
* const arr = [
|
||||
* ['a1', 'a2', 'a3'],
|
||||
* ['b1', 'b2', 'b3'],
|
||||
* ['c1', 'c2', 'c3'],
|
||||
* ['d1', 'd2', 'd3'],
|
||||
* ];
|
||||
*
|
||||
* const result = transpose(arr);
|
||||
* result === [
|
||||
* ['a1', 'b1', 'c1', 'd1'],
|
||||
* ['a2', 'b2', 'c2', 'd2'],
|
||||
* ['a3', 'b3', 'c3', 'd3'],
|
||||
* ]
|
||||
* ```
|
||||
*/
|
||||
export function transpose<T>(array: T[][]): T[][] {
|
||||
return array[0].map((_, i) => {
|
||||
return array.map((column) => column[i]);
|
||||
});
|
||||
}
|
||||
@@ -17,12 +17,465 @@ You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Table as TiptapTable, TableOptions } from "@tiptap/extension-table";
|
||||
import { tableEditing, columnResizing, TableView } from "@tiptap/pm/tables";
|
||||
import {
|
||||
callOrReturn,
|
||||
getExtensionField,
|
||||
mergeAttributes,
|
||||
Node,
|
||||
ParentConfig
|
||||
} from "@tiptap/core";
|
||||
import { DOMOutputSpec, Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||
import { EditorView, NodeView } from "@tiptap/pm/view";
|
||||
import { createColGroup } from "./utilities/createColGroup.js";
|
||||
import { createTable } from "./utilities/createTable.js";
|
||||
import { deleteTableWhenAllCellsSelected } from "./utilities/deleteTableWhenAllCellsSelected.js";
|
||||
import { TableView } from "./TableView.js";
|
||||
import { TableNodeView } from "./component.js";
|
||||
import { Plugin, PluginKey } from "prosemirror-state";
|
||||
import {
|
||||
addColumnAfter,
|
||||
addColumnBefore,
|
||||
addRowAfter,
|
||||
addRowBefore,
|
||||
deleteColumn,
|
||||
deleteRow,
|
||||
deleteTable,
|
||||
goToNextCell,
|
||||
mergeCells,
|
||||
setCellAttr,
|
||||
splitCell,
|
||||
toggleHeader,
|
||||
toggleHeaderCell
|
||||
} from "./prosemirror-tables/commands.js";
|
||||
import { fixTables } from "./prosemirror-tables/fixtables.js";
|
||||
import { CellSelection } from "./prosemirror-tables/cellselection.js";
|
||||
import { columnResizing } from "./prosemirror-tables/columnresizing.js";
|
||||
import { tableEditing } from "./prosemirror-tables/index.js";
|
||||
|
||||
export interface TableOptions {
|
||||
/**
|
||||
* HTML attributes for the table element.
|
||||
* @default {}
|
||||
* @example { class: 'foo' }
|
||||
*/
|
||||
HTMLAttributes: Record<string, any>;
|
||||
|
||||
/**
|
||||
* Enables the resizing of tables.
|
||||
* @default false
|
||||
* @example true
|
||||
*/
|
||||
resizable: boolean;
|
||||
|
||||
/**
|
||||
* The minimum width of a cell.
|
||||
* @default 25
|
||||
* @example 50
|
||||
*/
|
||||
cellMinWidth: number;
|
||||
|
||||
showResizeHandleOnSelection: boolean;
|
||||
|
||||
/**
|
||||
* The node view to render the table.
|
||||
* @default TableView
|
||||
*/
|
||||
View:
|
||||
| (new (
|
||||
node: ProseMirrorNode,
|
||||
cellMinWidth: number,
|
||||
view: EditorView
|
||||
) => NodeView)
|
||||
| null;
|
||||
|
||||
/**
|
||||
* Allow table node selection.
|
||||
* @default false
|
||||
* @example true
|
||||
*/
|
||||
allowTableNodeSelection: boolean;
|
||||
|
||||
defaultCellAttrs: { colwidth?: number[] };
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
table: {
|
||||
/**
|
||||
* Insert a table
|
||||
* @param options The table attributes
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||
*/
|
||||
insertTable: (options?: {
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
withHeaderRow?: boolean;
|
||||
}) => ReturnType;
|
||||
|
||||
/**
|
||||
* Add a column before the current column
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.addColumnBefore()
|
||||
*/
|
||||
addColumnBefore: () => ReturnType;
|
||||
|
||||
/**
|
||||
* Add a column after the current column
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.addColumnAfter()
|
||||
*/
|
||||
addColumnAfter: () => ReturnType;
|
||||
|
||||
/**
|
||||
* Delete the current column
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.deleteColumn()
|
||||
*/
|
||||
deleteColumn: () => ReturnType;
|
||||
|
||||
/**
|
||||
* Add a row before the current row
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.addRowBefore()
|
||||
*/
|
||||
addRowBefore: () => ReturnType;
|
||||
|
||||
/**
|
||||
* Add a row after the current row
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.addRowAfter()
|
||||
*/
|
||||
addRowAfter: () => ReturnType;
|
||||
|
||||
/**
|
||||
* Delete the current row
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.deleteRow()
|
||||
*/
|
||||
deleteRow: () => ReturnType;
|
||||
|
||||
/**
|
||||
* Delete the current table
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.deleteTable()
|
||||
*/
|
||||
deleteTable: () => ReturnType;
|
||||
|
||||
/**
|
||||
* Merge the currently selected cells
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.mergeCells()
|
||||
*/
|
||||
mergeCells: () => ReturnType;
|
||||
|
||||
/**
|
||||
* Split the currently selected cell
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.splitCell()
|
||||
*/
|
||||
splitCell: () => ReturnType;
|
||||
|
||||
/**
|
||||
* Toggle the header column
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.toggleHeaderColumn()
|
||||
*/
|
||||
toggleHeaderColumn: () => ReturnType;
|
||||
|
||||
/**
|
||||
* Toggle the header row
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.toggleHeaderRow()
|
||||
*/
|
||||
toggleHeaderRow: () => ReturnType;
|
||||
|
||||
/**
|
||||
* Toggle the header cell
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.toggleHeaderCell()
|
||||
*/
|
||||
toggleHeaderCell: () => ReturnType;
|
||||
|
||||
/**
|
||||
* Merge or split the currently selected cells
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.mergeOrSplit()
|
||||
*/
|
||||
mergeOrSplit: () => ReturnType;
|
||||
|
||||
/**
|
||||
* Set a cell attribute
|
||||
* @param name The attribute name
|
||||
* @param value The attribute value
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.setCellAttribute('align', 'right')
|
||||
*/
|
||||
setCellAttribute: (name: string, value: any) => ReturnType;
|
||||
|
||||
/**
|
||||
* Moves the selection to the next cell
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.goToNextCell()
|
||||
*/
|
||||
goToNextCell: () => ReturnType;
|
||||
|
||||
/**
|
||||
* Moves the selection to the previous cell
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.goToPreviousCell()
|
||||
*/
|
||||
goToPreviousCell: () => ReturnType;
|
||||
|
||||
/**
|
||||
* Try to fix the table structure if necessary
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.fixTables()
|
||||
*/
|
||||
fixTables: () => ReturnType;
|
||||
|
||||
/**
|
||||
* Set a cell selection inside the current table
|
||||
* @param position The cell position
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.setCellSelection({ anchorCell: 1, headCell: 2 })
|
||||
*/
|
||||
setCellSelection: (position: {
|
||||
anchorCell: number;
|
||||
headCell?: number;
|
||||
}) => ReturnType;
|
||||
};
|
||||
}
|
||||
|
||||
interface NodeConfig<Options, Storage> {
|
||||
/**
|
||||
* A string or function to determine the role of the table.
|
||||
* @default 'table'
|
||||
* @example () => 'table'
|
||||
*/
|
||||
tableRole?:
|
||||
| string
|
||||
| ((this: {
|
||||
name: string;
|
||||
options: Options;
|
||||
storage: Storage;
|
||||
parent: ParentConfig<NodeConfig<Options>>["tableRole"];
|
||||
}) => string);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This extension allows you to create tables.
|
||||
* @see https://www.tiptap.dev/api/nodes/table
|
||||
*/
|
||||
export const Table = Node.create<TableOptions>({
|
||||
name: "table",
|
||||
|
||||
// @ts-ignore
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
resizable: false,
|
||||
showResizeHandleOnSelection: false,
|
||||
cellMinWidth: 25,
|
||||
allowTableNodeSelection: false,
|
||||
defaultCellAttrs: {}
|
||||
};
|
||||
},
|
||||
|
||||
content: "tableRow+",
|
||||
|
||||
tableRole: "table",
|
||||
|
||||
isolating: true,
|
||||
|
||||
group: "block",
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "table" }];
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
const { colgroup, tableWidth, tableMinWidth } = createColGroup(
|
||||
node,
|
||||
this.options.cellMinWidth
|
||||
);
|
||||
|
||||
const table: DOMOutputSpec = [
|
||||
"table",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
style: tableWidth
|
||||
? `width: ${tableWidth}`
|
||||
: `min-width: ${tableMinWidth}`
|
||||
}),
|
||||
colgroup,
|
||||
["tbody", 0]
|
||||
];
|
||||
|
||||
return table;
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertTable:
|
||||
({ rows = 3, cols = 3, withHeaderRow = true } = {}) =>
|
||||
({ tr, dispatch, editor }) => {
|
||||
const node = createTable(
|
||||
editor.schema,
|
||||
rows,
|
||||
cols,
|
||||
withHeaderRow,
|
||||
undefined,
|
||||
this.options.defaultCellAttrs
|
||||
);
|
||||
|
||||
if (dispatch) {
|
||||
const offset = tr.selection.from + 1;
|
||||
|
||||
tr.replaceSelectionWith(node)
|
||||
.scrollIntoView()
|
||||
.setSelection(TextSelection.near(tr.doc.resolve(offset)));
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
addColumnBefore:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return addColumnBefore(
|
||||
state,
|
||||
dispatch,
|
||||
this.options.defaultCellAttrs
|
||||
);
|
||||
},
|
||||
addColumnAfter:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return addColumnAfter(state, dispatch, this.options.defaultCellAttrs);
|
||||
},
|
||||
deleteColumn:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return deleteColumn(state, dispatch);
|
||||
},
|
||||
addRowBefore:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return addRowBefore(state, dispatch);
|
||||
},
|
||||
addRowAfter:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return addRowAfter(state, dispatch);
|
||||
},
|
||||
deleteRow:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return deleteRow(state, dispatch);
|
||||
},
|
||||
deleteTable:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return deleteTable(state, dispatch);
|
||||
},
|
||||
mergeCells:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return mergeCells(state, dispatch);
|
||||
},
|
||||
splitCell:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return splitCell(state, dispatch);
|
||||
},
|
||||
toggleHeaderColumn:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return toggleHeader("column")(state, dispatch);
|
||||
},
|
||||
toggleHeaderRow:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return toggleHeader("row")(state, dispatch);
|
||||
},
|
||||
toggleHeaderCell:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return toggleHeaderCell(state, dispatch);
|
||||
},
|
||||
mergeOrSplit:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
if (mergeCells(state, dispatch)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return splitCell(state, dispatch);
|
||||
},
|
||||
setCellAttribute:
|
||||
(name, value) =>
|
||||
({ state, dispatch }) => {
|
||||
return setCellAttr(name, value)(state, dispatch);
|
||||
},
|
||||
goToNextCell:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return goToNextCell(1)(state, dispatch);
|
||||
},
|
||||
goToPreviousCell:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return goToNextCell(-1)(state, dispatch);
|
||||
},
|
||||
fixTables:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
if (dispatch) {
|
||||
fixTables(state);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
setCellSelection:
|
||||
(position) =>
|
||||
({ tr, dispatch }) => {
|
||||
if (dispatch) {
|
||||
const selection = CellSelection.create(
|
||||
tr.doc,
|
||||
position.anchorCell,
|
||||
position.headCell
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
tr.setSelection(selection);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Tab: () => {
|
||||
if (this.editor.commands.goToNextCell()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!this.editor.can().addRowAfter()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.editor.chain().addRowAfter().goToNextCell().run();
|
||||
},
|
||||
"Shift-Tab": () => this.editor.commands.goToPreviousCell(),
|
||||
Backspace: deleteTableWhenAllCellsSelected,
|
||||
"Mod-Backspace": deleteTableWhenAllCellsSelected,
|
||||
Delete: deleteTableWhenAllCellsSelected,
|
||||
"Mod-Delete": deleteTableWhenAllCellsSelected
|
||||
};
|
||||
},
|
||||
|
||||
export const Table = TiptapTable.extend<TableOptions>({
|
||||
addProseMirrorPlugins() {
|
||||
const isResizable = this.options.resizable && this.editor.isEditable;
|
||||
|
||||
@@ -30,10 +483,10 @@ export const Table = TiptapTable.extend<TableOptions>({
|
||||
...(isResizable
|
||||
? [
|
||||
columnResizing({
|
||||
handleWidth: this.options.handleWidth,
|
||||
cellMinWidth: this.options.cellMinWidth,
|
||||
View: TableNodeView(this.editor),
|
||||
lastColumnResizable: this.options.lastColumnResizable
|
||||
showResizeHandleOnSelection:
|
||||
this.options.showResizeHandleOnSelection
|
||||
})
|
||||
]
|
||||
: [tiptapTableView(this.options.cellMinWidth)]),
|
||||
@@ -41,6 +494,20 @@ export const Table = TiptapTable.extend<TableOptions>({
|
||||
allowTableNodeSelection: this.options.allowTableNodeSelection
|
||||
})
|
||||
];
|
||||
},
|
||||
|
||||
extendNodeSchema(extension) {
|
||||
const context = {
|
||||
name: extension.name,
|
||||
options: extension.options,
|
||||
storage: extension.storage
|
||||
};
|
||||
|
||||
return {
|
||||
tableRole: callOrReturn(
|
||||
getExtensionField(extension, "tableRole", context)
|
||||
)
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
13
packages/editor/src/extensions/table/utilities/createCell.ts
Normal file
13
packages/editor/src/extensions/table/utilities/createCell.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Fragment, Node as ProsemirrorNode, NodeType } from "@tiptap/pm/model";
|
||||
|
||||
export function createCell(
|
||||
cellType: NodeType,
|
||||
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>,
|
||||
defaultCellAttrs?: { colwidth?: number[] }
|
||||
): ProsemirrorNode | null | undefined {
|
||||
if (cellContent) {
|
||||
return cellType.createChecked(defaultCellAttrs, cellContent);
|
||||
}
|
||||
|
||||
return cellType.createAndFill(defaultCellAttrs);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { DOMOutputSpec, Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
|
||||
/**
|
||||
* Creates a colgroup element for a table node in ProseMirror.
|
||||
*
|
||||
* @param node - The ProseMirror node representing the table.
|
||||
* @param cellMinWidth - The minimum width of a cell in the table.
|
||||
* @param overrideCol - (Optional) The index of the column to override the width of.
|
||||
* @param overrideValue - (Optional) The width value to use for the overridden column.
|
||||
* @returns An object containing the colgroup element, the total width of the table, and the minimum width of the table.
|
||||
*/
|
||||
export function createColGroup(
|
||||
node: ProseMirrorNode,
|
||||
cellMinWidth: number,
|
||||
overrideCol?: number,
|
||||
overrideValue?: any,
|
||||
) {
|
||||
let totalWidth = 0
|
||||
let fixedWidth = true
|
||||
const cols: DOMOutputSpec[] = []
|
||||
const row = node.firstChild
|
||||
|
||||
if (!row) {
|
||||
return {}
|
||||
}
|
||||
|
||||
for (let i = 0, col = 0; i < row.childCount; i += 1) {
|
||||
const { colspan, colwidth } = row.child(i).attrs
|
||||
|
||||
for (let j = 0; j < colspan; j += 1, col += 1) {
|
||||
const hasWidth = overrideCol === col ? overrideValue : colwidth && colwidth[j]
|
||||
const cssWidth = hasWidth ? `${hasWidth}px` : ''
|
||||
|
||||
totalWidth += hasWidth || cellMinWidth
|
||||
|
||||
if (!hasWidth) {
|
||||
fixedWidth = false
|
||||
}
|
||||
|
||||
cols.push(['col', cssWidth ? { style: `width: ${cssWidth}` } : {}])
|
||||
}
|
||||
}
|
||||
|
||||
const tableWidth = fixedWidth ? `${totalWidth}px` : ''
|
||||
const tableMinWidth = fixedWidth ? '' : `${totalWidth}px`
|
||||
|
||||
const colgroup: DOMOutputSpec = ['colgroup', {}, ...cols]
|
||||
|
||||
return { colgroup, tableWidth, tableMinWidth }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Fragment, Node as ProsemirrorNode, Schema } from "@tiptap/pm/model";
|
||||
|
||||
import { createCell } from "./createCell.js";
|
||||
import { getTableNodeTypes } from "./getTableNodeTypes.js";
|
||||
|
||||
export function createTable(
|
||||
schema: Schema,
|
||||
rowsCount: number,
|
||||
colsCount: number,
|
||||
withHeaderRow: boolean,
|
||||
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>,
|
||||
defaultCellAttrs?: { colwidth?: number[] }
|
||||
): ProsemirrorNode {
|
||||
const types = getTableNodeTypes(schema);
|
||||
const headerCells: ProsemirrorNode[] = [];
|
||||
const cells: ProsemirrorNode[] = [];
|
||||
|
||||
for (let index = 0; index < colsCount; index += 1) {
|
||||
const cell = createCell(types.cell, cellContent, defaultCellAttrs);
|
||||
|
||||
if (cell) {
|
||||
cells.push(cell);
|
||||
}
|
||||
|
||||
if (withHeaderRow) {
|
||||
const headerCell = createCell(
|
||||
types.header_cell,
|
||||
cellContent,
|
||||
defaultCellAttrs
|
||||
);
|
||||
|
||||
if (headerCell) {
|
||||
headerCells.push(headerCell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rows: ProsemirrorNode[] = [];
|
||||
|
||||
for (let index = 0; index < rowsCount; index += 1) {
|
||||
rows.push(
|
||||
types.row.createChecked(
|
||||
null,
|
||||
withHeaderRow && index === 0 ? headerCells : cells
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return types.table.createChecked(null, rows);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { findParentNodeClosestToPos, KeyboardShortcutCommand } from '@tiptap/core'
|
||||
|
||||
import { isCellSelection } from './isCellSelection.js'
|
||||
|
||||
export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({ editor }) => {
|
||||
const { selection } = editor.state
|
||||
|
||||
if (!isCellSelection(selection)) {
|
||||
return false
|
||||
}
|
||||
|
||||
let cellCount = 0
|
||||
const table = findParentNodeClosestToPos(selection.ranges[0].$from, node => {
|
||||
return node.type.name === 'table'
|
||||
})
|
||||
|
||||
table?.node.descendants(node => {
|
||||
if (node.type.name === 'table') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (['tableCell', 'tableHeader'].includes(node.type.name)) {
|
||||
cellCount += 1
|
||||
}
|
||||
})
|
||||
|
||||
const allCellsSelected = cellCount === selection.ranges.length
|
||||
|
||||
if (!allCellsSelected) {
|
||||
return false
|
||||
}
|
||||
|
||||
editor.commands.deleteTable()
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NodeType, Schema } from '@tiptap/pm/model'
|
||||
|
||||
export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } {
|
||||
if (schema.cached.tableNodeTypes) {
|
||||
return schema.cached.tableNodeTypes
|
||||
}
|
||||
|
||||
const roles: { [key: string]: NodeType } = {}
|
||||
|
||||
Object.keys(schema.nodes).forEach(type => {
|
||||
const nodeType = schema.nodes[type]
|
||||
|
||||
if (nodeType.spec.tableRole) {
|
||||
roles[nodeType.spec.tableRole] = nodeType
|
||||
}
|
||||
})
|
||||
|
||||
schema.cached.tableNodeTypes = roles
|
||||
|
||||
return roles
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CellSelection } from "../prosemirror-tables/cellselection.js";
|
||||
|
||||
export function isCellSelection(value: unknown): value is CellSelection {
|
||||
return value instanceof CellSelection;
|
||||
}
|
||||
@@ -285,7 +285,11 @@ const useTiptap = (
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
allowTableNodeSelection: true,
|
||||
cellMinWidth: 50
|
||||
cellMinWidth: 20,
|
||||
showResizeHandleOnSelection: isMobile,
|
||||
defaultCellAttrs: {
|
||||
colwidth: [100]
|
||||
}
|
||||
}),
|
||||
Clipboard,
|
||||
TableRow,
|
||||
@@ -385,6 +389,7 @@ const useTiptap = (
|
||||
parseOptions: { preserveWhitespace: true }
|
||||
}),
|
||||
[
|
||||
isMobile,
|
||||
previewAttachment,
|
||||
downloadAttachment,
|
||||
openAttachmentPicker,
|
||||
|
||||
@@ -41,8 +41,6 @@ import { useToolbarLocation } from "../stores/toolbar-store.js";
|
||||
import { showPopup } from "../../components/popup-presenter/index.js";
|
||||
import { useRefValue } from "../../hooks/use-ref-value.js";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { selectedRect } from "@tiptap/pm/tables";
|
||||
import { TextSelection } from "@tiptap/pm/state";
|
||||
|
||||
export function TableSettings(props: ToolProps) {
|
||||
const { editor } = props;
|
||||
@@ -90,6 +88,7 @@ export function RowProperties(props: ToolProps) {
|
||||
<ToolButton
|
||||
icon={props.icon}
|
||||
title={props.title}
|
||||
variant={props.variant}
|
||||
buttonRef={buttonRef}
|
||||
toggled={isMenuOpen}
|
||||
onClick={() => setIsMenuOpen(true)}
|
||||
@@ -134,6 +133,7 @@ export function ColumnProperties(props: ToolProps) {
|
||||
<ToolButton
|
||||
icon={props.icon}
|
||||
title={props.title}
|
||||
variant={props.variant}
|
||||
buttonRef={buttonRef}
|
||||
toggled={isMenuOpen}
|
||||
onClick={() => setIsMenuOpen(true)}
|
||||
@@ -184,6 +184,7 @@ export function TableProperties(props: ToolProps) {
|
||||
<ToolButton
|
||||
icon={props.icon}
|
||||
title={props.title}
|
||||
variant={props.variant}
|
||||
buttonRef={buttonRef}
|
||||
toggled={isMenuOpen}
|
||||
onClick={() => setIsMenuOpen(true)}
|
||||
|
||||
@@ -228,7 +228,7 @@ img.ProseMirror-separator {
|
||||
.ProseMirror table {
|
||||
border-collapse: collapse;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
overflow-y: hidden;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
@@ -242,10 +242,6 @@ img.ProseMirror-separator {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.ProseMirror table .selectedCell {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ProseMirror table td > *,
|
||||
.ProseMirror table th > * {
|
||||
margin-bottom: 0;
|
||||
@@ -258,8 +254,9 @@ img.ProseMirror-separator {
|
||||
}
|
||||
|
||||
.ProseMirror table .selectedCell:after {
|
||||
background: var(--accent);
|
||||
content: "";
|
||||
background: var(--shade);
|
||||
box-shadow: inset 0px 0px 0px 1px var(--accent);
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
@@ -267,28 +264,88 @@ img.ProseMirror-separator {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.ProseMirror table .column-resize-handle {
|
||||
background-color: var(--accent);
|
||||
background-color: transparent;
|
||||
bottom: -2px;
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
pointer-events: none;
|
||||
right: -5px;
|
||||
top: 0;
|
||||
width: 5px;
|
||||
width: 10px;
|
||||
|
||||
cursor: ew-resize;
|
||||
cursor: col-resize;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.ProseMirror table .column-resize-handle.active::before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
top: calc(50% - 11px);
|
||||
left: -4px;
|
||||
|
||||
width: 16px;
|
||||
height: 18px;
|
||||
|
||||
background-color: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
|
||||
cursor: ew-resize;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.ProseMirror table .column-resize-handle.active::after {
|
||||
position: absolute;
|
||||
content: "";
|
||||
top: calc(50% - 10px);
|
||||
left: -4px;
|
||||
|
||||
background-size: 16px;
|
||||
background-color: var(--icon);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
mask: url(https://api.iconify.design/ic:round-drag-indicator.svg) no-repeat 50% 50%;
|
||||
mask-size: cover;
|
||||
|
||||
cursor: ew-resize;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.ProseMirror table p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.resize-cursor {
|
||||
cursor: ew-resize;
|
||||
cursor: col-resize;
|
||||
.ProseMirror td > h1:first-child,
|
||||
.ProseMirror td > h2:first-child,
|
||||
.ProseMirror td > h3:first-child,
|
||||
.ProseMirror td > h4:first-child,
|
||||
.ProseMirror td > h5:first-child,
|
||||
.ProseMirror td > h6:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.ProseMirror td > ol,
|
||||
.ProseMirror td > ul {
|
||||
padding-left: 20px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.ProseMirror td > blockquote {
|
||||
margin-left: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.ProseMirror td > blockquote > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
.resize-cursor {
|
||||
|
||||
} */
|
||||
|
||||
.drop-cursor {
|
||||
background-color: var(--paragraph, var(--nn_primary_paragraph)) !important;
|
||||
}
|
||||
@@ -707,6 +764,10 @@ p > *::selection {
|
||||
mask-size: cover;
|
||||
}
|
||||
|
||||
.simple-checklist > li.checked p {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Callout */
|
||||
.ProseMirror div.callout {
|
||||
padding: 15px;
|
||||
@@ -834,150 +895,133 @@ del.diffdel {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ProseMirror h1,
|
||||
.ProseMirror h2,
|
||||
.ProseMirror h3,
|
||||
.ProseMirror h4,
|
||||
.ProseMirror h5,
|
||||
.ProseMirror h1 ,
|
||||
.ProseMirror h2 ,
|
||||
.ProseMirror h3 ,
|
||||
.ProseMirror h4 ,
|
||||
.ProseMirror h5 ,
|
||||
.ProseMirror h6 {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ProseMirror h1::before,
|
||||
.ProseMirror h2::before,
|
||||
.ProseMirror h3::before,
|
||||
.ProseMirror h4::before,
|
||||
.ProseMirror h5::before,
|
||||
.ProseMirror h6::before {
|
||||
position: absolute;
|
||||
.ProseMirror h1 .heading-collapse-icon,
|
||||
.ProseMirror h2 .heading-collapse-icon,
|
||||
.ProseMirror h3 .heading-collapse-icon,
|
||||
.ProseMirror h4 .heading-collapse-icon,
|
||||
.ProseMirror h5 .heading-collapse-icon,
|
||||
.ProseMirror h6 .heading-collapse-icon {
|
||||
cursor: pointer;
|
||||
content: "";
|
||||
background-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-inline-start: 8px;
|
||||
|
||||
background-color: var(--icon);
|
||||
mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGZpbGw9IiM4ODg4ODgiIGQ9Ik03LjQxIDguNThMMTIgMTMuMTdsNC41OS00LjU5TDE4IDEwbC02IDZsLTYtNmwxLjQxLTEuNDJaIi8+PC9zdmc+)
|
||||
no-repeat 50% 50%;
|
||||
mask-size: cover;
|
||||
border: 1px solid var(--background);
|
||||
|
||||
transform: rotate(0);
|
||||
transition: transform 250ms ease, opacity 200ms ease;
|
||||
left: -22px;
|
||||
opacity: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ProseMirror h1[dir="rtl"]::before,
|
||||
.ProseMirror h2[dir="rtl"]::before,
|
||||
.ProseMirror h3[dir="rtl"]::before,
|
||||
.ProseMirror h4[dir="rtl"]::before,
|
||||
.ProseMirror h5[dir="rtl"]::before,
|
||||
.ProseMirror h6[dir="rtl"]::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ProseMirror h1[dir="rtl"]::after,
|
||||
.ProseMirror h2[dir="rtl"]::after,
|
||||
.ProseMirror h3[dir="rtl"]::after,
|
||||
.ProseMirror h4[dir="rtl"]::after,
|
||||
.ProseMirror h5[dir="rtl"]::after,
|
||||
.ProseMirror h6[dir="rtl"]::after {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
content: "";
|
||||
background-size: 18px;
|
||||
.ProseMirror h1 .heading-collapse-icon {
|
||||
margin-top: 3.5px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
|
||||
background-color: var(--icon);
|
||||
mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGZpbGw9IiM4ODg4ODgiIGQ9Ik03LjQxIDguNThMMTIgMTMuMTdsNC41OS00LjU5TDE4IDEwbC02IDZsLTYtNmwxLjQxLTEuNDJaIi8+PC9zdmc+)
|
||||
no-repeat 50% 50%;
|
||||
mask-size: cover;
|
||||
border: 1px solid var(--background);
|
||||
|
||||
transform: rotate(0deg);
|
||||
transition: transform 250ms ease, opacity 200ms ease;
|
||||
right: -22px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ProseMirror h1::before,
|
||||
.ProseMirror h1::after {
|
||||
top: 8px;
|
||||
.ProseMirror h2 .heading-collapse-icon {
|
||||
margin-top: 3px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.ProseMirror h2::before,
|
||||
.ProseMirror h2::after
|
||||
{
|
||||
top: 3px;
|
||||
.ProseMirror h3 .heading-collapse-icon {
|
||||
margin-top: 2.3px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.ProseMirror h3::before,
|
||||
.ProseMirror h3::after {
|
||||
top: 0px;
|
||||
.ProseMirror h4 .heading-collapse-icon {
|
||||
margin-top: 1.8px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.ProseMirror h4::before,
|
||||
.ProseMirror h4::after {
|
||||
top: -1px;
|
||||
.ProseMirror h5 .heading-collapse-icon {
|
||||
margin-top: 1.3px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.ProseMirror h5::before,
|
||||
.ProseMirror h5::after {
|
||||
top: -2px;
|
||||
.ProseMirror h6 .heading-collapse-icon {
|
||||
margin-top: 0.3px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.ProseMirror h6::before,
|
||||
.ProseMirror h6::after {
|
||||
top: -4px;
|
||||
}
|
||||
|
||||
.ProseMirror h1[data-collapsed="true"]::before,
|
||||
.ProseMirror h2[data-collapsed="true"]::before,
|
||||
.ProseMirror h3[data-collapsed="true"]::before,
|
||||
.ProseMirror h4[data-collapsed="true"]::before,
|
||||
.ProseMirror h5[data-collapsed="true"]::before,
|
||||
.ProseMirror h6[data-collapsed="true"]::before {
|
||||
.ProseMirror h1[data-collapsed="true"] .heading-collapse-icon,
|
||||
.ProseMirror h2[data-collapsed="true"] .heading-collapse-icon,
|
||||
.ProseMirror h3[data-collapsed="true"] .heading-collapse-icon,
|
||||
.ProseMirror h4[data-collapsed="true"] .heading-collapse-icon,
|
||||
.ProseMirror h5[data-collapsed="true"] .heading-collapse-icon,
|
||||
.ProseMirror h6[data-collapsed="true"] .heading-collapse-icon {
|
||||
transform: rotate(-90deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ProseMirror h1[data-collapsed="true"]::after,
|
||||
.ProseMirror h2[data-collapsed="true"]::after,
|
||||
.ProseMirror h3[data-collapsed="true"]::after,
|
||||
.ProseMirror h4[data-collapsed="true"]::after,
|
||||
.ProseMirror h5[data-collapsed="true"]::after,
|
||||
.ProseMirror h6[data-collapsed="true"]::after {
|
||||
.ProseMirror h1[data-collapsed="true"][dir="rtl"] .heading-collapse-icon,
|
||||
.ProseMirror h2[data-collapsed="true"][dir="rtl"] .heading-collapse-icon,
|
||||
.ProseMirror h3[data-collapsed="true"][dir="rtl"] .heading-collapse-icon,
|
||||
.ProseMirror h4[data-collapsed="true"][dir="rtl"] .heading-collapse-icon,
|
||||
.ProseMirror h5[data-collapsed="true"][dir="rtl"] .heading-collapse-icon,
|
||||
.ProseMirror h6[data-collapsed="true"][dir="rtl"] .heading-collapse-icon {
|
||||
transform: rotate(90deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
.ProseMirror h1:hover::before,
|
||||
.ProseMirror h2:hover::before,
|
||||
.ProseMirror h3:hover::before,
|
||||
.ProseMirror h4:hover::before,
|
||||
.ProseMirror h5:hover::before,
|
||||
.ProseMirror h6:hover::before,
|
||||
.ProseMirror h1:hover::after,
|
||||
.ProseMirror h2:hover::after,
|
||||
.ProseMirror h3:hover::after,
|
||||
.ProseMirror h4:hover::after,
|
||||
.ProseMirror h5:hover::after,
|
||||
.ProseMirror h6:hover::after {
|
||||
.ProseMirror h1:hover .heading-collapse-icon,
|
||||
.ProseMirror h2:hover .heading-collapse-icon,
|
||||
.ProseMirror h3:hover .heading-collapse-icon,
|
||||
.ProseMirror h4:hover .heading-collapse-icon,
|
||||
.ProseMirror h5:hover .heading-collapse-icon,
|
||||
.ProseMirror h6:hover .heading-collapse-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ProseMirror div.callout h1::before,
|
||||
.ProseMirror div.callout h2::before,
|
||||
.ProseMirror div.callout h3::before,
|
||||
.ProseMirror div.callout h4::before,
|
||||
.ProseMirror div.callout h5::before,
|
||||
.ProseMirror div.callout h6::before {
|
||||
.ProseMirror div.callout h1 .heading-collapse-icon,
|
||||
.ProseMirror div.callout h2 .heading-collapse-icon,
|
||||
.ProseMirror div.callout h3 .heading-collapse-icon,
|
||||
.ProseMirror div.callout h4 .heading-collapse-icon,
|
||||
.ProseMirror div.callout h5 .heading-collapse-icon,
|
||||
.ProseMirror div.callout h6 .heading-collapse-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* hide collapse icon when heading is empty (only contains trailing break) */
|
||||
.ProseMirror h1:has(> div > br.ProseMirror-trailingBreak:only-child) .heading-collapse-icon,
|
||||
.ProseMirror h2:has(> div > br.ProseMirror-trailingBreak:only-child) .heading-collapse-icon,
|
||||
.ProseMirror h3:has(> div > br.ProseMirror-trailingBreak:only-child) .heading-collapse-icon,
|
||||
.ProseMirror h4:has(> div > br.ProseMirror-trailingBreak:only-child) .heading-collapse-icon,
|
||||
.ProseMirror h5:has(> div > br.ProseMirror-trailingBreak:only-child) .heading-collapse-icon,
|
||||
.ProseMirror h6:has(> div > br.ProseMirror-trailingBreak:only-child) .heading-collapse-icon {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.ProseMirror h1 .heading-collapse-icon,
|
||||
.ProseMirror h2 .heading-collapse-icon,
|
||||
.ProseMirror h3 .heading-collapse-icon,
|
||||
.ProseMirror h4 .heading-collapse-icon,
|
||||
.ProseMirror h5 .heading-collapse-icon,
|
||||
.ProseMirror h6 .heading-collapse-icon {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
[data-hidden="true"] {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -996,3 +1040,4 @@ del.diffdel {
|
||||
pre[class*="language-"] {
|
||||
overflow: initial !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -1600,6 +1600,10 @@ msgstr "Click to remove"
|
||||
msgid "Click to reset {title}"
|
||||
msgstr "Click to reset {title}"
|
||||
|
||||
#: src/strings.ts:2610
|
||||
msgid "Click to update"
|
||||
msgstr "Click to update"
|
||||
|
||||
#: src/strings.ts:1380
|
||||
msgid "Close"
|
||||
msgstr "Close"
|
||||
@@ -4100,6 +4104,10 @@ msgstr "No notebooks selected to move"
|
||||
msgid "No one can view this {type} except you."
|
||||
msgstr "No one can view this {type} except you."
|
||||
|
||||
#: src/strings.ts:2611
|
||||
msgid "No password"
|
||||
msgstr "No password"
|
||||
|
||||
#: src/strings.ts:279
|
||||
msgid "No references found of this note"
|
||||
msgstr "No references found of this note"
|
||||
@@ -4870,6 +4878,10 @@ msgstr "Publish"
|
||||
msgid "Publish note"
|
||||
msgstr "Publish note"
|
||||
|
||||
#: src/strings.ts:2612
|
||||
msgid "Publish to the web"
|
||||
msgstr "Publish to the web"
|
||||
|
||||
#: src/strings.ts:488
|
||||
msgid "Publish your note to share it with others. You can set a password to protect it."
|
||||
msgstr "Publish your note to share it with others. You can set a password to protect it."
|
||||
@@ -7030,6 +7042,10 @@ msgstr "View source code"
|
||||
msgid "View your recovery codes to recover your account in case you lose access to your two-factor authentication methods."
|
||||
msgstr "View your recovery codes to recover your account in case you lose access to your two-factor authentication methods."
|
||||
|
||||
#: src/strings.ts:2609
|
||||
msgid "Views"
|
||||
msgstr "Views"
|
||||
|
||||
#: src/strings.ts:416
|
||||
msgid "Visit homepage"
|
||||
msgstr "Visit homepage"
|
||||
|
||||
@@ -1589,6 +1589,10 @@ msgstr ""
|
||||
msgid "Click to reset {title}"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2610
|
||||
msgid "Click to update"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:1380
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
@@ -4080,6 +4084,10 @@ msgstr ""
|
||||
msgid "No one can view this {type} except you."
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2611
|
||||
msgid "No password"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:279
|
||||
msgid "No references found of this note"
|
||||
msgstr ""
|
||||
@@ -4844,6 +4852,10 @@ msgstr ""
|
||||
msgid "Publish note"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2612
|
||||
msgid "Publish to the web"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:488
|
||||
msgid "Publish your note to share it with others. You can set a password to protect it."
|
||||
msgstr ""
|
||||
@@ -6981,6 +6993,10 @@ msgstr ""
|
||||
msgid "View your recovery codes to recover your account in case you lose access to your two-factor authentication methods."
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2609
|
||||
msgid "Views"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:416
|
||||
msgid "Visit homepage"
|
||||
msgstr ""
|
||||
|
||||
@@ -2605,5 +2605,9 @@ Use this if changes from other devices are not appearing on this device. This wi
|
||||
clickToDirectlyClaimPromo: () =>
|
||||
t`Click here to directly claim the promotion.`,
|
||||
loginToUploadAttachments: () =>
|
||||
t`Login to upload attachments. [Read more](https://help.notesnook.com/faqs/login-to-upload-attachments)`
|
||||
t`Login to upload attachments. [Read more](https://help.notesnook.com/faqs/login-to-upload-attachments)`,
|
||||
views: () => t`Views`,
|
||||
clickToUpdate: () => t`Click to update`,
|
||||
noPassword: () => t`No password`,
|
||||
publishToTheWeb: () => t`Publish to the web`
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user