Compare commits

...

52 Commits

Author SHA1 Message Date
Abdullah Atta
8f35d36a2a clipper(web): remove sqlite files 2025-11-25 14:19:35 +05:00
Abdullah Atta
873ba49e32 clipper(web): initial commit to make web cilpper work independently 2025-11-25 14:07:54 +05:00
Abdullah Atta
9f88eaae77 web: bump version to 3.3.6-beta.0 2025-11-24 14:21:04 +05:00
01zulfi
704ad578fa editor: unhide children when collapsed heading is changed to non-heading node (#8946)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2025-11-24 14:20:02 +05:00
01zulfi
cf608977e1 editor: improve table cell styling (#8960)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2025-11-24 14:18:13 +05:00
01zulfi
bdd78df452 editor: change heading collapse icon pos (#8953)
* editor: enable heading in table, change collapse icon pos, && disable empty heading collapse
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* editor: migrate empty collapsed headings in parseHTML instead of plugin
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* editor: remove migration for empty collapsed headings
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* editor: fix heading collapse on mobile
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* editor: use ontouchend instead of ontouchstart

---------

Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2025-11-24 10:49:43 +05:00
01zulfi
cec05b6dfc web: allow archiving when notes drag-n-dropped on nav item
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2025-11-24 10:46:21 +05:00
01zulfi
3e4779e6bf editor: fade checklist when checked
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

Update packages/editor/styles/styles.css

Signed-off-by: Abdullah Atta <thecodrr@protonmail.com>
2025-11-24 10:46:21 +05:00
01zulfi
9ebcbb7bd8 web: exclude empty paragraphs in editor stats
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2025-11-24 10:46:21 +05:00
01zulfi
f3f8020929 editor: disable collapsible headings inside tables
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2025-11-24 10:46:21 +05:00
01zulfi
43186d28e7 core: fix user keys re-encryption when updating password
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2025-11-24 10:46:21 +05:00
Abdullah Atta
d9d6a40276 Merge pull request #8930 from streetwriters/feat/improve-tables-on-mobile
Improve tables on mobile
2025-11-12 11:23:55 +05:00
Abdullah Atta
3d00502341 editor: fix table column/row tool sizing 2025-11-12 11:23:16 +05:00
Abdullah Atta
7975dfcc78 editor: fix recursive enter in tables on mobile 2025-11-12 11:23:16 +05:00
Abdullah Atta
907406e7b2 editor: add support for resize on hover 2025-11-12 11:23:16 +05:00
Abdullah Atta
8b03f96a3f editor: fix column resize handle hiding after column resize 2025-11-12 11:23:16 +05:00
Abdullah Atta
5183b33f9d editor: fix type errors 2025-11-12 11:23:16 +05:00
Abdullah Atta
bfddf288eb editor: massive improvements to tables on mobile
- resizable columns
- cell selection
2025-11-12 11:23:16 +05:00
Abdullah Atta
28ada2b032 mobile: release v3.3.9 2025-11-12 09:48:21 +05:00
Abdullah Atta
33c3bf0d69 Merge pull request #8919 from streetwriters/web/show-monograph-view-count
web: show monograph's view count
2025-11-08 13:07:14 +05:00
Abdullah Atta
2fc3cc3c76 web: remove border separation 2025-11-08 13:05:59 +05:00
Abdullah Atta
69a3fa4d90 web: lazily load monograph analytics 2025-11-08 12:59:29 +05:00
Abdullah Atta
bd4f130ef8 core: handle errors on monograph analytics fetch 2025-11-08 12:59:17 +05:00
Abdullah Atta
28b563b95b web: make monograph analytics a pro feature 2025-11-08 12:49:36 +05:00
Abdullah Atta
71952a354e core: monographs stats -> monograph analytics 2025-11-08 12:46:58 +05:00
Abdullah Atta
41ad8e0988 web: improve publish view ux 2025-11-07 19:26:32 +05:00
01zulfi
3e87e2ea5a web: use monograph stats api
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2025-11-07 10:54:15 +05:00
01zulfi
a6541b9731 web: show monograph's view count
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2025-11-06 14:10:52 +05:00
Abdullah Atta
cc584461ad Merge pull request #8909 from streetwriters/mobile-release-3.3.8 2025-11-05 23:31:34 +05:00
01zulfi
05ea085cdc Merge pull request #8915 from streetwriters/release/v3.3.5
Release/v3.3.5
2025-11-05 13:15:30 +05:00
01zulfi
015453a40b Merge pull request #8914 from streetwriters/release/v3.3.5
web: bump version to 3.3.5
2025-11-05 13:07:01 +05:00
01zulfi
dad92a186c web: bump version to 3.3.5
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2025-11-05 12:08:04 +05:00
Abdullah Atta
7e5e19e72c Merge pull request #8913 from streetwriters/editor/fix-checklist-bulletlist
editor: fix checklist parsing as bulletlist
2025-11-05 12:03:55 +05:00
01zulfi
8e1484a029 editor: fix checklist parsing as bulletlist
* reduce list-item's parseHTML priority lower than check-list-item's parseHTML priority

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2025-11-05 11:01:52 +05:00
01zulfi
b2645fc4d9 Merge pull request #8908 from streetwriters/release/v3.3.4
Release/v3.3.4
2025-11-03 17:23:50 +05:00
Abdullah Atta
58f060ae59 Merge pull request #8905 from streetwriters/editor/fix-images-in-lists 2025-11-03 16:14:00 +05:00
Abdullah Atta
d9ea44f92a Merge pull request #8857 from 01zulfi/core/escape-paranthesis-in-search 2025-11-03 12:38:33 +05:00
Abdullah Atta
1bf86386fa Merge pull request #8904 from streetwriters/editor/fix-heading-plugin 2025-11-03 10:29:23 +05:00
Abdullah Atta
6fc328c4dd clipper(web): bump version to 0.4.0 2025-11-01 19:44:29 +05:00
Ammar Ahmed
a60a1aa406 Merge branch 'master' into beta 2025-11-01 09:43:46 +05:00
Abdullah Atta
7bd1931195 Merge pull request #8892 from streetwriters/release/v3.3.3
Release/v3.3.3
2025-10-31 18:25:01 +05:00
01zulfi
c59091f946 editor: fix changing level of collapsed heading
* preserve collasped state
* reset visibility of hidden nodes under changed heading

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2025-10-31 14:06:54 +05:00
Abdullah Atta
6a017c237d Merge branch 'master' into beta 2025-10-30 13:31:01 +05:00
Abdullah Atta
27c641536a editor: add support for twitter embeds (#8879) 2025-10-30 12:02:25 +05:00
Abdullah Atta
8aa0a539f8 Merge pull request #7294 from 01zulfi/webclipper/bookmark 2025-10-27 15:19:50 +05:00
Abdullah Atta
61549e8e06 Merge pull request #8254 from 01zulfi/webclipper/start-clip-button 2025-10-27 13:49:21 +05:00
Abdullah Atta
317be2c8da Merge pull request #8195 from 01zulfi/clipper/improve-clip-with-styles 2025-10-27 13:41:53 +05:00
Ammar Ahmed
c47c410446 mobile: fix styles in editor 2025-10-24 09:02:19 +05:00
Ammar Ahmed
3a5558eb16 mobile: release v3.3.6 2025-10-24 09:02:19 +05:00
Ammar Ahmed
f8171ddc3f mobile: allow users to open redeem code url from app 2025-10-24 09:02:19 +05:00
Ammar Ahmed
55fb3d5332 Merge branch 'master' into beta 2025-10-23 13:48:46 +05:00
Abdullah Atta
3ebcd36ca9 Merge pull request #8803 from streetwriters/release/3.3.2 2025-10-20 16:07:17 +05:00
90 changed files with 10859 additions and 2831 deletions

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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'

View File

@@ -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)";

View File

@@ -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"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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;
}
}

View File

@@ -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(),

View File

@@ -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;

View File

@@ -506,6 +506,8 @@ function RouteItem({
? "trash"
: item.path === "/favorites"
? "favorites"
: item.path == "/archive"
? "archive"
: undefined
});
}}

