Compare commits

..

96 Commits

Author SHA1 Message Date
akshat5302 57646a27e0 fix: install script container availability check 2024-10-04 16:34:32 +05:30
Akshita Goyal ec22f1fc53 fix: cycles build issue (#5750) 2024-10-04 14:11:26 +05:30
Akshita Goyal f1a0a8d925 Fix: Cycle graphs refactor (#5745)
* fix: community changes for cycle graphs

* fix: added dependency from root package.json
2024-10-03 19:25:53 +05:30
Mihir ee0dce46de [WEB-2520] fix-Sorted Icon Not Updating Dynamically in Spreadsheet View (#5688)
* Updated conditional rendering of sorting icons

* Removed unused imports
2024-10-03 17:31:08 +05:30
Ketan Sharma b7ee7e19fc [WEB-2213] fix: group by persistence for list view (#5590)
* fix kanban view localStorage

* add functionality for list view and add type for kanban function

* add comment in issue-filter-helper store

* improved code quality

* add comment for clarity

* use better variable names

* use useCallback hook and change variable name

* made suggested changes
2024-10-03 17:29:50 +05:30
rahulramesha 8291043704 Stop duplicate issue layout updates (#5743) 2024-10-03 16:51:18 +05:30
Akshat Jain bc41b1113a add /live path in proxy pass (#5742) 2024-10-03 15:09:13 +05:30
Dancia 77d4a8379d Updated SECURITY.md (#5737)
* Updated SECUTITY.md

* Updated SECUTITY.md

* minor fix
2024-10-03 14:09:01 +05:30
Prateek Shourya c90df623de fix: live base server url. (#5734)
* fix: live base server url.

* chore: update websocket URL logic.
2024-10-03 14:06:03 +05:30
Prateek Shourya 62c45f3bb1 [WEB-2559] fix: live server URL generation for self-managed instances. (#5733) 2024-10-01 21:03:17 +05:30
Prateek Shourya 96dc9db237 [WEB-2559] fix: web socket protocol. (#5731) 2024-10-01 19:57:17 +05:30
Akshita Goyal 5474ab326d fix: cycles import issues for ee (#5732) 2024-10-01 19:52:52 +05:30
Akshita Goyal 4940dc2193 Chore: progress chart changes (#5707)
* fix: progress chart code splitting

* fix: progress chart code splitting

* fix: build errors + review changes
2024-10-01 18:59:49 +05:30
Satish Gandham 632282d0df Fix build erorrs and unnecessary console.logs (#5730) 2024-10-01 15:31:04 +05:30
Satish Gandham 33f6c1fe9e [WEB-2001] feat: Fix local cache issues r4 (#5726)
* - Handle single quotes in load workspace queries
- Add IS null where condition in query utils

* Fix description_html being lost

* Change secondary order to sequence_id

* Fix update persistence layer

* Add instrumentation

* - Fallback to server incase of any error
2024-10-01 14:18:01 +05:30
Prateek Shourya 927d265209 [WEB-2573] improvement: search-issues API optimization. (#5727)
* limit search results to 100 issues.
2024-10-01 14:15:35 +05:30
M. Palanikannan bfef0e89e0 [PE-46] fix: added aspect ratio to resizing (#5693)
* fix: added aspect ratio to resizing

* fix: image loading

* fix: image uploading and adding only necessary keys to listen to

* fix: image aspect ratio maintainance done

* fix: loading of images with uploads

* fix: custom image extension loading fixed

* fix: refactored all the upload logic

* fix: focus detection for editor fixed

* fix: drop images and inserting images cleaned up

* fix: cursor focus after image node insertion and multi drop/paste range error fix

* fix: image types fixed

* fix: remove old images' upload code and cleaning up the code

* fix: imports

* fix: this reference in the plugin

* fix: added file validation

* fix: added error handling while reading files

* fix: prevent old data to be updated in updateAttributes

* fix: props types for node and image block

* fix: remove unnecessary dependency

* fix: seperated display message logic from ui

* chore: added comments to better explain the loading states

* fix: added getPos to deps

* fix: remove click event on failed to load state

* fix: css for error and selected state
2024-09-30 19:43:14 +05:30
Prateek Shourya e9d5db0093 [WEB-2568] chore: minor improvements for issue activity component. (#5725) 2024-09-30 19:23:24 +05:30
M. Palanikannan bcd46b6aa9 fix: missing editor package (#5708) 2024-09-30 17:58:11 +05:30
Prateek Shourya 66ca1663bf [WEB-2579] fix: frequent loader on issue detail / archived issue detail page. (#5724)
* [WEB-2579] fix: frequent loader on issue detail / archived issue detail page.

* chore: minor improvement.
2024-09-30 17:32:08 +05:30
Akshat Jain 944f3417a1 chore: added live dev script (#5715)
* add live dev script

* fix: redis changes in .env .example
2024-09-30 17:03:29 +05:30
Ketan Sharma 193d530b40 [WEB-2550] fix: spacing by removing the right border (#5699)
* fix spacing by removing the right border

* remove log statement

* replicate the same for space
2024-09-30 16:17:57 +05:30
Aaryan Khandelwal 3b0f3ca761 chore: show content loader untile the server has synced (#5657) 2024-09-30 15:57:19 +05:30
Mihir 7f5a898cec [WEB-2266] chore-No favorites should be aligned like the rest of the things (#5618)
* Updated alignment of empty favorite text

* Updated padding
2024-09-30 15:49:30 +05:30
Mihir bf6588b573 Updated notification text wrap (#5607) 2024-09-30 15:46:35 +05:30
Prateek Shourya c25fa594fe [WEB-2568] chore: minor improvements related to issue identifier and issue modal. (#5723)
* [WEB-2568] chore: minor improvements related to issue identifier and issue modal.

* fix: error handling for session recorder script.

* chore: minor improvement
2024-09-30 14:07:22 +05:30
Prateek Shourya b1dccf3773 chore: properties validation. (#5718) 2024-09-27 21:46:11 +05:30
Aaryan Khandelwal 04686d1721 fix: convert image size to string (#5717) 2024-09-27 20:39:50 +05:30
Satish Gandham ec08fb078d [WEB-2001] feat: Fix local cache issues r3 (#5714)
* - Handle single quotes in load workspace queries
- Add IS null where condition in query utils

* Fix description_html being lost

* Change secondary order to sequence_id

* Fix update persistence layer

* Fix issue types filter
Fix none filter

* add local cache toggle in help section

* remove toggle from user settings

* Reset storage class on disabling local

---------

Co-authored-by: rahulramesha <rahulramesham@gmail.com>
2024-09-27 15:11:38 +05:30
Satish Gandham 8aa32d410c [WEB-2001] feat: Fix local cache issues v2 (#5712)
* - Handle single quotes in load workspace queries
- Add IS null where condition in query utils

* Fix description_html being lost

* Change secondary order to sequence_id

* Fix update persistence layer
2024-09-27 13:19:38 +05:30
Aaryan Khandelwal ade03e9f8f chore: move headings list extension to the document editor (#5711) 2024-09-27 08:24:04 +05:30
Anmol Singh Bhatia d253933995 [WEB-2552] fix: issue list overflow and event propagation (#5706) 2024-09-26 16:55:01 +05:30
Anmol Singh Bhatia 150af986fd fix: list layout item (#5704) 2024-09-26 14:11:48 +05:30
Satish Gandham f3340749e8 [WEB-2001] fix: Issue local cache fixes (#5703)
* Fix sync of local updates

* Escape single quotes!!

* Fix last updated time query

* Move console.logs out

* Fix issue title not rendering line breaks when disabled

* Add a todo

* Fix build errors

* Disable local
2024-09-26 14:04:59 +05:30
rahulramesha 6e0ece496a fix peek overview loading state (#5698) 2024-09-26 13:29:34 +05:30
sriram veeraghanta 0068ea93de fix: rollup dependabot vulnerability fix 2024-09-25 19:35:26 +05:30
Prateek Shourya 6942e491d0 [WEB-2542] Fix: display filter and tooltip issues in list layout. (#5696)
* [WEB-2542] fix: list layout issues.
* fix: issue type display filter not working.
* fix: layout shift when hovered on bulkops checkbox.

* fix: build errors.

* fix: lint errors
2024-09-25 17:47:46 +05:30
Anmol Singh Bhatia 22623fad33 [WEB-2543] chore: workspace inbox guest permission (#5695)
* chore: workspace inbox permission updated

* chore: workspace inbox permission updated

* chore: code refactor

* chore: code refactor
2024-09-25 17:17:42 +05:30
Aaryan Khandelwal 85f7483b1b fix: update version history overlay z-index (#5694) 2024-09-25 14:11:21 +05:30
Anmol Singh Bhatia fbb60941ef fix: issue quick action (#5692) 2024-09-25 13:50:44 +05:30
M. Palanikannan 20e569294d [WEB-2528] fix: side menu rendering even if created already (#5687)
* fix: side menu rendering even if created already

* fix: drag handles position
2024-09-24 20:11:49 +05:30
rahulramesha 117afdb67f add requestIdleCallback polyfill to fix Safari crash (#5689) 2024-09-24 19:37:12 +05:30
Satish Gandham 3df230393a [WEB-2001]feat: Cache issues on the client (#5327)
* use common getIssues from issue service instead of multiple different services for modules and cycles

* Use SQLite to store issues locally and load issues from it.

* Fix incorrect total count and filtering on assignees.

* enable parallel API calls

* use common getIssues from issue service instead of multiple different services for modules and cycles

* Use SQLite to store issues locally and load issues from it.

* Fix incorrect total count and filtering on assignees.

* enable parallel API calls

* chore: deleted issue list

* - Handle local mutations
- Implement getting the updates
- Use SWR to update/sync data

* Wait for sync to complete in get issues

* Fix build errors

* Fix build issue

* - Sync updates to local-db
- Fallback to server when the local data is loading
- Wait when the updates are being fetched

* Add issues in batches

* Disable skeleton loaders for first 10 issues

* Load issues in bulk

* working version of sql lite with grouped issues

* Use window queries for group by

* - Fix sort by date fields
- Fix the total count

* - Fix grouping by created by
- Fix order by and limit

* fix pagination

* Fix sorting on issue priority

* - Add secondary sort order
- Fix group by priority

* chore: added timestamp filter for deleted issues

* - Extract local DB into its own class
- Implement sorting by label names

* Implement subgroup by

* sub group by changes

* Refactor query constructor

* Insert or update issues instead of directly adding them.

* Segregated queries. Not working though!!

* - Get filtered issues and then group them.
- Cleanup code.
- Implement order by labels.

* Fix build issues

* Remove debuggers

* remove loaders while changing sorting or applying filters

* fix loader while clearing all filters

* Fix issue with project being synced twice

* Improve project sync

* Optimize the queries

* Make create dummy data more realistic

* dev: added total pages in the global paginator

* chore: updated total_paged count

* chore: added state_group in the issues pagination

* chore: removed deleted_at from the issue pagination payload

* chore: replaced state_group with state__group

* Integrate new getIssues API, and fix sync issues bug.

* Fix issue with SWR running twice in workspace wrapper

* Fix DB initialization called when opening project for the first time.

* Add all the tables required for sorting

* Exclude description from getIssues

* Add getIssue function.

* Add only selected fields to get query.

* Fix the count query

* Minor query optimization when no joins are required.

* fetch issue description from local db

* clear local db on signout

* Correct dummy data creation

* Fix sort by assignee

* sync to local changes

* chore: added archived issues in the deleted endpoint

* Sync deletes to local db.

* - Add missing indexes for tables used in sorting in spreadsheet layout.
- Add options table

* Make fallback optional in getOption

* Kanban column virtualization

* persist project sync readiness to sqlite and use that as the source of truth for the project issues to be ready

* fix build errors

* Fix calendar view

* fetch slimed down version of modules in project wrapper

* fetch toned down modules and then fetch complete modules

* Fix multi value order by in spread sheet layout

* Fix sort by

* Fix the query when ordering by multi field names

* Remove unused import

* Fix sort by multi value fields

* Format queries and fix order by

* fix order by for multi issue

* fix loaders for spreadsheet

* Fallback to manual order whn moving away from spreadsheet layout

* fix minor bug

* Move fix for order_by when switching from spreadsheet layout to translateQueryParams

* fix default rendering of kanban groups

* Fix none priority being saved as null

* Remove debugger statement

* Fix issue load

* chore: updated isue paginated query from  to

* Fix sub issues and start and target date filters

* Fix active and backlog filter

* Add default order by

* Update the Query param to match with backend.

* local sqlite db versioning

* When window is hidden, do not perform any db versioning

* fix error handling and fall back to server when database errors out

* Add ability to disable local db cache

* remove db version check from getIssues function

* change db version to number and remove workspaceInitPromise in storage.sqlite

* - Sync the entire workspace in the background
- Add get sub issue method with distribution

* Make changes to get issues for sync to match backend.

* chore: handled workspace and project in v2 paginted issues

* disable issue description and title until fetched from server

* sync issues post bulk operations

* fix server error

* fix front end build

* Remove full workspace sync

* - Remove the toast message on sync.
- Update the disable local message.

* Add Hardcoded constant to disable the local db caching

* fix lint errors

* Fix order by in grouping

* update yarn lock

* fix build

* fix plane-web imports

* address review comments

---------

Co-authored-by: rahulramesha <rahulramesham@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: gurusainath <gurusainath007@gmail.com>
2024-09-24 19:01:34 +05:30
Aaryan Khandelwal 8dabe839f3 fix: pass update image size (#5686) 2024-09-24 16:09:12 +05:30
Anmol Singh Bhatia 6b63e050ae [WEB-2525] fix: activity filters (#5682)
* fix: activity filters

* chore: code refactor
2024-09-24 16:08:28 +05:30
rahulramesha 6170a80757 [WEB-2001] chore: Code refactor for noload changes. (#5683)
* use common getIssues from issue service instead of multiple different services for modules and cycles

* add group by to server constants

* change issue detail's overview's is loading logic to the loader from the store

* add extra method in local storage

* Kanban render 10 issues by default per column

* fix height in group virtualization

* remove debounced code for Kanban fetching more issues per column

* fix lint errors
2024-09-24 14:27:57 +05:30
Aaryan Khandelwal 5ca794b648 chore: remove line-through decoration from checked todo list items (#5659) 2024-09-24 13:56:36 +05:30
Prateek Shourya f38755b755 [WEB-2496] style: fix invite member input alignment on error state. (#5658) 2024-09-23 18:56:22 +05:30
Aaryan Khandelwal 2153eda9a8 fix: editor container height (#5669) 2024-09-23 18:49:53 +05:30
sriram veeraghanta 83bfca6f2d fix: linting issues and rule changes (#5681)
* fix: lint config package updates

* fix: tsconfig changes

* fix: lint config setup

* fix: lint errors and adding new rules

* fix: lint errors

* fix: ui and editor lints

* fix: build error

* fix: editor tsconfig

* fix: lint errors

* fix: types fixes

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2024-09-23 17:10:38 +05:30
Aaryan Khandelwal e143e0a051 chore: add server name while server initialization (#5656) 2024-09-23 16:44:50 +05:30
Mihir 50af7c5bf6 Updated the empty state button text for analytics (#5678) 2024-09-23 16:44:11 +05:30
Aaryan Khandelwal 846398df41 fix: casing across all settings pages (#5675) 2024-09-23 16:41:25 +05:30
Aaryan Khandelwal 0853a2790f style: updated create workspace item text color (#5674) 2024-09-23 16:41:04 +05:30
Mihir ed39f2dc37 [WEB-2390] fix: Clickable Area for Issue List Layout Item (#5536)
* Updated control block to cover the whole element

* Updated the control link to cover the whole issues and relation blocks

* updated word wrap in notifications

* Reverted break words as its a different issue.
2024-09-23 16:36:58 +05:30
Bavisetti Narayan 45fded9842 chore: issue relation hard delete (#5671) 2024-09-23 16:33:39 +05:30
Mihir 76a34440c3 Updated icons to mutate (#5670) 2024-09-23 16:26:47 +05:30
Ketan Sharma 4d200ff0a3 [WEB-2427] fix: white background behind emoji (#5624)
* adding translucent background

* make icon rounded
2024-09-23 16:24:51 +05:30
Ketan Sharma f49a2aa9e3 [WEB-2511] fix: fix overlapping issues for headers globally (#5667)
* fixed only for spreadsheet

* change package for global change

* made global and ad hoc changes

* fix border and z-index for intake and notifications header
2024-09-23 16:03:56 +05:30
Aaryan Khandelwal 83b83326c5 [WEB-2509] feat: fullscreen option for editor images (#5665)
* feat: editor image full screen mode

* fix: full screen modal visibility

* refactor: memoize calculations

* chore: update useEffect dependencies
2024-09-23 16:00:06 +05:30
Anmol Singh Bhatia 3c1779b287 fix: workspace setting validation (#5654) 2024-09-23 15:56:36 +05:30
Aaryan Khandelwal 22b32fd5c6 [WEB-2497] chore: update pages' offline badge tooltip content (#5652)
* chore: update offline badge tooltip content

* chore: revert yarn lock changes
2024-09-23 15:52:32 +05:30
rahulramesha c4c2d81d24 fix build (#5679) 2024-09-23 15:40:34 +05:30
Aaryan Khandelwal f9a8896486 [WEB-1116] chore: add fallback for the live server (#5622)
* chore: add fallback for the live server

* fix: update provider document after patch request

* chore: make the health check call only on connection fail

* chore: update debounce interval

* refactor: remove useSwr call for healtch check

* fix: pages fallback init
2024-09-23 15:35:06 +05:30
rahulramesha ae1a63f832 [WEB-2518] chore: Reverse order by of priority keys (#5591)
* make front end changes for priority orderby reversal

* chore: handled priority ordering in issues pagination

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
2024-09-23 14:58:05 +05:30
M. Palanikannan a05876552c [WEB-1116] fix: page outline not reflecting changes in realtime (#5567)
* fix: svg not supported in image uploads

* fix: svg image file error message fixed

* fix: heading not updating with realtime

* chore: add read-only editor support

* fix: headings show on initial render

* fix: types and imports

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2024-09-23 14:44:27 +05:30
rahulramesha b6e813cb9a fix animation performance on kanban group virtualization (#5666) 2024-09-23 12:48:44 +05:30
rahulramesha f328772b82 fix large dropdown properties truncation (#5672) 2024-09-22 01:42:16 +05:30
rahulramesha 604ddad3fa [WEB-2453] fix: Render on hover only when enabled (#5609) 2024-09-20 20:26:38 +05:30
rahulramesha 66cfc7344e change kanban group virtualization logic (#5664) 2024-09-20 14:39:28 +05:30
Aaryan Khandelwal a4933b5614 chore: remove modal for creating a page (#5561) 2024-09-19 20:26:11 +05:30
Ketan Sharma e70e27296b changes for web-2425 (#5616) 2024-09-19 20:15:10 +05:30
Prateek Shourya 361ef9236e [WEB-1970] fix: onboarding invitation page fluctuation on refresh. (#5627) 2024-09-19 17:51:22 +05:30
Ketan Sharma 450bb42c46 [WEB-2330] fix: you don't have permission toast on on bulk delete (#5599)
* fix logic check boolean then call function

* minor code improvement

* fixed logic error
2024-09-19 17:49:30 +05:30
Aaryan Khandelwal 77152b3119 style: remove side menu position transition (#5637) 2024-09-19 17:47:34 +05:30
Ketan Sharma e9464f9e68 [WEB-2475] fix: applied filters header z-index and transparency (#5632)
* fixed only for spreadsheet

* change package for global change
2024-09-19 17:36:52 +05:30
rahulramesha c8c9638e5a fix render-if-visible-hoc's style calculation performance issue (#5647) 2024-09-19 10:02:46 +05:30
Akshita Goyal bd0ca0cded fix: archive page break issue resolved (#5644) 2024-09-18 20:08:27 +05:30
Anmol Singh Bhatia 96781dbb0f fix: workspace view applied filters (#5651) 2024-09-18 20:07:01 +05:30
Bavisetti Narayan 19132d15b8 chore: pick first inbox issue (#5650) 2024-09-18 19:10:36 +05:30
sriram veeraghanta 6befc6e564 fix: upgrading nextjs package 2024-09-18 18:56:38 +05:30
Aaryan Khandelwal 441e5fc054 chore: update page lock authorization (#5635) 2024-09-18 18:21:05 +05:30
Aaryan Khandelwal 43633f2f28 fix: issue description value (#5636) 2024-09-18 18:20:43 +05:30
Anmol Singh Bhatia 3a9f01b9eb [WEB-2462] [WEB-2461] fix: project intake filters (#5645)
* chore: intake order by options updated

* fix: intake filters icon and spacing

* chore: code refactor
2024-09-18 18:10:30 +05:30
rahulramesha 5e83da9ca1 [WEB-2316] chore: Kanban group virtualization (#5565)
* kanban group virtualization

* minor name change
2024-09-18 18:03:49 +05:30
Akshita Goyal aec4162c22 fix: webhook modal spacing (#5641) 2024-09-18 15:35:46 +05:30
Anmol Singh Bhatia 44542fdd6b fix: list layout quick action styling (#5639) 2024-09-18 15:33:20 +05:30
Anmol Singh Bhatia 5ad6e99327 fix: project settings layout (#5638) 2024-09-18 15:01:35 +05:30
Bavisetti Narayan 30018d64a2 chore: restrict member to see private projects (#5640) 2024-09-18 14:54:35 +05:30
Prateek Shourya 1c0c1586cb [WEB-2308] fix: descritpion editor loader on issue modal when edition a sub issue from another project. (#5625) 2024-09-18 13:38:01 +05:30
Prateek Shourya 524033411e [WEB-2250] fix: filter projects with create permission while selecting the project in create issue modal. (#5630) 2024-09-18 13:32:24 +05:30
Prateek Shourya 3b40158d9a [WEB-2395] chore: minor UX copy update for what's new link. (#5626)
* [WEB-2395] chore: minor ux copy update for what's new link.

* fix: import errors.
2024-09-18 13:22:51 +05:30
Bavisetti Narayan 4d9115d51e chore: inbox rename (#5628) 2024-09-18 13:18:45 +05:30
M. Palanikannan 146a500f9f [WEB-2450] fix: image resize component (#5623)
* fix: image resize fixed for initial render

* fix: working image resize with mousemove handler only inside the editor

* fix: unnecessary calc

* fix: setting state to true
2024-09-17 16:54:42 +05:30
Anmol Singh Bhatia 7d7415b235 [WEB-2467] fix: platform bug (#5621)
* fix: reaction endpoint

* fix: project label edit permission

* fix: guest role upgrade

* fix: list layout dnd permission

* fix: module and cycle toast alert

* fix: leave project redirection
2024-09-17 16:43:51 +05:30
Akshita Goyal 7aea820cfa [WEB-2459] Fix: analytics scroll + dashboard stat minor padding (#5613)
* fix: analytics scroll + dashboard stat minor padding

* fix: build issue
2024-09-17 16:33:34 +05:30
379 changed files with 7437 additions and 5786 deletions
-59
View File
@@ -1,59 +0,0 @@
/**
* Adds three new lint plugins over the existing configuration:
* This is used to lint staged files only.
* We should remove this file once the entire codebase follows these rules.
*/
module.exports = {
root: true,
extends: [
"custom",
],
parser: "@typescript-eslint/parser",
settings: {
"import/resolver": {
typescript: {},
node: {
moduleDirectory: ["node_modules", "."],
},
},
},
rules: {
"import/order": [
"error",
{
groups: ["builtin", "external", "internal", "parent", "sibling"],
pathGroups: [
{
pattern: "react",
group: "external",
position: "before",
},
{
pattern: "lucide-react",
group: "external",
position: "after",
},
{
pattern: "@headlessui/**",
group: "external",
position: "after",
},
{
pattern: "@plane/**",
group: "external",
position: "after",
},
{
pattern: "@/**",
group: "internal",
},
],
pathGroupsExcludedImportTypes: ["builtin", "internal", "react"],
alphabetize: {
order: "asc",
caseInsensitive: true,
},
},
],
},
};
-10
View File
@@ -1,10 +0,0 @@
module.exports = {
root: true,
// This tells ESLint to load the config from the package `eslint-config-custom`
extends: ["custom"],
settings: {
next: {
rootDir: ["web/", "space/", "admin/"],
},
},
};
View File
-3
View File
@@ -1,3 +0,0 @@
{
"*.{ts,tsx,js,jsx}": ["eslint -c ./.eslintrc-staged.js", "prettier --check"]
}
+30 -35
View File
@@ -1,44 +1,39 @@
# Security Policy
# Security policy
This document outlines the security protocols and vulnerability reporting guidelines for the Plane project. Ensuring the security of our systems is a top priority, and while we work diligently to maintain robust protection, vulnerabilities may still occur. We highly value the communitys role in identifying and reporting security concerns to uphold the integrity of our systems and safeguard our users.
This document outlines security procedures and vulnerabilities reporting for the Plane project.
## Reporting a vulnerability
If you have identified a security vulnerability, submit your findings to [security@plane.so](mailto:security@plane.so).
Ensure your report includes all relevant information needed for us to reproduce and assess the issue. Include the IP address or URL of the affected system.
At Plane, we safeguarding the security of our systems with top priority. Despite our efforts, vulnerabilities may still exist. We greatly appreciate your assistance in identifying and reporting any such vulnerabilities to help us maintain the integrity of our systems and protect our clients.
To ensure a responsible and effective disclosure process, please adhere to the following:
To report a security vulnerability, please email us directly at security@plane.so with a detailed description of the vulnerability and steps to reproduce it. Please refrain from disclosing the vulnerability publicly until we have had an opportunity to review and address it.
- Maintain confidentiality and refrain from publicly disclosing the vulnerability until we have had the opportunity to investigate and address the issue.
- Refrain from running automated vulnerability scans on our infrastructure or dashboard without prior consent. Contact us to set up a sandbox environment if necessary.
- Do not exploit any discovered vulnerabilities for malicious purposes, such as accessing or altering user data.
- Do not engage in physical security attacks, social engineering, distributed denial of service (DDoS) attacks, spam campaigns, or attacks on third-party applications as part of your vulnerability testing.
## Out of Scope Vulnerabilities
## Out of scope
While we appreciate all efforts to assist in improving our security, please note that the following types of vulnerabilities are considered out of scope:
We appreciate your help in identifying vulnerabilities. However, please note that the following types of vulnerabilities are considered out of scope:
- Vulnerabilities requiring man-in-the-middle (MITM) attacks or physical access to a users device.
- Content spoofing or text injection issues without a clear attack vector or the ability to modify HTML/CSS.
- Issues related to email spoofing.
- Missing DNSSEC, CAA, or CSP headers.
- Absence of secure or HTTP-only flags on non-sensitive cookies.
- Attacks requiring MITM or physical access to a user's device.
- Content spoofing and text injection issues without demonstrating an attack vector or ability to modify HTML/CSS.
- Email spoofing.
- Missing DNSSEC, CAA, CSP headers.
- Lack of Secure or HTTP only flag on non-sensitive cookies.
## Our commitment
## Reporting Process
At Plane, we are committed to maintaining transparent and collaborative communication throughout the vulnerability resolution process. Here's what you can expect from us:
If you discover a vulnerability, please adhere to the following reporting process:
- **Response Time** <br/>
We will acknowledge receipt of your vulnerability report within three business days and provide an estimated timeline for resolution.
- **Legal Protection** <br/>
We will not initiate legal action against you for reporting vulnerabilities, provided you adhere to the reporting guidelines.
- **Confidentiality** <br/>
Your report will be treated with confidentiality. We will not disclose your personal information to third parties without your consent.
- **Recognition** <br/>
With your permission, we are happy to publicly acknowledge your contribution to improving our security once the issue is resolved.
- **Timely Resolution** <br/>
We are committed to working closely with you throughout the resolution process, providing timely updates as necessary. Our goal is to address all reported vulnerabilities swiftly, and we will actively engage with you to coordinate a responsible disclosure once the issue is fully resolved.
1. Email your findings to security@plane.so.
2. Refrain from running automated scanners on our infrastructure or dashboard without prior consent. Contact us to set up a sandbox environment if necessary.
3. Do not exploit the vulnerability for malicious purposes, such as downloading excessive data or altering user data.
4. Maintain confidentiality and refrain from disclosing the vulnerability until it has been resolved.
5. Avoid using physical security attacks, social engineering, distributed denial of service, spam, or third-party applications.
When reporting a vulnerability, please provide sufficient information to allow us to reproduce and address the issue promptly. Include the IP address or URL of the affected system, along with a detailed description of the vulnerability.
## Our Commitment
We are committed to promptly addressing reported vulnerabilities and maintaining open communication throughout the resolution process. Here's what you can expect from us:
- **Response Time:** We will acknowledge receipt of your report within three business days and provide an expected resolution date.
- **Legal Protection:** We will not pursue legal action against you for reporting vulnerabilities, provided you adhere to the reporting guidelines.
- **Confidentiality:** Your report will be treated with strict confidentiality. We will not disclose your personal information to third parties without your consent.
- **Progress Updates:** We will keep you informed of our progress in resolving the reported vulnerability.
- **Recognition:** With your permission, we will publicly acknowledge you as the discoverer of the vulnerability.
- **Timely Resolution:** We strive to resolve all reported vulnerabilities promptly and will actively participate in the publication process once the issue is resolved.
We appreciate your cooperation in helping us maintain the security of our systems and protecting our clients. Thank you for your contributions to our security efforts.
reference: https://supabase.com/.well-known/security.txt
We appreciate your help in ensuring the security of our platform. Your contributions are crucial to protecting our users and maintaining a secure environment. Thank you for working with us to keep Plane safe.
+4 -48
View File
@@ -1,52 +1,8 @@
module.exports = {
root: true,
extends: ["custom"],
extends: ["@plane/eslint-config/next.js"],
parser: "@typescript-eslint/parser",
settings: {
"import/resolver": {
typescript: {},
node: {
moduleDirectory: ["node_modules", "."],
},
},
parserOptions: {
project: true,
},
rules: {
"import/order": [
"error",
{
groups: ["builtin", "external", "internal", "parent", "sibling",],
pathGroups: [
{
pattern: "react",
group: "external",
position: "before",
},
{
pattern: "lucide-react",
group: "external",
position: "after",
},
{
pattern: "@headlessui/**",
group: "external",
position: "after",
},
{
pattern: "@plane/**",
group: "external",
position: "after",
},
{
pattern: "@/**",
group: "internal",
}
],
pathGroupsExcludedImportTypes: ["builtin", "internal", "react"],
alphabetize: {
order: "asc",
caseInsensitive: true,
},
},
],
},
}
};
+1 -3
View File
@@ -9,9 +9,7 @@ import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-si
// hooks
import { useTheme } from "@/hooks/store";
export interface IInstanceSidebar {}
export const InstanceSidebar: FC<IInstanceSidebar> = observer(() => {
export const InstanceSidebar: FC = observer(() => {
// store
const { isSidebarCollapsed, toggleSidebar } = useTheme();
@@ -7,11 +7,7 @@ import { Button } from "@plane/ui";
import InstanceFailureDarkImage from "@/public/instance/instance-failure-dark.svg";
import InstanceFailureImage from "@/public/instance/instance-failure.svg";
type InstanceFailureViewProps = {
// mutate: () => void;
};
export const InstanceFailureView: FC<InstanceFailureViewProps> = () => {
export const InstanceFailureView: FC = () => {
const { resolvedTheme } = useTheme();
const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage;
+1 -1
View File
@@ -1,5 +1,5 @@
// helpers
import { API_BASE_URL } from "helpers/common.helper";
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
+2 -2
View File
@@ -1,7 +1,7 @@
// helpers
import { API_BASE_URL } from "helpers/common.helper";
// types
import type { IUser } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
+7 -5
View File
@@ -8,7 +8,8 @@
"build": "next build",
"preview": "next build && next start",
"start": "next start",
"lint": "next lint"
"lint": "eslint . --ext .ts,.tsx",
"lint:errors": "eslint . --ext .ts,.tsx --quiet"
},
"dependencies": {
"@headlessui/react": "^1.7.19",
@@ -16,6 +17,7 @@
"@plane/helpers": "*",
"@plane/types": "*",
"@plane/ui": "*",
"@sentry/nextjs": "^8.32.0",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
@@ -25,7 +27,7 @@
"lucide-react": "^0.356.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "^14.2.3",
"next": "^14.2.12",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"react": "^18.3.1",
@@ -37,15 +39,15 @@
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@plane/eslint-config": "*",
"@plane/typescript-config": "*",
"@types/js-cookie": "^3.0.6",
"@types/node": "18.16.1",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.8",
"@types/zxcvbn": "^4.4.4",
"eslint-config-custom": "*",
"tailwind-config-custom": "*",
"tsconfig": "*",
"typescript": "5.4.5"
"typescript": "5.3.3"
}
}
+6 -12
View File
@@ -1,21 +1,15 @@
{
"extends": "tsconfig/nextjs.json",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"],
"extends": "@plane/typescript-config/nextjs.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"baseUrl": ".",
"jsx": "preserve",
"esModuleInterop": true,
"paths": {
"@/*": ["core/*"],
"@/helpers/*": ["helpers/*"],
"@/public/*": ["public/*"],
"@/plane-admin/*": ["ce/*"]
},
"plugins": [
{
"name": "next"
}
]
}
}
},
"include": ["next-env.d.ts", "next.config.js", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+8 -3
View File
@@ -301,11 +301,16 @@ class ProjectAPIEndpoint(BaseAPIView):
if serializer.is_valid():
serializer.save()
if serializer.data["inbox_view"]:
Inbox.objects.get_or_create(
name=f"{project.name} Inbox",
inbox = Inbox.objects.filter(
project=project,
is_default=True,
)
).first()
if not inbox:
Inbox.objects.create(
name=f"{project.name} Inbox",
project=project,
is_default=True,
)
# Create the triage state in Backlog group
State.objects.get_or_create(
@@ -44,7 +44,6 @@ from .cycle import (
CycleIssueSerializer,
CycleWriteSerializer,
CycleUserPropertiesSerializer,
CycleAnalyticsSerializer,
)
from .asset import FileAssetSerializer
from .issue import (
-8
View File
@@ -7,7 +7,6 @@ from .issue import IssueStateSerializer
from plane.db.models import (
Cycle,
CycleIssue,
CycleAnalytics,
CycleUserProperties,
)
@@ -94,7 +93,6 @@ class CycleIssueSerializer(BaseSerializer):
"cycle",
]
class CycleUserPropertiesSerializer(BaseSerializer):
class Meta:
model = CycleUserProperties
@@ -104,9 +102,3 @@ class CycleUserPropertiesSerializer(BaseSerializer):
"project",
"cycle" "user",
]
class CycleAnalyticsSerializer(BaseSerializer):
class Meta:
model = CycleAnalytics
fields = "__all__"
-6
View File
@@ -9,7 +9,6 @@ from plane.app.views import (
CycleProgressEndpoint,
CycleAnalyticsEndpoint,
TransferCycleIssueEndpoint,
CycleIssueStateAnalyticsEndpoint,
CycleUserPropertiesEndpoint,
CycleArchiveUnarchiveEndpoint,
)
@@ -119,9 +118,4 @@ urlpatterns = [
CycleAnalyticsEndpoint.as_view(),
name="project-cycle",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-progress/",
CycleIssueStateAnalyticsEndpoint.as_view(),
name="project-cycle-progress",
),
]
+8 -2
View File
@@ -20,6 +20,7 @@ from plane.app.views import (
IssueViewSet,
LabelViewSet,
BulkArchiveIssuesEndpoint,
DeletedIssuesListViewSet,
IssuePaginatedViewSet,
)
@@ -39,9 +40,9 @@ urlpatterns = [
),
name="project-issue",
),
# updated v1 paginated issues
# updated v2 paginated issues
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/v2/issues/",
"workspaces/<str:slug>/v2/issues/",
IssuePaginatedViewSet.as_view({"get": "list"}),
name="project-issues-paginated",
),
@@ -311,4 +312,9 @@ urlpatterns = [
),
name="project-issue-draft",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/deleted-issues/",
DeletedIssuesListViewSet.as_view(),
name="deleted-issues",
),
]
+4 -2
View File
@@ -100,9 +100,10 @@ from .cycle.base import (
TransferCycleIssueEndpoint,
CycleAnalyticsEndpoint,
CycleProgressEndpoint,
CycleIssueStateAnalyticsEndpoint,
)
from .cycle.issue import CycleIssueViewSet
from .cycle.issue import (
CycleIssueViewSet,
)
from .cycle.archive import (
CycleArchiveUnarchiveEndpoint,
)
@@ -113,6 +114,7 @@ from .issue.base import (
IssueViewSet,
IssueUserDisplayPropertyEndpoint,
BulkDeleteIssuesEndpoint,
DeletedIssuesListViewSet,
IssuePaginatedViewSet,
)
-51
View File
@@ -31,7 +31,6 @@ from plane.app.permissions import allow_permission, ROLE
from plane.app.serializers import (
CycleSerializer,
CycleUserPropertiesSerializer,
CycleAnalyticsSerializer,
CycleWriteSerializer,
)
from plane.bgtasks.issue_activities_task import issue_activity
@@ -45,8 +44,6 @@ from plane.db.models import (
User,
Project,
ProjectMember,
CycleAnalytics,
CycleIssueStateProgress,
)
from plane.utils.analytics_plot import burndown_plot
from plane.bgtasks.recent_visited_task import recent_visited_task
@@ -961,37 +958,6 @@ class TransferCycleIssueEndpoint(BaseAPIView):
updated_cycles, ["cycle_id"], batch_size=100
)
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
CycleIssueStateProgress.objects.bulk_create(
[
CycleIssueStateProgress(
cycle_id=new_cycle_id,
state_id=cycle_issue.issue.state_id,
issue_id=cycle_issue.issue_id,
state_group=cycle_issue.issue.state.group,
type="ADDED",
estimate_id=cycle_issue.issue.estimate_point_id,
estimate_value=(
cycle_issue.issue.estimate_point.value
if estimate_type
else None
),
project_id=project_id,
workspace_id=cycle_issue.workspace_id,
created_by_id=request.user.id,
updated_by_id=request.user.id,
)
for cycle_issue in cycle_issues
],
batch_size=10,
)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
@@ -1182,7 +1148,6 @@ class CycleProgressEndpoint(BaseAPIView):
status=status.HTTP_200_OK,
)
class CycleAnalyticsEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@@ -1402,19 +1367,3 @@ class CycleAnalyticsEndpoint(BaseAPIView):
},
status=status.HTTP_200_OK,
)
class CycleIssueStateAnalyticsEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, cycle_id):
cycle_state_progress = CycleAnalytics.objects.filter(
cycle_id=cycle_id,
project_id=project_id,
workspace__slug=slug,
)
return Response(
CycleAnalyticsSerializer(cycle_state_progress, many=True).data,
status=status.HTTP_200_OK,
)
-64
View File
@@ -24,8 +24,6 @@ from plane.db.models import (
Issue,
IssueAttachment,
IssueLink,
CycleIssueStateProgress,
Project,
)
from plane.utils.grouper import (
issue_group_values,
@@ -270,10 +268,6 @@ class CycleIssueViewSet(BaseViewSet):
]
new_issues = list(set(issues) - set(existing_issues))
# Fetch issue details
issue_objects = Issue.objects.filter(id__in=new_issues)
issue_dict = {issue.id: issue for issue in issue_objects}
# New issues to create
created_records = CycleIssue.objects.bulk_create(
[
@@ -290,42 +284,6 @@ class CycleIssueViewSet(BaseViewSet):
batch_size=10,
)
# estimate_type = Project.objects.filter(
# workspace__slug=slug,
# pk=project_id,
# estimate__isnull=False,
# estimate__type="points",
# ).exists()
# for issue_id in new_issues:
# print(issue_id, "issue id")
# print(issue_dict[issue_id].state_id, "state_id")
# CycleIssueStateProgress.objects.bulk_create(
# [
# CycleIssueStateProgress(
# cycle_id=cycle_id,
# state_id=str(issue_dict[issue_id].state_id),
# issue_id=issue_id,
# state_group=issue_dict[issue_id].state.group,
# type="ADDED",
# estimate_id=issue_dict[issue_id].estimate_id,
# estimate_value=(
# issue_dict[issue_id].estimate_point.value
# if estimate_type
# else None
# ),
# project_id=project_id,
# workspace_id=cycle.workspace_id,
# created_by_id=request.user.id,
# updated_by_id=request.user.id,
# )
# print(issue_id, "issue id")
# for issue_id in new_issues
# ],
# batch_size=10,
# )
# Updated Issues
updated_records = []
update_cycle_issue_activity = []
@@ -378,28 +336,6 @@ class CycleIssueViewSet(BaseViewSet):
project_id=project_id,
cycle_id=cycle_id,
)
issue = Issue.objects.get(pk=issue_id)
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
CycleIssueStateProgress.objects.create(
cycle_id=cycle_id,
state_id=issue.state_id,
issue_id=issue_id,
state_group=issue.state.group,
type="REMOVED",
estimate_id=issue.estimate_id,
estimate_value=(
issue.estimate_point.value if estimate_type else None
),
project_id=project_id,
created_by_id=request.user.id,
updated_by_id=request.user.id,
)
issue_activity.delay(
type="cycle.activity.deleted",
requested_data=json.dumps(
+5 -5
View File
@@ -167,10 +167,10 @@ class InboxIssueViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
inbox_id = Inbox.objects.get(
inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
)
project = Project.objects.get(pk=project_id)
).first()
project = Project.objects.get(pk=project_id)
filters = issue_filters(request.GET, "GET", "issue__")
inbox_issue = (
InboxIssue.objects.filter(
@@ -527,9 +527,9 @@ class InboxIssueViewSet(BaseViewSet):
model=Issue,
)
def retrieve(self, request, slug, project_id, pk):
inbox_id = Inbox.objects.get(
inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
)
).first()
project = Project.objects.get(pk=project_id)
inbox_issue = (
InboxIssue.objects.select_related("issue")
+50 -40
View File
@@ -42,7 +42,6 @@ from plane.db.models import (
IssueSubscriber,
Project,
ProjectMember,
CycleIssueStateProgress,
)
from plane.utils.grouper import (
issue_group_values,
@@ -235,11 +234,17 @@ class IssueViewSet(BaseViewSet):
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
extra_filters = {}
if request.GET.get("updated_at__gt", None) is not None:
extra_filters = {
"updated_at__gt": request.GET.get("updated_at__gt")
}
project = Project.objects.get(pk=project_id, workspace__slug=slug)
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = self.get_queryset().filter(**filters)
issue_queryset = self.get_queryset().filter(**filters, **extra_filters)
# Custom ordering for priority and state
# Issue queryset
@@ -545,8 +550,6 @@ class IssueViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
recent_visited_task.delay(
slug=slug,
entity_name="issue",
@@ -604,29 +607,6 @@ class IssueViewSet(BaseViewSet):
current_instance = json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
)
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
if issue.cycle_id:
CycleIssueStateProgress.objects.create(
cycle_id=issue.cycle_id,
state_id=issue.state_id,
issue_id=issue.id,
state_group=issue.state.group,
type="UPDATED",
estimate_id=issue.estimate_point_id,
estimate_value=(
issue.estimate_point.value if estimate_type else None
),
project_id=project_id,
workspace_id=issue.workspace_id,
created_by_id=request.user.id,
updated_by_id=request.user.id,
)
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
serializer = IssueCreateSerializer(
@@ -739,16 +719,43 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
)
class DeletedIssuesListViewSet(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id):
filters = {}
if request.GET.get("updated_at__gt", None) is not None:
filters = {"updated_at__gt": request.GET.get("updated_at__gt")}
deleted_issues = (
Issue.all_objects.filter(
workspace__slug=slug,
project_id=project_id,
)
.filter(Q(archived_at__isnull=False) | Q(deleted_at__isnull=False))
.filter(**filters)
.values_list("id", flat=True)
)
return Response(deleted_issues, status=status.HTTP_200_OK)
class IssuePaginatedViewSet(BaseViewSet):
def get_queryset(self):
workspace_slug = self.kwargs.get("slug")
project_id = self.kwargs.get("project_id")
# getting the project_id from the request params
project_id = self.request.GET.get("project_id", None)
issue_queryset = Issue.issue_objects.filter(
workspace__slug=workspace_slug
)
if project_id:
issue_queryset = issue_queryset.filter(project_id=project_id)
return (
Issue.issue_objects.filter(
workspace__slug=workspace_slug, project_id=project_id
issue_queryset.select_related(
"workspace", "project", "state", "parent"
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
@@ -786,17 +793,18 @@ class IssuePaginatedViewSet(BaseViewSet):
return paginated_data
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
def list(self, request, slug):
project_id = self.request.GET.get("project_id", None)
cursor = request.GET.get("cursor", None)
is_description_required = request.GET.get("description", False)
updated_at = request.GET.get("updated_at__gte", None)
updated_at = request.GET.get("updated_at__gt", None)
# required fields
required_fields = [
"id",
"name",
"state_id",
"state__group",
"sort_order",
"completed_at",
"estimate_point",
@@ -813,7 +821,6 @@ class IssuePaginatedViewSet(BaseViewSet):
"updated_by",
"is_draft",
"archived_at",
"deleted_at",
"module_ids",
"label_ids",
"assignee_ids",
@@ -826,15 +833,18 @@ class IssuePaginatedViewSet(BaseViewSet):
required_fields.append("description_html")
# querying issues
base_queryset = Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id
).order_by("updated_at")
base_queryset = Issue.issue_objects.filter(workspace__slug=slug)
if project_id:
base_queryset = base_queryset.filter(project_id=project_id)
base_queryset = base_queryset.order_by("updated_at")
queryset = self.get_queryset().order_by("updated_at")
# filtering issues by greater then updated_at given by the user
if updated_at:
base_queryset = base_queryset.filter(updated_at__gte=updated_at)
queryset = queryset.filter(updated_at__gte=updated_at)
base_queryset = base_queryset.filter(updated_at__gt=updated_at)
queryset = queryset.filter(updated_at__gt=updated_at)
queryset = queryset.annotate(
label_ids=Coalesce(
+2 -2
View File
@@ -37,7 +37,7 @@ class IssueReactionViewSet(BaseViewSet):
.distinct()
)
@allow_permission(ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def create(self, request, slug, project_id, issue_id):
serializer = IssueReactionSerializer(data=request.data)
if serializer.is_valid():
@@ -60,7 +60,7 @@ class IssueReactionViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def destroy(self, request, slug, project_id, issue_id, reaction_code):
issue_reaction = IssueReaction.objects.get(
workspace__slug=slug,
+1 -1
View File
@@ -267,7 +267,7 @@ class IssueRelationViewSet(BaseViewSet):
IssueRelationSerializer(issue_relation).data,
cls=DjangoJSONEncoder,
)
issue_relation.delete()
issue_relation.delete(soft=False)
issue_activity.delay(
type="issue_relation.activity.deleted",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
@@ -41,7 +41,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER],
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST],
level="WORKSPACE",
)
def list(self, request, slug):
@@ -207,7 +207,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
def mark_unread(self, request, slug, pk):
notification = Notification.objects.get(
@@ -219,7 +219,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
def archive(self, request, slug, pk):
notification = Notification.objects.get(
@@ -231,7 +231,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
def unarchive(self, request, slug, pk):
notification = Notification.objects.get(
@@ -286,7 +286,7 @@ class UnreadNotificationEndpoint(BaseAPIView):
class MarkAllReadNotificationViewSet(BaseViewSet):
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
def create(self, request, slug):
snoozed = request.data.get("snoozed", False)
+9 -4
View File
@@ -173,7 +173,7 @@ class ProjectViewSet(BaseViewSet):
member=request.user,
workspace__slug=slug,
is_active=True,
role=10,
role=15,
).exists():
projects = projects.filter(
Q(
@@ -438,11 +438,16 @@ class ProjectViewSet(BaseViewSet):
if serializer.is_valid():
serializer.save()
if serializer.data["inbox_view"]:
Inbox.objects.get_or_create(
name=f"{project.name} Inbox",
inbox = Inbox.objects.filter(
project=project,
is_default=True,
)
).first()
if not inbox:
Inbox.objects.create(
name=f"{project.name} Inbox",
project=project,
is_default=True,
)
# Create the triage state in Backlog group
State.objects.get_or_create(
@@ -414,6 +414,7 @@ class UserProjectRolesEndpoint(BaseAPIView):
project_members = ProjectMember.objects.filter(
workspace__slug=slug,
member_id=request.user.id,
is_active=True,
).values("project_id", "role")
project_members = {
+1 -1
View File
@@ -90,7 +90,7 @@ class GlobalSearchEndpoint(BaseAPIView):
"project__identifier",
"project_id",
"workspace__slug",
)
)[:100]
def filter_cycles(self, query, slug, project_id, workspace_search):
fields = ["name"]
+1 -1
View File
@@ -97,6 +97,6 @@ class IssueSearchEndpoint(BaseAPIView):
"state__name",
"state__group",
"state__color",
),
)[:100],
status=status.HTTP_200_OK,
)
@@ -1,120 +0,0 @@
# Django imports
from django.db.models import Sum
from django.utils import timezone
from django.db.models import F
from django.db.models.functions import RowNumber
from django.db.models import Max, Subquery, OuterRef
# Third party imports
from celery import shared_task
from plane.db.models import Cycle, CycleIssueStateProgress, CycleAnalytics
@shared_task
def track_cycle_issue_state_progress():
active_cycles = Cycle.objects.filter(
start_date__lte=timezone.now(), end_date__gte=timezone.now()
).values_list("id", "project_id", "workspace_id")
analytics_records = []
current_date = timezone.now().date()
for cycle_id, project_id, workspace_id in active_cycles:
# Subquery to get the latest id for each issue_id
# Subquery to get the latest created_at for each issue_id
# latest_created_at = CycleIssueStateProgress.objects.filter(
# cycle_id=cycle_id,
# type__in=["ADDED", "UPDATED"],
# issue_id=OuterRef("issue_id"),
# created_at__lte=timezone.now(),
# ).values('issue_id').annotate(
# latest_created=Max('created_at')
# ).values('latest_created')
# # Main query to get the latest unique issues
# cycle_issues = CycleIssueStateProgress.objects.filter(
# cycle_id=cycle_id,
# type__in=["ADDED", "UPDATED"],
# created_at=Subquery(latest_created_at),
# issue_id=OuterRef("issue_id")
# ).order_by("issue_id")
cycle_issues = CycleIssueStateProgress.objects.filter(
id=Subquery(
CycleIssueStateProgress.objects.filter(
cycle_id=cycle_id,
type__in=["ADDED", "UPDATED"],
issue=OuterRef("issue"),
)
.order_by("-created_at")
.values("id")[:1]
)
)
# print()
for issue in cycle_issues.values():
print(issue, "issues")
total_issues = cycle_issues.count()
total_estimate_points = (
cycle_issues.aggregate(
total_estimate_points=Sum("estimate_value")
)["total_estimate_points"]
or 0
)
state_groups = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
state_data = {
group: {
"count": cycle_issues.filter(state_group=group).count(),
"estimate_points": cycle_issues.filter(
state_group=group
).aggregate(total_estimate_points=Sum("estimate_value"))[
"total_estimate_points"
]
or 0,
}
for group in state_groups
}
# Prepare analytics record for bulk insert
analytics_records.append(
CycleAnalytics(
cycle_id=cycle_id,
date=current_date,
total_issues=total_issues,
total_estimate_points=total_estimate_points,
backlog_issues=state_data["backlog"]["count"],
unstarted_issues=state_data["unstarted"]["count"],
started_issues=state_data["started"]["count"],
completed_issues=state_data["completed"]["count"],
cancelled_issues=state_data["cancelled"]["count"],
backlog_estimate_points=state_data["backlog"][
"estimate_points"
],
unstarted_estimate_points=state_data["unstarted"][
"estimate_points"
],
started_estimate_points=state_data["started"][
"estimate_points"
],
completed_estimate_points=state_data["completed"][
"estimate_points"
],
cancelled_estimate_points=state_data["cancelled"][
"estimate_points"
],
project_id=project_id,
workspace_id=workspace_id,
)
)
# Bulk create the records at once
if analytics_records:
CycleAnalytics.objects.bulk_create(analytics_records)
+33 -20
View File
@@ -347,7 +347,7 @@ def create_issues(workspace, project, user_id, issue_count):
)
)
text = fake.text(max_nb_chars=60000)
text = fake.text(max_nb_chars=3000)
issues.append(
Issue(
state_id=states[random.randint(0, len(states) - 1)],
@@ -490,18 +490,23 @@ def create_issue_assignees(workspace, project, user_id, issue_count):
def create_issue_labels(workspace, project, user_id, issue_count):
# labels
labels = Label.objects.filter(project=project).values_list("id", flat=True)
issues = random.sample(
list(
# issues = random.sample(
# list(
# Issue.objects.filter(project=project).values_list("id", flat=True)
# ),
# int(issue_count / 2),
# )
issues = list(
Issue.objects.filter(project=project).values_list("id", flat=True)
),
int(issue_count / 2),
)
)
shuffled_labels = list(labels)
# Bulk issue
bulk_issue_labels = []
for issue in issues:
random.shuffle(shuffled_labels)
for label in random.sample(
list(labels), random.randint(0, len(labels) - 1)
shuffled_labels, random.randint(0, 5)
):
bulk_issue_labels.append(
IssueLabel(
@@ -552,25 +557,33 @@ def create_module_issues(workspace, project, user_id, issue_count):
modules = Module.objects.filter(project=project).values_list(
"id", flat=True
)
issues = random.sample(
list(
# issues = random.sample(
# list(
# Issue.objects.filter(project=project).values_list("id", flat=True)
# ),
# int(issue_count / 2),
# )
issues = list(
Issue.objects.filter(project=project).values_list("id", flat=True)
),
int(issue_count / 2),
)
)
shuffled_modules = list(modules)
# Bulk issue
bulk_module_issues = []
for issue in issues:
module = modules[random.randint(0, len(modules) - 1)]
bulk_module_issues.append(
ModuleIssue(
module_id=module,
issue_id=issue,
project=project,
workspace=workspace,
random.shuffle(shuffled_modules)
for module in random.sample(
shuffled_modules, random.randint(0, 5)
):
bulk_module_issues.append(
ModuleIssue(
module_id=module,
issue_id=issue,
project=project,
workspace=workspace,
)
)
)
# Issue assignees
ModuleIssue.objects.bulk_create(
bulk_module_issues, batch_size=1000, ignore_conflicts=True
-4
View File
@@ -40,10 +40,6 @@ app.conf.beat_schedule = {
"task": "plane.bgtasks.deletion_task.hard_delete",
"schedule": crontab(hour=0, minute=0),
},
"track-cycle-issue-state-progress": {
"task": "plane.bgtasks.cycle_issue_state_progress_task.track_cycle_issue_state_progress",
"schedule": crontab(hour=9, minute=6),
},
}
# Load task modules from all registered Django app configs.
@@ -73,7 +73,7 @@ class Command(BaseCommand):
from plane.bgtasks.dummy_data_task import create_dummy_data
create_dummy_data.delay(
create_dummy_data(
slug=workspace_slug,
email=creator,
members=members,
File diff suppressed because one or more lines are too long
+1 -10
View File
@@ -2,16 +2,7 @@ from .analytic import AnalyticView
from .api import APIActivityLog, APIToken
from .asset import FileAsset
from .base import BaseModel
from .cycle import (
Cycle,
CycleFavorite,
CycleIssue,
CycleUserProperties,
CycleAnalytics,
CycleUpdates,
CycleUpdateReaction,
CycleIssueStateProgress,
)
from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties
from .dashboard import Dashboard, DashboardWidget, Widget
from .deploy_board import DeployBoard
from .estimate import Estimate, EstimatePoint
+2 -149
View File
@@ -1,6 +1,3 @@
# Python Imports
import pytz
# Django imports
from django.conf import settings
from django.db import models
@@ -58,12 +55,10 @@ class Cycle(ProjectBaseModel):
description = models.TextField(
verbose_name="Cycle Description", blank=True
)
start_date = models.DateTimeField(
start_date = models.DateField(
verbose_name="Start Date", blank=True, null=True
)
end_date = models.DateTimeField(
verbose_name="End Date", blank=True, null=True
)
end_date = models.DateField(verbose_name="End Date", blank=True, null=True)
owned_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
@@ -76,11 +71,6 @@ class Cycle(ProjectBaseModel):
progress_snapshot = models.JSONField(default=dict)
archived_at = models.DateTimeField(null=True)
logo_props = models.JSONField(default=dict)
# timezone
USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
user_timezone = models.CharField(
max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES
)
class Meta:
verbose_name = "Cycle"
@@ -186,140 +176,3 @@ class CycleUserProperties(ProjectBaseModel):
def __str__(self):
return f"{self.cycle.name} {self.user.email}"
class TypeEnum(models.TextChoices):
ADDED = "ADDED", "Added"
UPDATED = "UPDATED", "Updated"
REMOVED = "REMOVED", "Removed"
TRANSFER = "TRANSFER", "Transfer"
class CycleIssueStateProgress(ProjectBaseModel):
cycle = models.ForeignKey(
"db.Cycle",
on_delete=models.DO_NOTHING,
related_name="cycle_issue_state_progress",
)
state = models.ForeignKey(
"db.State",
on_delete=models.DO_NOTHING,
related_name="cycle_issue_state_progress",
)
issue = models.ForeignKey(
"db.Issue",
on_delete=models.DO_NOTHING,
related_name="cycle_issue_state_progress",
)
state_group = models.CharField(max_length=255)
type = models.CharField(
max_length=30,
choices=TypeEnum.choices,
)
estimate_id = models.UUIDField(null=True)
estimate_value = models.FloatField(null=True)
class Meta:
verbose_name = "Cycle Issue State Progress"
verbose_name_plural = "Cycle Issue State Progress"
db_table = "cycle_issue_state_progress"
ordering = ("-created_at",)
def __str__(self):
return f"{self.cycle.name} {self.issue.name}"
class CycleAnalytics(ProjectBaseModel):
cycle = models.ForeignKey(
"db.Cycle", on_delete=models.CASCADE, related_name="cycle_analytics"
)
date = models.DateField()
data = models.JSONField(default=dict)
total_issues = models.FloatField(default=0)
total_estimate_points = models.FloatField(default=0)
# state group wise distribution
backlog_issues = models.FloatField(default=0)
unstarted_issues = models.FloatField(default=0)
started_issues = models.FloatField(default=0)
completed_issues = models.FloatField(default=0)
cancelled_issues = models.FloatField(default=0)
backlog_estimate_points = models.FloatField(default=0)
unstarted_estimate_points = models.FloatField(default=0)
started_estimate_points = models.FloatField(default=0)
completed_estimate_points = models.FloatField(default=0)
cancelled_estimate_points = models.FloatField(default=0)
class Meta:
unique_together = ["cycle", "date"]
verbose_name = "Cycle Analytics"
verbose_name_plural = "Cycle Analytics"
db_table = "cycle_analytics"
ordering = ("-created_at",)
def __str__(self):
return f"{self.user.email} <{self.cycle.name}>"
class UpdatesEnum(models.TextChoices):
ONTRACK = "ONTRACK", "On Track"
OFFTRACK = "OFFTRACK", "Off Track"
AT_RISK = "AT_RISK", "At Risk"
STARTED = "STARTED", "Started"
SCOPE_INCREASED = "SCOPE_INCREASED", "Scope Increased"
SCOPE_DECREASED = "SCOPE_DECREASED", "Scope Decreased"
class CycleUpdates(ProjectBaseModel):
cycle = models.ForeignKey(
"db.Cycle", on_delete=models.CASCADE, related_name="cycle_updates"
)
description = models.TextField(blank=True)
status = models.CharField(
max_length=30,
choices=UpdatesEnum.choices,
)
completed_issues = models.FloatField(default=0)
total_issues = models.FloatField(default=0)
total_estimate_points = models.FloatField(default=0)
completed_estimate_points = models.FloatField(default=0)
class Meta:
verbose_name = "Cycle Updates"
verbose_name_plural = "Cycle Updates"
db_table = "cycle_updates"
ordering = ("-created_at",)
def __str__(self):
return f"{self.cycle.name}"
class CycleUpdateReaction(ProjectBaseModel):
cycle = models.ForeignKey(
"db.Cycle",
on_delete=models.CASCADE,
related_name="cycle_update_reactions",
)
update = models.ForeignKey(
"db.CycleUpdates",
on_delete=models.CASCADE,
related_name="cycle_update_reactions",
)
reaction = models.CharField(max_length=20)
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="cycle_update_reactions",
)
class Meta:
verbose_name = "Cycle Update Reaction"
verbose_name_plural = "Cycle Update Reactions"
db_table = "cycle_update_reactions"
ordering = ("-created_at",)
def __str__(self):
return f"{self.actor.email} <{self.cycle.name}>"
-6
View File
@@ -1,5 +1,4 @@
# Python imports
import pytz
from uuid import uuid4
# Django imports
@@ -120,11 +119,6 @@ class Project(BaseModel):
related_name="default_state",
)
archived_at = models.DateTimeField(null=True)
# timezone
USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
user_timezone = models.CharField(
max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES
)
def __str__(self):
"""Return name of the project"""
-1
View File
@@ -279,7 +279,6 @@ CELERY_IMPORTS = (
"plane.bgtasks.file_asset_task",
"plane.bgtasks.email_notification_task",
"plane.bgtasks.api_logs_task",
"plane.bgtasks.cycle_issue_state_progress_task",
# management tasks
"plane.bgtasks.dummy_data_task",
)
@@ -1,3 +1,6 @@
# python imports
from math import ceil
# constants
PAGINATOR_MAX_LIMIT = 1000
@@ -36,6 +39,9 @@ def paginate(base_queryset, queryset, cursor, on_result):
total_results = base_queryset.count()
page_size = min(cursor_object.current_page_size, PAGINATOR_MAX_LIMIT)
# getting the total pages available based on the page size
total_pages = ceil(total_results / page_size)
# Calculate the start and end index for the paginated data
start_index = 0
if cursor_object.current_page > 0:
@@ -72,6 +78,7 @@ def paginate(base_queryset, queryset, cursor, on_result):
"next_page_results": next_page_results,
"page_count": len(paginated_data),
"total_results": total_results,
"total_pages": total_pages,
"results": paginated_data,
}
+2 -2
View File
@@ -30,9 +30,9 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"):
)
).order_by("priority_order")
order_by_param = (
"-priority_order"
"priority_order"
if order_by_param.startswith("-")
else "priority_order"
else "-priority_order"
)
# State Ordering
elif order_by_param in [
+5 -5
View File
@@ -82,7 +82,7 @@ class CursorResult(Sequence):
return f"<{type(self).__name__}: results={len(self.results)}>"
MAX_LIMIT = 100
MAX_LIMIT = 1000
class BadPaginationError(Exception):
@@ -118,7 +118,7 @@ class OffsetPaginator:
self.max_offset = max_offset
self.on_results = on_results
def get_result(self, limit=100, cursor=None):
def get_result(self, limit=1000, cursor=None):
# offset is page #
# value is page limit
if cursor is None:
@@ -727,7 +727,7 @@ class BasePaginator:
cursor_name = "cursor"
# get the per page parameter from request
def get_per_page(self, request, default_per_page=100, max_per_page=100):
def get_per_page(self, request, default_per_page=1000, max_per_page=1000):
try:
per_page = int(request.GET.get("per_page", default_per_page))
except ValueError:
@@ -747,8 +747,8 @@ class BasePaginator:
on_results=None,
paginator=None,
paginator_cls=OffsetPaginator,
default_per_page=100,
max_per_page=100,
default_per_page=1000,
max_per_page=1000,
cursor_cls=Cursor,
extra_stats=None,
controller=None,
+2 -2
View File
@@ -280,7 +280,7 @@ function download() {
function startServices() {
/bin/bash -c "$COMPOSE_CMD -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH up -d --pull if_not_present --quiet-pull"
local migrator_container_id=$(docker container ls -aq -f "name=$SERVICE_FOLDER-migrator")
local migrator_container_id=$(docker ps --format "{{.ID}} {{.Names}}" | grep -E "${SERVICE_FOLDER}(-migrator|_migrator)" | awk '{print $1}')
if [ -n "$migrator_container_id" ]; then
local idx=0
while docker inspect --format='{{.State.Status}}' $migrator_container_id | grep -q "running"; do
@@ -308,7 +308,7 @@ function startServices() {
fi
fi
local api_container_id=$(docker container ls -q -f "name=$SERVICE_FOLDER-api")
local api_container_id=$(docker ps --format "{{.ID}} {{.Names}}" | grep -E "${SERVICE_FOLDER}(-api|_api)" | awk '{print $1}')
local idx2=0
while ! docker logs $api_container_id 2>&1 | grep -m 1 -i "Application startup complete" | grep -q ".";
do
+3 -2
View File
@@ -1,7 +1,8 @@
API_BASE_URL="http://api:8000"
LIVE_BASE_PATH="/live"
REDIS_URL="redis://localhost:6379"
REDIS_URL="redis://plane-redis:6379/"
# If you prefer not to provide a Redis URL, you can set the REDIS_HOST and REDIS_PORT environment variables instead.
REDIS_PORT=6379
REDIS_HOST=localhost
REDIS_HOST=plane-redis
+4
View File
@@ -0,0 +1,4 @@
.turbo/*
out/*
dist/*
public/*
+8
View File
@@ -0,0 +1,8 @@
{
"root": true,
"extends": ["@plane/eslint-config/server.js"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": true
}
}
+8 -3
View File
@@ -7,7 +7,10 @@
"type": "module",
"scripts": {
"build": "babel src --out-dir dist --extensions \".ts,.js\"",
"start": "node dist/server.js"
"start": "node dist/server.js",
"lint": "eslint . --ext .ts,.tsx",
"dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
"lint:errors": "eslint . --ext .ts,.tsx --quiet"
},
"keywords": [],
"author": "",
@@ -35,6 +38,7 @@
"morgan": "^1.10.0",
"pino-http": "^10.3.0",
"pino-pretty": "^11.2.2",
"uuid": "^10.0.0",
"y-prosemirror": "^1.2.9",
"y-protocols": "^1.0.6",
"yjs": "^13.6.14"
@@ -51,9 +55,10 @@
"@types/express-ws": "^3.0.4",
"@types/node": "^20.14.9",
"babel-plugin-module-resolver": "^5.0.2",
"nodemon": "^3.1.0",
"concurrently": "^9.0.1",
"nodemon": "^3.1.7",
"ts-node": "^10.9.2",
"tsup": "^7.2.0",
"typescript": "5.4.5"
"typescript": "5.3.3"
}
}
+1 -2
View File
@@ -1,2 +1 @@
// eslint-disable-next-line @typescript-eslint/ban-types
export type TAdditionalDocumentTypes = {}
export type TAdditionalDocumentTypes = {};
+8 -5
View File
@@ -45,7 +45,8 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
const documentType = params.get("documentType")?.toString() as
| TDocumentTypes
| undefined;
// TODO: Fix this lint error.
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve) => {
try {
let fetchedData = null;
@@ -53,7 +54,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
fetchedData = await fetchPageDescriptionBinary(
params,
pageId,
cookie,
cookie
);
} else {
fetchedData = await fetchDocument({
@@ -83,6 +84,8 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
| TDocumentTypes
| undefined;
// TODO: Fix this lint error.
// eslint-disable-next-line no-async-promise-executor
return new Promise(async () => {
try {
if (documentType === "project_page") {
@@ -121,7 +124,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
}
manualLogger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error,
error
);
reject(error);
});
@@ -135,12 +138,12 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
} catch (error) {
manualLogger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error,
error
);
}
} else {
manualLogger.warn(
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)",
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)"
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { ErrorRequestHandler } from "express";
import { manualLogger } from "@/core/helpers/logger.js";
export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
export const errorHandler: ErrorRequestHandler = (err, _req, res) => {
// Log the error
manualLogger.error(err);
+6 -1
View File
@@ -1,11 +1,15 @@
import { Server } from "@hocuspocus/server";
import { v4 as uuidv4 } from "uuid";
// lib
import { handleAuthentication } from "@/core/lib/authentication.js";
// extensions
import { getExtensions } from "@/core/extensions/index.js";
export const getHocusPocusServer = async () => {
const extensions = await getExtensions();
const serverName = process.env.HOSTNAME || uuidv4();
return Server.configure({
name: serverName,
onAuthenticate: async ({
requestHeaders,
requestParameters,
@@ -34,5 +38,6 @@ export const getHocusPocusServer = async () => {
}
},
extensions,
debounce: 10000
});
};
+7 -24
View File
@@ -1,43 +1,26 @@
{
"extends": "tsconfig/base.json",
"extends": "@plane/typescript-config/base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": [
"ES2015"
],
"lib": ["ES2015"],
"outDir": "./dist",
"rootDir": "./src",
"rootDir": ".",
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
],
"@/plane-live/*": [
"./src/ce/*"
]
"@/*": ["./src/*"],
"@/plane-live/*": ["./src/ce/*"]
},
"removeComments": true,
"esModuleInterop": true,
"skipLibCheck": true,
"sourceMap": true,
"inlineSources": true,
// Set `sourceRoot` to "/" to strip the build path prefix
// from generated source code references.
// This improves issue grouping in Sentry.
"sourceRoot": "/"
},
"include": [
"src/**/*.ts"
],
"exclude": [
"./dist",
"./build",
"./node_modules"
]
"include": ["src/**/*.ts", "tsup.config.ts"],
"exclude": ["./dist", "./build", "./node_modules"]
}
+1 -1
View File
@@ -57,7 +57,7 @@ http {
proxy_set_header Upgrade ${dollar}http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host ${dollar}http_host;
proxy_pass http://live:3000/;
proxy_pass http://live:3000/live/;
}
location /spaces/ {
+3 -17
View File
@@ -8,34 +8,20 @@
"space",
"admin",
"live",
"packages/editor",
"packages/eslint-config-custom",
"packages/tailwind-config-custom",
"packages/tsconfig",
"packages/ui",
"packages/types",
"packages/constants",
"packages/helpers"
"packages/*"
],
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --concurrency=13",
"start": "turbo run start",
"lint": "turbo run lint",
"lint:errors": "turbo run lint:errors",
"clean": "turbo run clean",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"prepare": "husky"
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"devDependencies": {
"autoprefixer": "^10.4.15",
"eslint-config-custom": "*",
"eslint-plugin-prettier": "^5.1.3",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
"postcss": "^8.4.29",
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"tailwindcss": "^3.3.3",
"turbo": "^2.1.1"
},
"resolutions": {
+13
View File
@@ -13,6 +13,19 @@ export enum EIssueGroupByToServerOptions {
"created_by" = "created_by",
}
export enum EIssueGroupBYServerToProperty {
"state_id" = "state_id",
"priority" = "priority",
"labels__id" = "label_ids",
"state__group" = "state__group",
"assignees__id" = "assignee_ids",
"cycle_id" = "cycle_id",
"issue_module__module_id" = "module_ids",
"target_date" = "target_date",
"project_id" = "project_id",
"created_by" = "created_by",
}
export enum EServerGroupByToFilterOptions {
"state_id" = "state",
"priority" = "priority",
+5 -34
View File
@@ -1,38 +1,9 @@
module.exports = {
root: true,
extends: ["custom"],
rules: {
"import/order": [
"error",
{
groups: ["builtin", "external", "internal", "parent", "sibling"],
pathGroups: [
{
pattern: "react",
group: "external",
position: "before",
},
{
pattern: "lucide-react",
group: "external",
position: "after",
},
{
pattern: "@plane/**",
group: "external",
position: "after",
},
{
pattern: "@/**",
group: "internal",
},
],
pathGroupsExcludedImportTypes: ["builtin", "internal", "react"],
alphabetize: {
order: "asc",
caseInsensitive: true,
},
},
],
extends: ["@plane/eslint-config/library.js"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
},
rules: {},
};
+5 -3
View File
@@ -26,6 +26,7 @@
"build": "tsup --minify",
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"peerDependencies": {
@@ -65,21 +66,22 @@
"tailwind-merge": "^1.14.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.9",
"uuid": "^10.0.0",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.2.5",
"y-protocols": "^1.0.6",
"yjs": "^13.6.15"
},
"devDependencies": {
"@plane/eslint-config": "*",
"@plane/typescript-config": "*",
"@types/node": "18.15.3",
"@types/react": "^18.2.42",
"@types/react-dom": "^18.2.17",
"eslint-config-custom": "*",
"postcss": "^8.4.38",
"tailwind-config-custom": "*",
"tsconfig": "*",
"tsup": "^7.2.0",
"typescript": "5.4.5"
"typescript": "5.3.3"
},
"keywords": [
"editor",
@@ -1,6 +1,6 @@
import React from "react";
// components
import { PageRenderer } from "@/components/editors";
import { DocumentContentLoader, PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// extensions
@@ -42,7 +42,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
}
// use document editor
const { editor } = useCollaborativeEditor({
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
disabledExtensions,
editorClassName,
embedHandler,
@@ -67,6 +67,8 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
if (!editor) return null;
if (!hasServerSynced && !hasServerConnectionFailed) return <DocumentContentLoader />;
return (
<PageRenderer
displayConfig={displayConfig}
@@ -1,6 +1,6 @@
import { forwardRef, MutableRefObject } from "react";
// components
import { PageRenderer } from "@/components/editors";
import { DocumentContentLoader, PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// extensions
@@ -35,7 +35,7 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn
);
}
const { editor } = useReadOnlyCollaborativeEditor({
const { editor, hasServerConnectionFailed, hasServerSynced } = useReadOnlyCollaborativeEditor({
editorClassName,
extensions,
forwardedRef,
@@ -52,6 +52,9 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn
});
if (!editor) return null;
if (!hasServerSynced && !hasServerConnectionFailed) return <DocumentContentLoader />;
return (
<PageRenderer
displayConfig={displayConfig}
@@ -1,4 +1,5 @@
export * from "./collaborative-editor";
export * from "./collaborative-read-only-editor";
export * from "./loader";
export * from "./page-renderer";
export * from "./read-only-editor";
@@ -0,0 +1,42 @@
// ui
import { Loader } from "@plane/ui";
export const DocumentContentLoader = () => (
<div className="size-full px-5">
<Loader className="relative space-y-4">
<div className="space-y-2">
<div className="py-2">
<Loader.Item width="100%" height="36px" />
</div>
<Loader.Item width="80%" height="22px" />
<div className="relative flex items-center gap-2">
<Loader.Item width="30px" height="30px" />
<Loader.Item width="30%" height="22px" />
</div>
<div className="py-2">
<Loader.Item width="60%" height="36px" />
</div>
<Loader.Item width="70%" height="22px" />
<Loader.Item width="30%" height="22px" />
<div className="relative flex items-center gap-2">
<Loader.Item width="30px" height="30px" />
<Loader.Item width="30%" height="22px" />
</div>
<div className="py-2">
<Loader.Item width="50%" height="30px" />
</div>
<Loader.Item width="100%" height="22px" />
<div className="py-2">
<Loader.Item width="30%" height="30px" />
</div>
<Loader.Item width="30%" height="22px" />
<div className="relative flex items-center gap-2">
<div className="py-2">
<Loader.Item width="30px" height="30px" />
</div>
<Loader.Item width="30%" height="22px" />
</div>
</div>
</Loader>
</div>
);
@@ -18,7 +18,8 @@ interface EditorContainerProps {
export const EditorContainer: FC<EditorContainerProps> = (props) => {
const { children, displayConfig, editor, editorContainerClassName, id } = props;
const handleContainerClick = () => {
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (event.target !== event.currentTarget) return;
if (!editor) return;
if (!editor.isEditable) return;
try {
@@ -23,7 +23,6 @@ export const AIFeaturesMenu: React.FC<Props> = (props) => {
menuRef.current.remove();
menuRef.current.style.visibility = "visible";
// @ts-expect-error - tippy types are incorrect
popup.current = tippy(document.body, {
getReferenceClientRect: null,
content: menuRef.current,
@@ -34,7 +34,6 @@ export const BlockMenu = (props: BlockMenuProps) => {
menuRef.current.remove();
menuRef.current.style.visibility = "visible";
// @ts-expect-error - tippy types are incorrect
popup.current = tippy(document.body, {
getReferenceClientRect: null,
content: menuRef.current,
@@ -23,6 +23,7 @@ import {
} from "lucide-react";
// helpers
import {
insertImage,
insertTableCommand,
setText,
toggleBlockquote,
@@ -192,9 +193,8 @@ export const ImageItem = (editor: Editor) =>
({
key: "image",
name: "Image",
isActive: () => editor?.isActive("image"),
command: (savedSelection: Selection | null) =>
editor?.commands.setImageUpload({ event: "insert", pos: savedSelection?.from }),
isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"),
command: (savedSelection: Selection | null) => insertImage({ editor, event: "insert", pos: savedSelection?.from }),
icon: ImageIcon,
}) as const;
@@ -117,14 +117,18 @@ export function LowlightPlugin({
// Such transactions can happen during collab syncing via y-prosemirror, for example.
transaction.steps.some(
(step) =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
step.from !== undefined &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
step.to !== undefined &&
oldNodes.some(
(node) =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
node.pos >= step.from &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
node.pos + node.node.nodeSize <= step.to
)
@@ -1,73 +1,175 @@
import React, { useRef, useState, useCallback, useLayoutEffect } from "react";
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
import { NodeSelection } from "@tiptap/pm/state";
// extensions
import { CustomImageNodeViewProps } from "@/extensions/custom-image";
import { CustomImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
// helpers
import { cn } from "@/helpers/common";
const MIN_SIZE = 100;
export const CustomImageBlock: React.FC<CustomImageNodeViewProps> = (props) => {
const { node, updateAttributes, selected, getPos, editor } = props;
const { src, width, height } = node.attrs;
type Pixel = `${number}px`;
const [size, setSize] = useState({ width: width || "35%", height: height || "auto" });
const [isLoading, setIsLoading] = useState(true);
type PixelAttribute<TDefault> = Pixel | TDefault;
export type ImageAttributes = {
src: string | null;
width: PixelAttribute<"35%" | number>;
height: PixelAttribute<"auto" | number>;
aspectRatio: number | null;
id: string | null;
};
type Size = {
width: PixelAttribute<"35%">;
height: PixelAttribute<"auto">;
aspectRatio: number | null;
};
const ensurePixelString = <TDefault,>(value: Pixel | TDefault | number | undefined | null, defaultValue?: TDefault) => {
if (!value || value === defaultValue) {
return defaultValue;
}
if (typeof value === "number") {
return `${value}px` satisfies Pixel;
}
return value;
};
type CustomImageBlockProps = CustomImageNodeViewProps & {
imageFromFileSystem: string;
setFailedToLoadImage: (isError: boolean) => void;
editorContainer: HTMLDivElement | null;
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
};
export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
// props
const {
node,
updateAttributes,
setFailedToLoadImage,
imageFromFileSystem,
selected,
getPos,
editor,
editorContainer,
setEditorContainer,
} = props;
const { src: remoteImageSrc, width, height, aspectRatio } = node.attrs;
// states
const [size, setSize] = useState<Size>({
width: ensurePixelString(width, "35%"),
height: ensurePixelString(height, "auto"),
aspectRatio: aspectRatio || 1,
});
const [isResizing, setIsResizing] = useState(false);
const [initialResizeComplete, setInitialResizeComplete] = useState(false);
// refs
const containerRef = useRef<HTMLDivElement>(null);
const containerRect = useRef<DOMRect | null>(null);
const imageRef = useRef<HTMLImageElement>(null);
const isResizing = useRef(false);
const aspectRatio = useRef(1);
useLayoutEffect(() => {
if (imageRef.current) {
const img = imageRef.current;
img.onload = () => {
if (node.attrs.width === "35%" && node.attrs.height === "auto") {
aspectRatio.current = img.naturalWidth / img.naturalHeight;
const initialWidth = Math.max(img.naturalWidth * 0.35, MIN_SIZE);
const initialHeight = initialWidth / aspectRatio.current;
setSize({ width: `${initialWidth}px`, height: `${initialHeight}px` });
}
setIsLoading(false);
};
const handleImageLoad = useCallback(() => {
const img = imageRef.current;
if (!img) return;
let closestEditorContainer: HTMLDivElement | null = null;
if (editorContainer) {
closestEditorContainer = editorContainer;
} else {
closestEditorContainer = img.closest(".editor-container") as HTMLDivElement | null;
if (!closestEditorContainer) {
console.error("Editor container not found");
return;
}
}
}, [src]);
if (!closestEditorContainer) {
console.error("Editor container not found");
return;
}
setEditorContainer(closestEditorContainer);
const aspectRatio = img.naturalWidth / img.naturalHeight;
if (width === "35%") {
const editorWidth = closestEditorContainer.clientWidth;
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
const initialHeight = initialWidth / aspectRatio;
const initialComputedSize = {
width: `${Math.round(initialWidth)}px` satisfies Pixel,
height: `${Math.round(initialHeight)}px` satisfies Pixel,
aspectRatio: aspectRatio,
};
setSize(initialComputedSize);
updateAttributes(initialComputedSize);
} else {
// as the aspect ratio in not stored for old images, we need to update the attrs
setSize((prevSize) => {
const newSize = { ...prevSize, aspectRatio };
updateAttributes(newSize);
return newSize;
});
}
setInitialResizeComplete(true);
}, [width, updateAttributes, editorContainer]);
// for real time resizing
useLayoutEffect(() => {
setSize((prevSize) => ({
...prevSize,
width: ensurePixelString(width),
height: ensurePixelString(height),
}));
}, [width, height]);
const handleResize = useCallback(
(e: MouseEvent | TouchEvent) => {
if (!containerRef.current || !containerRect.current || !size.aspectRatio) return;
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
const newWidth = Math.max(clientX - containerRect.current.left, MIN_SIZE);
const newHeight = newWidth / size.aspectRatio;
setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` }));
},
[size]
);
const handleResizeEnd = useCallback(() => {
setIsResizing(false);
updateAttributes(size);
}, [size, updateAttributes]);
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
e.stopPropagation();
isResizing.current = true;
setIsResizing(true);
if (containerRef.current) {
containerRect.current = containerRef.current.getBoundingClientRect();
}
}, []);
useLayoutEffect(() => {
// for realtime resizing and undo/redo
setSize({ width, height });
}, [width, height]);
useEffect(() => {
if (isResizing) {
window.addEventListener("mousemove", handleResize);
window.addEventListener("mouseup", handleResizeEnd);
window.addEventListener("mouseleave", handleResizeEnd);
const handleResize = useCallback((e: MouseEvent | TouchEvent) => {
if (!isResizing.current || !containerRef.current || !containerRect.current) return;
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
const newWidth = Math.max(clientX - containerRect.current.left, MIN_SIZE);
const newHeight = newWidth / aspectRatio.current;
setSize({ width: `${newWidth}px`, height: `${newHeight}px` });
}, []);
const handleResizeEnd = useCallback(() => {
if (isResizing.current) {
isResizing.current = false;
updateAttributes(size);
return () => {
window.removeEventListener("mousemove", handleResize);
window.removeEventListener("mouseup", handleResizeEnd);
window.removeEventListener("mouseleave", handleResizeEnd);
};
}
}, [size, updateAttributes]);
}, [isResizing, handleResize, handleResizeEnd]);
const handleMouseDown = useCallback(
const handleImageMouseDown = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
const pos = getPos();
@@ -77,48 +179,86 @@ export const CustomImageBlock: React.FC<CustomImageNodeViewProps> = (props) => {
[editor, getPos]
);
useLayoutEffect(() => {
const handleGlobalMouseMove = (e: MouseEvent) => handleResize(e);
const handleGlobalMouseUp = () => handleResizeEnd();
document.addEventListener("mousemove", handleGlobalMouseMove);
document.addEventListener("mouseup", handleGlobalMouseUp);
return () => {
document.removeEventListener("mousemove", handleGlobalMouseMove);
document.removeEventListener("mouseup", handleGlobalMouseUp);
};
}, [handleResize, handleResizeEnd]);
// show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or)
// if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete
const showImageLoader = !(remoteImageSrc || imageFromFileSystem) || !initialResizeComplete;
// show the image utils only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
const showImageUtils = editor.isEditable && remoteImageSrc && initialResizeComplete;
// show the preview image from the file system if the remote image's src is not set
const displayedImageSrc = remoteImageSrc ?? imageFromFileSystem;
return (
<div
ref={containerRef}
className="group/image-component relative inline-block max-w-full"
onMouseDown={handleMouseDown}
onMouseDown={handleImageMouseDown}
style={{
width: size.width,
height: size.height,
aspectRatio: size.aspectRatio,
}}
>
{isLoading && <div className="animate-pulse bg-custom-background-80 rounded-md" style={{ width, height }} />}
{showImageLoader && (
<div
className="animate-pulse bg-custom-background-80 rounded-md"
style={{ width: size.width, height: size.height }}
/>
)}
<img
ref={imageRef}
src={src}
className={cn("block rounded-md", {
hidden: isLoading,
src={displayedImageSrc}
onLoad={handleImageLoad}
onError={(e) => {
console.error("Error loading image", e);
setFailedToLoadImage(true);
}}
width={size.width}
className={cn("image-component block rounded-md", {
// hide the image while the background calculations of the image loader are in progress (to avoid flickering) and show the loader until then
hidden: showImageLoader,
"read-only-image": !editor.isEditable,
"blur-sm opacity-80 loading-image": !remoteImageSrc,
})}
style={{
width: size.width,
height: size.height,
aspectRatio: size.aspectRatio,
}}
/>
{editor.isEditable && selected && <div className="absolute inset-0 size-full bg-custom-primary-500/30" />}
{editor.isEditable && (
{showImageUtils && (
<ImageToolbarRoot
containerClassName={
"absolute top-1 right-1 z-20 bg-black/40 rounded opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity"
}
image={{
src: remoteImageSrc,
aspectRatio: size.aspectRatio,
height: size.height,
width: size.width,
}}
/>
)}
{selected && displayedImageSrc === remoteImageSrc && (
<div className="absolute inset-0 size-full bg-custom-primary-500/30" />
)}
{showImageUtils && (
<>
<div className="opacity-0 group-hover/image-component:opacity-100 absolute inset-0 border-2 border-custom-primary-100 pointer-events-none rounded-md transition-opacity duration-100 ease-in-out" />
<div
className="opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto absolute bottom-0 right-0 translate-y-1/2 translate-x-1/2 size-4 rounded-full bg-custom-primary-100 border-2 border-white cursor-nwse-resize transition-opacity duration-100 ease-in-out"
className={cn(
"absolute inset-0 border-2 border-custom-primary-100 pointer-events-none rounded-md transition-opacity duration-100 ease-in-out",
{
"opacity-100": isResizing,
"opacity-0 group-hover/image-component:opacity-100": !isResizing,
}
)}
/>
<div
className={cn(
"absolute bottom-0 right-0 translate-y-1/2 translate-x-1/2 size-4 rounded-full bg-custom-primary-100 border-2 border-white cursor-nwse-resize transition-opacity duration-100 ease-in-out",
{
"opacity-100 pointer-events-auto": isResizing,
"opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto":
!isResizing,
}
)}
onMouseDown={handleResizeStart}
/>
</>
@@ -1,23 +1,14 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
import { Editor, NodeViewWrapper } from "@tiptap/react";
// extensions
import {
CustomImageBlock,
CustomImageUploader,
UploadEntity,
UploadImageExtensionStorage,
} from "@/extensions/custom-image";
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
export type CustomImageNodeViewProps = {
getPos: () => number;
editor: Editor;
node: ProsemirrorNode & {
attrs: {
src: string;
width: string;
height: string;
};
attrs: ImageAttributes;
};
updateAttributes: (attrs: Record<string, any>) => void;
selected: boolean;
@@ -26,94 +17,60 @@ export type CustomImageNodeViewProps = {
export const CustomImageNode = (props: CustomImageNodeViewProps) => {
const { getPos, editor, node, updateAttributes, selected } = props;
const fileInputRef = useRef<HTMLInputElement>(null);
const hasTriggeredFilePickerRef = useRef(false);
const [isUploaded, setIsUploaded] = useState(!!node.attrs.src);
const [isUploaded, setIsUploaded] = useState(false);
const [imageFromFileSystem, setImageFromFileSystem] = useState<string | undefined>(undefined);
const [failedToLoadImage, setFailedToLoadImage] = useState(false);
const id = node.attrs.id as string;
const editorStorage = editor.storage.imageComponent as UploadImageExtensionStorage | undefined;
const getUploadEntity = useCallback(
(): UploadEntity | undefined => editorStorage?.fileMap.get(id),
[editorStorage, id]
);
const onUpload = useCallback(
(url: string) => {
if (url) {
setIsUploaded(true);
// Update the node view's src attribute
updateAttributes({ src: url });
editorStorage?.fileMap.delete(id);
}
},
[editorStorage?.fileMap, id, updateAttributes]
);
const uploadFile = useCallback(
async (file: File) => {
try {
// @ts-expect-error - TODO: fix typings, and don't remove await from
// here for now
const url: string = await editor?.commands.uploadImage(file);
if (!url) {
throw new Error("Something went wrong while uploading the image");
}
onUpload(url);
} catch (error) {
console.error("Error uploading file:", error);
}
},
[editor.commands, onUpload]
);
const [editorContainer, setEditorContainer] = useState<HTMLDivElement | null>(null);
const imageComponentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const uploadEntity = getUploadEntity();
if (uploadEntity) {
if (uploadEntity.event === "drop" && "file" in uploadEntity) {
uploadFile(uploadEntity.file);
} else if (uploadEntity.event === "insert" && fileInputRef.current && !hasTriggeredFilePickerRef.current) {
const entity = editorStorage?.fileMap.get(id);
if (entity && entity.hasOpenedFileInputOnce) return;
fileInputRef.current.click();
hasTriggeredFilePickerRef.current = true;
if (!entity) return;
editorStorage?.fileMap.set(id, { ...entity, hasOpenedFileInputOnce: true });
}
const closestEditorContainer = imageComponentRef.current?.closest(".editor-container");
if (!closestEditorContainer) {
console.error("Editor container not found");
return;
}
}, [getUploadEntity, uploadFile]);
setEditorContainer(closestEditorContainer as HTMLDivElement);
}, []);
// the image is already uploaded if the image-component node has src attribute
// and we need to remove the blob from our file system
useEffect(() => {
if (node.attrs.src) {
const remoteImageSrc = node.attrs.src;
if (remoteImageSrc) {
setIsUploaded(true);
setImageFromFileSystem(undefined);
} else {
setIsUploaded(false);
}
}, [node.attrs.src]);
const existingFile = React.useMemo(() => {
const entity = getUploadEntity();
return entity && entity.event === "drop" ? entity.file : undefined;
}, [getUploadEntity]);
return (
<NodeViewWrapper>
<div className="p-0 mx-0 my-2" data-drag-handle>
{isUploaded ? (
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
{(isUploaded || imageFromFileSystem) && !failedToLoadImage ? (
<CustomImageBlock
imageFromFileSystem={imageFromFileSystem}
editorContainer={editorContainer}
editor={editor}
getPos={getPos}
node={node}
updateAttributes={updateAttributes}
setEditorContainer={setEditorContainer}
setFailedToLoadImage={setFailedToLoadImage}
selected={selected}
updateAttributes={updateAttributes}
/>
) : (
<CustomImageUploader
onUpload={onUpload}
editor={editor}
fileInputRef={fileInputRef}
existingFile={existingFile}
failedToLoadImage={failedToLoadImage}
getPos={getPos}
loadImageFromFileSystem={setImageFromFileSystem}
node={node}
setIsUploaded={setIsUploaded}
selected={selected}
updateAttributes={updateAttributes}
/>
)}
</div>
@@ -1,36 +1,111 @@
import { ChangeEvent, useCallback, useEffect, useRef } from "react";
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
import { Editor } from "@tiptap/core";
import { ImageIcon } from "lucide-react";
// helpers
import { cn } from "@/helpers/common";
// hooks
import { useUploader, useFileUpload, useDropZone } from "@/hooks/use-file-upload";
import { useUploader, useDropZone } from "@/hooks/use-file-upload";
// plugins
import { isFileValid } from "@/plugins/image";
type RefType = React.RefObject<HTMLInputElement> | ((instance: HTMLInputElement | null) => void);
const assignRef = (ref: RefType, value: HTMLInputElement | null) => {
if (typeof ref === "function") {
ref(value);
} else if (ref && typeof ref === "object") {
(ref as React.MutableRefObject<HTMLInputElement | null>).current = value;
}
};
// extensions
import { getImageComponentImageFileMap, ImageAttributes } from "@/extensions/custom-image";
export const CustomImageUploader = (props: {
onUpload: (url: string) => void;
failedToLoadImage: boolean;
editor: Editor;
fileInputRef: RefType;
existingFile?: File;
selected: boolean;
loadImageFromFileSystem: (file: string) => void;
setIsUploaded: (isUploaded: boolean) => void;
node: ProsemirrorNode & {
attrs: ImageAttributes;
};
updateAttributes: (attrs: Record<string, any>) => void;
getPos: () => number;
}) => {
const { selected, onUpload, editor, fileInputRef, existingFile } = props;
const { loading, uploadFile } = useUploader({ onUpload, editor });
const { handleUploadClick, ref: internalRef } = useFileUpload();
const {
selected,
failedToLoadImage,
editor,
loadImageFromFileSystem,
node,
setIsUploaded,
updateAttributes,
getPos,
} = props;
// ref
const fileInputRef = useRef<HTMLInputElement>(null);
const hasTriggeredFilePickerRef = useRef(false);
const imageEntityId = node.attrs.id;
const imageComponentImageFileMap = useMemo(() => getImageComponentImageFileMap(editor), [editor]);
const onUpload = useCallback(
(url: string) => {
if (url) {
setIsUploaded(true);
// Update the node view's src attribute post upload
updateAttributes({ src: url });
imageComponentImageFileMap?.delete(imageEntityId);
const pos = getPos();
// get current node
const getCurrentSelection = editor.state.selection;
const currentNode = editor.state.doc.nodeAt(getCurrentSelection.from);
// only if the cursor is at the current image component, manipulate
// the cursor position
if (currentNode && currentNode.type.name === "imageComponent" && currentNode.attrs.src === url) {
// control cursor position after upload
const nextNode = editor.state.doc.nodeAt(pos + 1);
if (nextNode && nextNode.type.name === "paragraph") {
// If there is a paragraph node after the image component, move the focus to the next node
editor.commands.setTextSelection(pos + 1);
} else {
// create a new paragraph after the image component post upload
editor.commands.createParagraphNear();
}
}
}
},
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
);
// hooks
const { uploading: isImageBeingUploaded, uploadFile } = useUploader({ onUpload, editor, loadImageFromFileSystem });
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ uploader: uploadFile });
const localRef = useRef<HTMLInputElement | null>(null);
// the meta data of the image component
const meta = useMemo(
() => imageComponentImageFileMap?.get(imageEntityId),
[imageComponentImageFileMap, imageEntityId]
);
// if the image component is dropped, we check if it has an existing file
const existingFile = useMemo(() => (meta && meta.event === "drop" ? meta.file : undefined), [meta]);
// after the image component is mounted we start the upload process based on
// it's uploaded
useEffect(() => {
if (meta) {
if (meta.event === "drop" && "file" in meta) {
uploadFile(meta.file);
} else if (meta.event === "insert" && fileInputRef.current && !hasTriggeredFilePickerRef.current) {
if (meta.hasOpenedFileInputOnce) return;
fileInputRef.current.click();
hasTriggeredFilePickerRef.current = true;
imageComponentImageFileMap?.set(imageEntityId, { ...meta, hasOpenedFileInputOnce: true });
}
}
}, [meta, uploadFile, imageComponentImageFileMap]);
// check if the image is dropped and set the local image as the existing file
useEffect(() => {
if (existingFile) {
uploadFile(existingFile);
}
}, [existingFile, uploadFile]);
const onFileChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
@@ -44,13 +119,22 @@ export const CustomImageUploader = (props: {
[uploadFile]
);
useEffect(() => {
// no need to validate as the file is already validated before the drop onto
// the editor
if (existingFile) {
uploadFile(existingFile);
const getDisplayMessage = useCallback(() => {
const isUploading = isImageBeingUploaded || existingFile;
if (failedToLoadImage) {
return "Error loading image";
}
}, [existingFile, uploadFile]);
if (isUploading) {
return "Uploading...";
}
if (draggedInside) {
return "Drop image here";
}
return "Add an image";
}, [draggedInside, failedToLoadImage, existingFile, isImageBeingUploaded]);
return (
<div
@@ -58,28 +142,27 @@ export const CustomImageUploader = (props: {
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 cursor-pointer transition-all duration-200 ease-in-out",
{
"bg-custom-background-80 text-custom-text-200": draggedInside,
},
{
"text-custom-primary-200 bg-custom-primary-100/10": selected,
"text-custom-primary-200 bg-custom-primary-100/10 hover:bg-custom-primary-100/10 hover:text-custom-primary-200 border-custom-primary-200/10":
selected,
"text-red-500 cursor-default hover:text-red-500": failedToLoadImage,
"bg-red-500/10 hover:bg-red-500/10": failedToLoadImage && selected,
}
)}
onDrop={onDrop}
onDragOver={onDragEnter}
onDragLeave={onDragLeave}
contentEditable={false}
onClick={handleUploadClick}
onClick={() => {
if (!failedToLoadImage) {
fileInputRef.current?.click();
}
}}
>
<ImageIcon className="size-4" />
<div className="text-base font-medium">
{loading ? "Uploading..." : draggedInside ? "Drop image here" : existingFile ? "Uploading..." : "Add an image"}
</div>
<div className="text-base font-medium">{getDisplayMessage()}</div>
<input
className="size-0 overflow-hidden"
ref={(element) => {
localRef.current = element;
assignRef(fileInputRef, element);
assignRef(internalRef as RefType, element);
}}
ref={fileInputRef}
hidden
type="file"
accept=".jpg,.jpeg,.png,.webp"
@@ -1,3 +1,4 @@
export * from "./toolbar";
export * from "./image-block";
export * from "./image-node";
export * from "./image-uploader";
@@ -0,0 +1,159 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react";
// helpers
import { cn } from "@/helpers/common";
type Props = {
image: {
src: string;
height: string;
width: string;
aspectRatio: number;
};
isOpen: boolean;
toggleFullScreenMode: (val: boolean) => void;
};
const MAGNIFICATION_VALUES = [0.5, 0.75, 1, 1.5, 1.75, 2];
export const ImageFullScreenAction: React.FC<Props> = (props) => {
const { image, isOpen: isFullScreenEnabled, toggleFullScreenMode } = props;
const { src, width, aspectRatio } = image;
// states
const [magnification, setMagnification] = useState(1);
// refs
const modalRef = useRef<HTMLDivElement>(null);
// derived values
const widthInNumber = useMemo(() => Number(width?.replace("px", "")), [width]);
// close handler
const handleClose = useCallback(() => {
toggleFullScreenMode(false);
setTimeout(() => {
setMagnification(1);
}, 200);
}, [toggleFullScreenMode]);
// download handler
const handleOpenInNewTab = () => {
const link = document.createElement("a");
link.href = src;
link.target = "_blank";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// magnification decrease handler
const handleDecreaseMagnification = useCallback(() => {
const currentIndex = MAGNIFICATION_VALUES.indexOf(magnification);
if (currentIndex === 0) return;
setMagnification(MAGNIFICATION_VALUES[currentIndex - 1]);
}, [magnification]);
// magnification increase handler
const handleIncreaseMagnification = useCallback(() => {
const currentIndex = MAGNIFICATION_VALUES.indexOf(magnification);
if (currentIndex === MAGNIFICATION_VALUES.length - 1) return;
setMagnification(MAGNIFICATION_VALUES[currentIndex + 1]);
}, [magnification]);
// keydown handler
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape" || e.key === "+" || e.key === "=" || e.key === "-") {
e.preventDefault();
e.stopPropagation();
if (e.key === "Escape") handleClose();
if (e.key === "+" || e.key === "=") handleIncreaseMagnification();
if (e.key === "-") handleDecreaseMagnification();
}
},
[handleClose, handleDecreaseMagnification, handleIncreaseMagnification]
);
// click outside handler
const handleClickOutside = useCallback(
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (modalRef.current && e.target === modalRef.current) {
handleClose();
}
},
[handleClose]
);
// register keydown listener
useEffect(() => {
if (isFullScreenEnabled) {
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}
}, [handleKeyDown, isFullScreenEnabled]);
return (
<>
<div
className={cn(
"fixed inset-0 size-full z-20 bg-black/90 opacity-0 pointer-events-none cursor-default transition-opacity",
{
"opacity-100 pointer-events-auto": isFullScreenEnabled,
}
)}
>
<div ref={modalRef} onClick={handleClickOutside} className="relative size-full grid place-items-center">
<button
type="button"
onClick={handleClose}
className="absolute top-10 right-10 size-8 grid place-items-center"
>
<X className="size-8 text-white/60 hover:text-white transition-colors" />
</button>
<img
src={src}
className="read-only-image rounded-lg transition-all duration-200"
style={{
width: `${widthInNumber * magnification}px`,
aspectRatio,
}}
/>
</div>
<div className="fixed bottom-10 left-1/2 -translate-x-1/2 flex items-center justify-center gap-1 rounded-md border border-white/20 py-2 divide-x divide-white/20 bg-black">
<div className="flex items-center">
<button
type="button"
onClick={handleDecreaseMagnification}
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
disabled={magnification === MAGNIFICATION_VALUES[0]}
>
<Minus className="size-4" />
</button>
<span className="text-sm w-12 text-center text-white">{(100 * magnification).toFixed(0)}%</span>
<button
type="button"
onClick={handleIncreaseMagnification}
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
disabled={magnification === MAGNIFICATION_VALUES[MAGNIFICATION_VALUES.length - 1]}
>
<Plus className="size-4" />
</button>
</div>
<button
type="button"
onClick={handleOpenInNewTab}
className="flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200"
>
<ExternalLink className="size-4" />
</button>
</div>
</div>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleFullScreenMode(true);
}}
className="size-5 grid place-items-center hover:bg-black/40 text-white rounded transition-colors"
>
<Maximize className="size-3" />
</button>
</>
);
};
@@ -0,0 +1 @@
export * from "./root";
@@ -0,0 +1,37 @@
import { useState } from "react";
// helpers
import { cn } from "@/helpers/common";
// components
import { ImageFullScreenAction } from "./full-screen";
type Props = {
containerClassName?: string;
image: {
src: string;
height: string;
width: string;
aspectRatio: number;
};
};
export const ImageToolbarRoot: React.FC<Props> = (props) => {
const { containerClassName, image } = props;
// state
const [isFullScreenEnabled, setIsFullScreenEnabled] = useState(false);
return (
<>
<div
className={cn(containerClassName, {
"opacity-100 pointer-events-auto": isFullScreenEnabled,
})}
>
<ImageFullScreenAction
image={image}
isOpen={isFullScreenEnabled}
toggleFullScreenMode={(val) => setIsFullScreenEnabled(val)}
/>
</div>
</>
);
};
@@ -1,4 +1,4 @@
import { mergeAttributes } from "@tiptap/core";
import { Editor, mergeAttributes } from "@tiptap/core";
import { Image } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
@@ -11,15 +11,24 @@ import { TFileHandler } from "@/types";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
export type InsertImageComponentProps = {
file?: File;
pos?: number;
event: "insert" | "drop";
};
declare module "@tiptap/core" {
interface Commands<ReturnType> {
imageComponent: {
setImageUpload: ({ file, pos, event }: { file?: File; pos?: number; event: "insert" | "drop" }) => ReturnType;
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
uploadImage: (file: File) => () => Promise<string> | undefined;
};
}
}
export const getImageComponentImageFileMap = (editor: Editor) =>
(editor.storage.imageComponent as UploadImageExtensionStorage | undefined)?.fileMap;
export interface UploadImageExtensionStorage {
fileMap: Map<string, UploadEntity>;
}
@@ -51,6 +60,9 @@ export const CustomImageExtension = (props: TFileHandler) => {
["id"]: {
default: null,
},
aspectRatio: {
default: null,
},
};
},
@@ -101,12 +113,13 @@ export const CustomImageExtension = (props: TFileHandler) => {
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
uploadInProgress: false,
};
},
addCommands() {
return {
setImageUpload:
insertImageComponent:
(props: { file?: File; pos?: number; event: "insert" | "drop" }) =>
({ commands }) => {
// Early return if there's an invalid file being dropped
@@ -117,15 +130,21 @@ export const CustomImageExtension = (props: TFileHandler) => {
// generate a unique id for the image to keep track of dropped
// files' file data
const fileId = uuidv4();
if (props?.event === "drop" && props.file) {
(this.editor.storage.imageComponent as UploadImageExtensionStorage).fileMap.set(fileId, {
file: props.file,
event: props.event,
});
} else if (props.event === "insert") {
(this.editor.storage.imageComponent as UploadImageExtensionStorage).fileMap.set(fileId, {
event: props.event,
});
const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor);
if (imageComponentImageFileMap) {
if (props?.event === "drop" && props.file) {
imageComponentImageFileMap.set(fileId, {
file: props.file,
event: props.event,
});
} else if (props.event === "insert") {
imageComponentImageFileMap.set(fileId, {
event: props.event,
hasOpenedFileInputOnce: false,
});
}
}
const attributes = {
@@ -27,6 +27,9 @@ export const CustomReadOnlyImageExtension = () =>
["id"]: {
default: null,
},
aspectRatio: {
default: null,
},
};
},
+45 -20
View File
@@ -1,6 +1,6 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { Extension, Editor } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { EditorView } from "@tiptap/pm/view";
export const DropHandlerExtension = () =>
Extension.create({
@@ -8,6 +8,7 @@ export const DropHandlerExtension = () =>
priority: 1000,
addProseMirrorPlugins() {
const editor = this.editor;
return [
new Plugin({
key: new PluginKey("drop-handler-plugin"),
@@ -20,15 +21,9 @@ export const DropHandlerExtension = () =>
if (imageFiles.length > 0) {
const pos = view.state.selection.from;
imageFiles.forEach((file, index) => {
this.editor
.chain()
.focus()
.setImageUpload({ file, pos: pos + index, event: "drop" })
.run();
});
return true;
insertImages({ editor, files: imageFiles, initialPos: pos, event: "drop" });
}
return true;
}
return false;
},
@@ -45,15 +40,8 @@ export const DropHandlerExtension = () =>
});
if (coordinates) {
imageFiles.forEach((file, index) => {
setTimeout(() => {
this.editor
.chain()
.focus()
.setImageUpload({ file, pos: coordinates.pos + index, event: "drop" })
.run();
}, index * 100); // Slight delay between insertions
});
const pos = coordinates.pos;
insertImages({ editor, files: imageFiles, initialPos: pos, event: "drop" });
}
return true;
}
@@ -65,3 +53,40 @@ export const DropHandlerExtension = () =>
];
},
});
const insertImages = async ({
editor,
files,
initialPos,
event,
}: {
editor: Editor;
files: File[];
initialPos: number;
event: "insert" | "drop";
}) => {
let pos = initialPos;
for (const file of files) {
// safe insertion
const docSize = editor.state.doc.content.size;
pos = Math.min(pos, docSize);
// Check if the position has a non-empty node
const nodeAtPos = editor.state.doc.nodeAt(pos);
if (nodeAtPos && nodeAtPos.content.size > 0) {
// Move to the end of the current node
pos += nodeAtPos.nodeSize;
}
try {
// Insert the image at the current position
editor.commands.insertImageComponent({ file, pos, event });
} catch (error) {
console.error(`Error while ${event}ing image:`, error);
}
// Move to the next position
pos += 1;
}
};
@@ -149,7 +149,7 @@ export const CoreEditorExtensions = ({
placeholder: ({ editor, node }) => {
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
// if (editor.storage.image.uploadInProgress) return "";
if (editor.storage.imageComponent.uploadInProgress) return "";
const shouldHidePlaceholder =
editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");
@@ -0,0 +1,57 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
export interface IMarking {
type: "heading";
level: number;
text: string;
sequence: number;
}
export const HeadingListExtension = Extension.create({
name: "headingList",
addStorage() {
return {
headings: [] as IMarking[],
};
},
addProseMirrorPlugins() {
const plugin = new Plugin({
key: new PluginKey("heading-list"),
appendTransaction: (_, __, newState) => {
const headings: IMarking[] = [];
let h1Sequence = 0;
let h2Sequence = 0;
let h3Sequence = 0;
newState.doc.descendants((node) => {
if (node.type.name === "heading") {
const level = node.attrs.level;
const text = node.textContent;
headings.push({
type: "heading",
level: level,
text: text,
sequence: level === 1 ? ++h1Sequence : level === 2 ? ++h2Sequence : ++h3Sequence,
});
}
});
this.storage.headings = headings;
this.editor.emit("update", { editor: this.editor, transaction: newState.tr });
return null;
},
});
return [plugin];
},
getHeadings() {
return this.storage.headings;
},
});
@@ -1,8 +1,7 @@
import { mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { Image } from "@tiptap/extension-image";
// extensions
import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions";
import { UploadImageExtensionStorage } from "@/extensions";
export const CustomImageComponentWithoutProps = () =>
Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
@@ -27,6 +26,9 @@ export const CustomImageComponentWithoutProps = () =>
["id"]: {
default: null,
},
aspectRatio: {
default: null,
},
};
},
@@ -48,10 +50,6 @@ export const CustomImageComponentWithoutProps = () =>
deletedImageSet: new Map<string, boolean>(),
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
export default CustomImageComponentWithoutProps;
@@ -1,7 +1,4 @@
import ImageExt from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomImageNode } from "@/extensions";
export const ImageExtensionWithoutProps = () =>
ImageExt.extend({
@@ -16,8 +13,4 @@ export const ImageExtensionWithoutProps = () =>
},
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
@@ -19,3 +19,4 @@ export * from "./quote";
export * from "./read-only-extensions";
export * from "./side-menu";
export * from "./slash-commands";
export * from "./headers";
@@ -1,6 +1,7 @@
// TODO: fix all warnings
/* eslint-disable react/display-name */
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import { useEffect, useState } from "react";
import { NodeViewWrapper } from "@tiptap/react";
@@ -19,6 +19,7 @@ import {
TableRow,
Table,
CustomMention,
HeadingListExtension,
CustomReadOnlyImageExtension,
} from "@/extensions";
// helpers
@@ -108,4 +109,5 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
readonly: true,
}),
CharacterCount,
HeadingListExtension,
];
@@ -78,10 +78,11 @@ const SideMenu = (options: SideMenuPluginProps) => {
hideSideMenu();
view?.dom.parentElement?.appendChild(editorSideMenu);
// side menu elements' initialization
if (handlesConfig.ai) {
if (handlesConfig.ai && !editorSideMenu.querySelector("#ai-handle")) {
aiHandleView(view, editorSideMenu);
}
if (handlesConfig.dragDrop) {
if (handlesConfig.dragDrop && !editorSideMenu.querySelector("#drag-handle")) {
dragHandleView(view, editorSideMenu);
}
@@ -113,6 +114,10 @@ const SideMenu = (options: SideMenuPluginProps) => {
rect.top += (lineHeight - 20) / 2;
rect.top += paddingTop;
if (handlesConfig.ai) {
rect.left -= 20;
}
if (node.parentElement?.parentElement?.matches("td") || node.parentElement?.parentElement?.matches("th")) {
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= 5;
@@ -34,6 +34,7 @@ import {
toggleHeadingFour,
toggleHeadingFive,
toggleHeadingSix,
insertImage,
} from "@/helpers/editor-commands";
// types
import { CommandProps, ISlashCommandItem } from "@/types";
@@ -226,9 +227,7 @@ const getSuggestionItems =
icon: <ImageIcon className="size-3.5" />,
description: "Insert an image",
searchTerms: ["img", "photo", "picture", "media", "upload"],
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setImageUpload({ event: "insert" }).run();
},
command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }),
},
{
key: "divider",
@@ -198,6 +198,7 @@ function createToolbox({
onSelectColor: (color: { backgroundColor: string; textColor: string }) => void;
colors: { [key: string]: { backgroundColor: string; textColor: string; icon?: string } };
}): Instance<Props> {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const toolbox = tippy(triggerButton, {
content: h(
@@ -204,11 +204,8 @@ export const Table = Node.create({
({ tr, dispatch }) => {
if (dispatch) {
const selection = CellSelection.create(tr.doc, position.anchorCell, position.headCell);
// @ts-ignore
tr.setSelection(selection);
}
return true;
},
};
@@ -247,7 +244,7 @@ export const Table = Node.create({
return ({ editor, getPos, node, decorations }) => {
const { cellMinWidth } = this.options;
return new TableView(node, cellMinWidth, decorations, editor, getPos as () => number);
return new TableView(node, cellMinWidth, decorations as any, editor, getPos as () => number);
};
},
@@ -267,8 +264,6 @@ export const Table = Node.create({
handleWidth: this.options.handleWidth,
cellMinWidth: this.options.cellMinWidth,
// View: TableView,
// @ts-ignore
lastColumnResizable: this.options.lastColumnResizable,
})
);
@@ -1,4 +1,4 @@
import { Fragment, Node as ProsemirrorNode, NodeType } from "prosemirror-model";
import { Fragment, Node as ProsemirrorNode, NodeType } from "@tiptap/pm/model";
export function createCell(
cellType: NodeType,
@@ -1,4 +1,4 @@
import { NodeType, Schema } from "prosemirror-model";
import { NodeType, Schema } from "@tiptap/pm/model";
export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } {
if (schema.cached.tableNodeTypes) {
@@ -1,13 +1,10 @@
import { Editor, Range } from "@tiptap/core";
import { Selection } from "@tiptap/pm/state";
// extensions
import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-with-text";
// helpers
import { findTableAncestor } from "@/helpers/common";
// plugins
import { startImageUpload } from "@/plugins/image";
// types
import { UploadImage } from "@/types";
import { InsertImageComponentProps } from "@/extensions";
export const setText = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).clearNodes().run();
@@ -129,6 +126,27 @@ export const insertTableCommand = (editor: Editor, range?: Range) => {
else editor.chain().focus().clearNodes().insertTable({ rows: 3, cols: 3 }).run();
};
export const insertImage = ({
editor,
event,
pos,
file,
range,
}: {
editor: Editor;
event: "insert" | "drop";
pos?: number | null;
file?: File;
range?: Range;
}) => {
if (range) editor.chain().focus().deleteRange(range).run();
const imageOptions: InsertImageComponentProps = { event };
if (pos) imageOptions.pos = pos;
if (file) imageOptions.file = file;
return editor?.chain().focus().insertImageComponent(imageOptions).run();
};
export const unsetLinkEditor = (editor: Editor) => {
editor.chain().focus().unsetLink().run();
};
@@ -136,23 +154,3 @@ export const unsetLinkEditor = (editor: Editor) => {
export const setLinkEditor = (editor: Editor, url: string) => {
editor.chain().focus().setLink({ href: url }).run();
};
export const insertImageCommand = (
editor: Editor,
uploadFile: UploadImage,
savedSelection?: Selection | null,
range?: Range
) => {
if (range) editor.chain().focus().deleteRange(range).run();
const input = document.createElement("input");
input.type = "file";
input.accept = ".jpeg, .jpg, .png, .webp";
input.onchange = async () => {
if (input.files?.length) {
const file = input.files[0];
const pos = savedSelection?.anchor ?? editor.view.state.selection.from;
startImageUpload(editor, file, editor.view, pos, uploadFile);
}
};
input.click();
};
+16
View File
@@ -0,0 +1,16 @@
import * as Y from "yjs";
/**
* @description apply updates to a doc and return the updated doc in base64(binary) format
* @param {Uint8Array} document
* @param {Uint8Array} updates
* @returns {string} base64(binary) form of the updated doc
*/
export const applyUpdates = (document: Uint8Array, updates: Uint8Array): Uint8Array => {
const yDoc = new Y.Doc();
Y.applyUpdate(yDoc, document);
Y.applyUpdate(yDoc, updates);
const encodedDoc = Y.encodeStateAsUpdate(yDoc);
return encodedDoc;
};
@@ -1,9 +1,9 @@
import { useEffect, useLayoutEffect, useMemo } from "react";
import { useEffect, useLayoutEffect, useMemo, useState } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import Collaboration from "@tiptap/extension-collaboration";
import { IndexeddbPersistence } from "y-indexeddb";
// extensions
import { SideMenuExtension } from "@/extensions";
import { HeadingListExtension, SideMenuExtension } from "@/extensions";
// hooks
import { useEditor } from "@/hooks/use-editor";
// plane editor extensions
@@ -29,6 +29,9 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
tabIndex,
user,
} = props;
// states
const [hasServerConnectionFailed, setHasServerConnectionFailed] = useState(false);
const [hasServerSynced, setHasServerSynced] = useState(false);
// initialize Hocuspocus provider
const provider = useMemo(
() =>
@@ -38,11 +41,18 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
// using user id as a token to verify the user on the server
token: user.id,
url: realtimeConfig.url,
onAuthenticationFailed: () => serverHandler?.onServerError?.(),
onAuthenticationFailed: () => {
serverHandler?.onServerError?.();
setHasServerConnectionFailed(true);
},
onConnect: () => serverHandler?.onConnect?.(),
onClose: (data) => {
if (data.event.code === 1006) serverHandler?.onServerError?.();
if (data.event.code === 1006) {
serverHandler?.onServerError?.();
setHasServerConnectionFailed(true);
}
},
onSynced: () => setHasServerSynced(true),
}),
[id, realtimeConfig, serverHandler, user.id]
);
@@ -68,15 +78,12 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
editorProps,
editorClassName,
enableHistory: false,
fileHandler,
handleEditorReady,
forwardedRef,
mentionHandler,
extensions: [
SideMenuExtension({
aiEnabled: !disabledExtensions?.includes("ai"),
dragDropEnabled: true,
}),
HeadingListExtension,
Collaboration.configure({
document: provider.document,
}),
@@ -88,9 +95,18 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
userDetails: user,
}),
],
fileHandler,
handleEditorReady,
forwardedRef,
mentionHandler,
placeholder,
provider,
tabIndex,
});
return { editor };
return {
editor,
hasServerConnectionFailed,
hasServerSynced,
};
};
+41 -12
View File
@@ -1,8 +1,10 @@
import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { DOMSerializer } from "@tiptap/pm/model";
import { Selection } from "@tiptap/pm/state";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useTiptapEditor, Editor } from "@tiptap/react";
import * as Y from "yjs";
// components
import { getEditorMenuItems } from "@/components/menus";
// extensions
@@ -32,6 +34,7 @@ export interface CustomEditorProps {
};
onChange?: (json: object, html: string) => void;
placeholder?: string | ((isFocused: boolean, value: string) => string);
provider?: HocuspocusProvider;
tabIndex?: number;
// undefined when prop is not passed, null if intentionally passed to stop
// swr syncing
@@ -52,10 +55,12 @@ export const useEditor = (props: CustomEditorProps) => {
mentionHandler,
onChange,
placeholder,
provider,
tabIndex,
value,
} = props;
// states
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
// refs
const editorRef: MutableRefObject<Editor | null> = useRef(null);
@@ -78,7 +83,7 @@ export const useEditor = (props: CustomEditorProps) => {
},
mentionConfig: {
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
mentionHighlights: mentionHandler.highlights ?? [],
mentionHighlights: mentionHandler.highlights,
},
placeholder,
tabIndex,
@@ -102,7 +107,7 @@ export const useEditor = (props: CustomEditorProps) => {
// value is null when intentionally passed where syncing is not yet
// supported and value is undefined when the data from swr is not populated
if (value === null || value === undefined) return;
if (editor && !editor.isDestroyed && !editor.storage.image.uploadInProgress) {
if (editor && !editor.isDestroyed && !editor.storage.imageComponent.uploadInProgress) {
try {
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
const currentSavedSelection = savedSelectionRef.current;
@@ -154,11 +159,25 @@ export const useEditor = (props: CustomEditorProps) => {
const item = getEditorMenuItem(itemName);
return item ? item.isActive() : false;
},
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
// Subscribe to update event emitted from headers extension
editorRef.current?.on("update", () => {
callback(editorRef.current?.storage.headingList.headings);
});
// Return a function to unsubscribe to the continuous transactions of
// the editor on unmounting the component that has subscribed to this
// method
return () => {
editorRef.current?.off("update");
};
},
getHeadings: () => editorRef?.current?.storage.headingList.headings,
onStateChange: (callback: () => void) => {
// Subscribe to editor state changes
editorRef.current?.on("transaction", () => {
callback();
});
// Return a function to unsubscribe to the continuous transactions of
// the editor on unmounting the component that has subscribed to this
// method
@@ -170,15 +189,22 @@ export const useEditor = (props: CustomEditorProps) => {
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
return markdownOutput;
},
getHTML: (): string => {
const htmlOutput = editorRef.current?.getHTML() ?? "<p></p>";
return htmlOutput;
getDocument: () => {
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
const documentHTML = editorRef.current?.getHTML() ?? "<p></p>";
const documentJSON = editorRef.current?.getJSON() ?? null;
return {
binary: documentBinary,
html: documentHTML,
json: documentJSON,
};
},
scrollSummary: (marking: IMarking): void => {
if (!editorRef.current) return;
scrollSummary(editorRef.current, marking);
},
isEditorReadyToDiscard: () => editorRef.current?.storage.image.uploadInProgress === false,
isEditorReadyToDiscard: () => editorRef.current?.storage.imageComponent.uploadInProgress === false,
setFocusAtPosition: (position: number) => {
if (!editorRef.current || editorRef.current.isDestroyed) {
console.error("Editor reference is not available or has been destroyed.");
@@ -236,12 +262,15 @@ export const useEditor = (props: CustomEditorProps) => {
editorRef.current.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
}
},
getDocumentInfo: () => {
return {
characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editorRef?.current?.state),
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
};
getDocumentInfo: () => ({
characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editorRef?.current?.state),
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
}),
setProviderDocument: (value) => {
const document = provider?.document;
if (!document) return;
Y.applyUpdate(document, value);
},
}),
[editorRef, savedSelection]
@@ -1,17 +1,48 @@
import { DragEvent, useCallback, useEffect, useRef, useState } from "react";
import { DragEvent, useCallback, useEffect, useState } from "react";
import { Editor } from "@tiptap/core";
import { isFileValid } from "@/plugins/image";
export const useUploader = ({ onUpload, editor }: { onUpload: (url: string) => void; editor: Editor }) => {
const [loading, setLoading] = useState(false);
export const useUploader = ({
onUpload,
editor,
loadImageFromFileSystem,
}: {
onUpload: (url: string) => void;
editor: Editor;
loadImageFromFileSystem: (file: string) => void;
}) => {
const [uploading, setUploading] = useState(false);
const uploadFile = useCallback(
async (file: File) => {
setLoading(true);
const setImageUploadInProgress = (isUploading: boolean) => {
editor.storage.imageComponent.uploadInProgress = isUploading;
};
setImageUploadInProgress(true);
setUploading(true);
const fileNameTrimmed = trimFileName(file.name);
const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type });
const isValid = isFileValid(fileWithTrimmedName);
if (!isValid) {
setImageUploadInProgress(false);
return;
}
try {
const reader = new FileReader();
reader.onload = () => {
if (reader.result) {
loadImageFromFileSystem(reader.result as string);
} else {
console.error("Failed to read the file: reader.result is null");
}
};
reader.onerror = () => {
console.error("Error reading file");
};
reader.readAsDataURL(fileWithTrimmedName);
// @ts-expect-error - TODO: fix typings, and don't remove await from
// here for now
const url: string = await editor?.commands.uploadImage(file);
const url: string = await editor?.commands.uploadImage(fileWithTrimmedName);
if (!url) {
throw new Error("Something went wrong while uploading the image");
@@ -21,24 +52,17 @@ export const useUploader = ({ onUpload, editor }: { onUpload: (url: string) => v
console.log(errPayload);
const error = errPayload?.response?.data?.error || "Something went wrong";
console.error(error);
} finally {
setImageUploadInProgress(false);
setUploading(false);
}
setLoading(false);
},
[onUpload, editor]
[onUpload]
);
return { loading, uploadFile };
return { uploading, uploadFile };
};
export const useFileUpload = () => {
const fileInput = useRef<HTMLInputElement>(null);
const handleUploadClick = useCallback(() => {
fileInput.current?.click();
}, []);
return { ref: fileInput, handleUploadClick };
};
export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) => {
const [isDragging, setIsDragging] = useState<boolean>(false);
const [draggedInside, setDraggedInside] = useState<boolean>(false);
@@ -90,10 +114,9 @@ export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) =>
const file = filteredFiles.length > 0 ? filteredFiles[0] : undefined;
if (file) {
const isValid = isFileValid(file);
if (isValid) {
uploader(file);
}
uploader(file);
} else {
console.error("No file found");
}
},
[uploader]
@@ -109,3 +132,14 @@ export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) =>
return { isDragging, draggedInside, onDragEnter, onDragLeave, onDrop };
};
function trimFileName(fileName: string, maxLength = 100) {
if (fileName.length > maxLength) {
const extension = fileName.split(".").pop();
const nameWithoutExtension = fileName.slice(0, -(extension?.length ?? 0 + 1));
const allowedNameLength = maxLength - (extension?.length ?? 0) - 1; // -1 for the dot
return `${nameWithoutExtension.slice(0, allowedNameLength)}.${extension}`;
}
return fileName;
}
@@ -1,7 +1,9 @@
import { useEffect, useLayoutEffect, useMemo } from "react";
import { useEffect, useLayoutEffect, useMemo, useState } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import Collaboration from "@tiptap/extension-collaboration";
import { IndexeddbPersistence } from "y-indexeddb";
// extensions
import { HeadingListExtension } from "@/extensions";
// hooks
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// types
@@ -20,6 +22,9 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit
serverHandler,
user,
} = props;
// states
const [hasServerConnectionFailed, setHasServerConnectionFailed] = useState(false);
const [hasServerSynced, setHasServerSynced] = useState(false);
// initialize Hocuspocus provider
const provider = useMemo(
() =>
@@ -28,10 +33,18 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit
name: id,
token: user.id,
parameters: realtimeConfig.queryParams,
onAuthenticationFailed: () => {
serverHandler?.onServerError?.();
setHasServerConnectionFailed(true);
},
onConnect: () => serverHandler?.onConnect?.(),
onClose: (data) => {
if (data.event.code === 1006) serverHandler?.onServerError?.();
if (data.event.code === 1006) {
serverHandler?.onServerError?.();
setHasServerConnectionFailed(true);
}
},
onSynced: () => setHasServerSynced(true),
}),
[id, realtimeConfig, user.id]
);
@@ -54,16 +67,22 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit
const editor = useReadOnlyEditor({
editorProps,
editorClassName,
forwardedRef,
handleEditorReady,
mentionHandler,
extensions: [
...(extensions ?? []),
HeadingListExtension,
Collaboration.configure({
document: provider.document,
}),
],
forwardedRef,
handleEditorReady,
mentionHandler,
provider,
});
return { editor, isIndexedDbSynced: true };
return {
editor,
hasServerConnectionFailed,
hasServerSynced,
};
};
@@ -1,6 +1,8 @@
import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
import * as Y from "yjs";
// extensions
import { CoreReadOnlyEditorExtensions } from "@/extensions";
// helpers
@@ -21,17 +23,21 @@ interface CustomReadOnlyEditorProps {
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
};
provider?: HocuspocusProvider;
}
export const useReadOnlyEditor = ({
initialValue,
editorClassName,
forwardedRef,
extensions = [],
editorProps = {},
handleEditorReady,
mentionHandler,
}: CustomReadOnlyEditorProps) => {
export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
const {
initialValue,
editorClassName,
forwardedRef,
extensions = [],
editorProps = {},
handleEditorReady,
mentionHandler,
provider,
} = props;
const editor = useCustomEditor({
editable: false,
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
@@ -74,21 +80,39 @@ export const useReadOnlyEditor = ({
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
return markdownOutput;
},
getHTML: (): string => {
const htmlOutput = editorRef.current?.getHTML() ?? "<p></p>";
return htmlOutput;
getDocument: () => {
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
const documentHTML = editorRef.current?.getHTML() ?? "<p></p>";
const documentJSON = editorRef.current?.getJSON() ?? null;
return {
binary: documentBinary,
html: documentHTML,
json: documentJSON,
};
},
scrollSummary: (marking: IMarking): void => {
if (!editorRef.current) return;
scrollSummary(editorRef.current, marking);
},
getDocumentInfo: () => {
return {
characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editorRef?.current?.state),
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
getDocumentInfo: () => ({
characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editorRef?.current?.state),
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
}),
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
// Subscribe to update event emitted from headers extension
editorRef.current?.on("update", () => {
callback(editorRef.current?.storage.headingList.headings);
});
// Return a function to unsubscribe to the continuous transactions of
// the editor on unmounting the component that has subscribed to this
// method
return () => {
editorRef.current?.off("update");
};
},
getHeadings: () => editorRef?.current?.storage.headingList.headings,
}));
if (!editor) {
+2 -35
View File
@@ -2,45 +2,12 @@ import { NodeSelection } from "@tiptap/pm/state";
import { EditorView } from "@tiptap/pm/view";
// extensions
import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions";
// plugins
import { nodeDOMAtCoords } from "@/plugins/drag-handle";
const sparklesIcon =
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sparkles"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg>';
const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
const elements = document.elementsFromPoint(coords.x, coords.y);
const generalSelectors = [
"li",
"p:not(:first-child)",
".code-block",
"blockquote",
"img",
"h1, h2, h3, h4, h5, h6",
"[data-type=horizontalRule]",
".table-wrapper",
".issue-embed",
].join(", ");
for (const elem of elements) {
if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
return elem;
}
// if the element is a <p> tag that is the first child of a td or th
if (
(elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) &&
elem?.textContent?.trim() !== ""
) {
return elem; // Return only if p tag is not empty in td or th
}
// apply general selector
if (elem.matches(generalSelectors)) {
return elem;
}
}
return null;
};
const nodePosAtDOM = (node: Element, view: EditorView, options: SideMenuPluginProps) => {
const boundingRect = node.getBoundingClientRect();
@@ -37,39 +37,19 @@ export const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
"p:not(:first-child)",
".code-block",
"blockquote",
"img",
"h1, h2, h3, h4, h5, h6",
"[data-type=horizontalRule]",
".table-wrapper",
".issue-embed",
".image-component",
".image-upload-component",
].join(", ");
const hasNestedImg = (el: Element): boolean => {
if (el.tagName.toLowerCase() === "img") return true;
// @ts-expect-error todo
for (const child of el.children) {
if (hasNestedImg(child)) return true;
}
return false;
};
for (const elem of elements) {
const elemHasNestedImg = hasNestedImg(elem);
if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
return elem;
}
// if the element is a <p> tag and has a nested img i.e. the new image
// component
if (elem.matches("p") && elemHasNestedImg) {
return null;
}
if (elem.matches("div") && elemHasNestedImg) {
return elem;
}
// if the element is a <p> tag that is the first child of a td or th
if (
(elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) &&
@@ -46,6 +46,7 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag
async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise<void> {
try {
if (!src) return;
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
await deleteImage(assetUrlWithWorkspaceId);
} catch (error) {
@@ -1,137 +0,0 @@
import { Editor } from "@tiptap/core";
import { EditorView } from "@tiptap/pm/view";
import { v4 as uuidv4 } from "uuid";
// plugins
import { findPlaceholder, isFileValid, removePlaceholder, uploadKey } from "@/plugins/image";
// types
import { UploadImage } from "@/types";
export async function startImageUpload(
editor: Editor,
file: File,
view: EditorView,
pos: number | null,
uploadFile: UploadImage
) {
editor.storage.image.uploadInProgress = true;
if (!isFileValid(file)) {
editor.storage.image.uploadInProgress = false;
return;
}
const id = uuidv4();
const tr = view.state.tr;
if (!tr.selection.empty) tr.deleteSelection();
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
tr.setMeta(uploadKey, {
add: {
id,
pos,
src: reader.result,
},
});
view.dispatch(tr);
};
// Handle FileReader errors
reader.onerror = (error) => {
console.error("FileReader error: ", error);
removePlaceholder(editor, view, id);
return;
};
try {
const fileNameTrimmed = trimFileName(file.name);
const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type });
const resolvedPos = view.state.doc.resolve(pos ?? 0);
const nodeBefore = resolvedPos.nodeBefore;
// if the image is at the start of the line i.e. when nodeBefore is null
if (nodeBefore === null) {
if (pos) {
// so that the image is not inserted at the next line, else incase the
// image is inserted at any line where there's some content, the
// position is kept as it is to be inserted at the next line
pos -= 1;
}
}
view.focus();
const src = await uploadAndValidateImage(fileWithTrimmedName, uploadFile);
if (src == null) {
throw new Error("Resolved image URL is undefined.");
}
const { schema } = view.state;
pos = findPlaceholder(view.state, id);
if (pos == null) {
editor.storage.image.uploadInProgress = false;
return;
}
const imageSrc = typeof src === "object" ? reader.result : src;
const node = schema.nodes.image.create({ src: imageSrc });
if (pos < 0 || pos > view.state.doc.content.size) {
throw new Error("Invalid position to insert the image node.");
}
// insert the image node at the position of the placeholder and remove the placeholder
const transaction = view.state.tr.insert(pos, node).setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
editor.storage.image.uploadInProgress = false;
} catch (error) {
console.error("Error in uploading and inserting image: ", error);
removePlaceholder(editor, view, id);
}
}
async function uploadAndValidateImage(file: File, uploadFile: UploadImage): Promise<string | undefined> {
try {
const imageUrl = await uploadFile(file);
if (imageUrl == null) {
throw new Error("Image URL is undefined.");
}
await new Promise<void>((resolve, reject) => {
const image = new Image();
image.src = imageUrl;
image.onload = () => {
resolve();
};
image.onerror = (error) => {
console.error("Error in loading image: ", error);
reject(error);
};
});
return imageUrl;
} catch (error) {
console.error("Error in uploading image: ", error);
// throw error to remove the placeholder
throw error;
}
}
function trimFileName(fileName: string, maxLength = 100) {
if (fileName.length > maxLength) {
const extension = fileName.split(".").pop();
const nameWithoutExtension = fileName.slice(0, -(extension?.length ?? 0 + 1));
const allowedNameLength = maxLength - (extension?.length ?? 0) - 1; // -1 for the dot
return `${nameWithoutExtension.slice(0, allowedNameLength)}.${extension}`;
}
return fileName;
}
@@ -2,6 +2,4 @@ export * from "./types";
export * from "./utils";
export * from "./constants";
export * from "./delete-image";
export * from "./image-upload-handler";
export * from "./restore-image";
export * from "./upload-image";

Some files were not shown because too many files have changed in this diff Show More