View File

@@ -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",

View File

@@ -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
};
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -2,7 +2,6 @@
"extends": "../../tsconfig",
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"jsx": "react-jsx",
"moduleResolution": "Bundler",
"noEmit": true

View File

@@ -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
};

View 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 };

View File

@@ -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>>;

View File

@@ -10,5 +10,6 @@
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -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": {

View File

@@ -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 [];
}

View File

@@ -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) {

View File

@@ -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"

View 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 };

View File

@@ -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

View File

@@ -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>
</>
);

View File

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

View File

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

View File

@@ -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();

View File

@@ -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);

View 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;

View File

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

View File

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

View File

@@ -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>

View File

@@ -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"]
}

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

View File

@@ -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({

View File

@@ -0,0 +1,3 @@
- Minor bug fixes and improvements
Thank you for using Notesnook!

View File

@@ -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",

View File

@@ -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 };
}
}
}

View File

@@ -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;
}

View File

@@ -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"
}

View File

@@ -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",

View File

@@ -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>

View File

@@ -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> `;
}

View File

@@ -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>"`;

View File

@@ -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();
});
}

View File

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

View File

@@ -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",

View File

@@ -24,7 +24,7 @@ export const ListItem = TiptapListItem.extend({
parseHTML() {
return [
{
priority: 100,
priority: 50,
tag: `li`,
getContent: ensureLeadingParagraph
}

View File

@@ -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;

View File

@@ -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")
};
}

View File

@@ -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
];
}
});

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

View File

@@ -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;

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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;
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

View 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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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";
}
}

View 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;
}

View File

@@ -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;
}

View File

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

View File

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

View File

@@ -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;
}

View File

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

View File

@@ -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;
}

View File

@@ -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 };
}

View File

@@ -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]);
});
}

View File

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

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

View File

@@ -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 }
}

View File

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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,5 @@
import { CellSelection } from "../prosemirror-tables/cellselection.js";
export function isCellSelection(value: unknown): value is CellSelection {
return value instanceof CellSelection;
}

View File

@@ -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,

View File

@@ -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)}

View File

@@ -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;
}

View File

@@ -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"

View File

@@ -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 ""

View File

@@ -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`
};