Compare commits

..

2 Commits

Author SHA1 Message Date
Manuel Raynaud
294b1a0dc5 🐛(backend) replace document creation table locks with retry strategy
We have situation where the number of locks in the database can increase
dangerously creating deadlock situation. To remove this situation we
decided to change the strategy to manage document creation concurrency.
We decided to use a retry strategy, trying to create the document
multiple times while a usable path is found. To avoid having an
inifinite loop, we use a max_attempts counter configurable using the
setting TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS
2026-05-06 12:53:53 +02:00
Manuel Raynaud
cbd2705c9f ♻️(backend) split core/utils.py module
We need to split the core/utils.py in multiple submodule created in
core/utils/*.py. We need to do this to avoir circular import between
this module and the models module.
2026-05-06 12:42:29 +02:00
85 changed files with 3591 additions and 3305 deletions

View File

@@ -6,22 +6,10 @@ and this project adheres to
## [Unreleased]
### Added
- ⚡️(frontend) add skeleton on content loading #2254
### Changed
- 🧵(backend) remove lock from db table #2272
### Fixed
- 💬(frontend) add missing link in onboarding description #2233
- 🐛(frontend) sanitize pasted and dropped content in document title #2210
- 🐛(frontend) Emoji menu doesn't display above comment box #2229
- 🐛(frontend) Block menu doesn't stay open on 1st line #2229
- 🐛(frontend) The "+" on the first line of a new doc doesn't work #2229
- 🐛(backend) replace document creation table locks with retry strategy
## [v5.0.0] - 2026-04-08

View File

@@ -134,6 +134,7 @@ These are the environment variables you can set for the `impress-backend` contai
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
| TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS | Number of attempts to create a document before failing. | 10 |
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
| USER_ONBOARDING_DOCUMENTS | A list of documents IDs for which a read-only access will be created for new s | [] |
| USER_ONBOARDING_SANDBOX_DOCUMENT | ID of a template sandbox document that will be duplicated for new users | |

View File

@@ -4,11 +4,9 @@
import binascii
import mimetypes
from base64 import b64decode
from logging import getLogger
from os.path import splitext
from django.conf import settings
from django.db import IntegrityError, transaction
from django.db.models import Q
from django.utils.functional import lazy
from django.utils.text import slugify
@@ -25,8 +23,7 @@ from core.services.converter_services import (
ConversionError,
Converter,
)
logger = getLogger(__name__)
from core.utils.treebeard import create_tree_node_with_retry
class UserSerializer(serializers.ModelSerializer):
@@ -470,18 +467,12 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
{"content": ["Could not convert content"]}
) from err
while True:
try:
with transaction.atomic():
document = models.Document.add_root(
title=validated_data["title"],
creator=user,
)
break
except IntegrityError as e:
if "impress_document_path_key" not in str(e):
raise
logger.warning("Path key conflict when creating document, retrying...")
document = create_tree_node_with_retry(
lambda: models.Document.add_root(
title=validated_data["title"],
creator=user,
)
)
if user:
# Associate the document with the pre-existing user

View File

@@ -19,7 +19,7 @@ from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.core.validators import URLValidator
from django.db import IntegrityError, connection, transaction
from django.db import connection, transaction
from django.db import models as db
from django.db.models.expressions import RawSQL
from django.db.models.functions import Greatest, Left, Length
@@ -67,11 +67,10 @@ from core.services.search_indexers import (
get_visited_document_ids_of,
)
from core.tasks.mail import send_ask_for_access_mail
from core.utils import (
extract_attachments,
filter_descendants,
users_sharing_documents_with,
)
from core.utils.paths import filter_descendants
from core.utils.treebeard import create_tree_node_with_retry
from core.utils.users import users_sharing_documents_with
from core.utils.yjs import extract_attachments
from ..enums import FeatureFlag, SearchType
from . import permissions, serializers, utils
@@ -708,18 +707,12 @@ class DocumentViewSet(
{"file": ["Could not convert file content"]}
) from err
while True:
try:
with transaction.atomic():
obj = models.Document.add_root(
creator=self.request.user,
**serializer.validated_data,
)
break
except IntegrityError as e:
if "impress_document_path_key" not in str(e):
raise
logger.warning("Path key conflict when creating document, retrying...")
obj = create_tree_node_with_retry(
lambda: models.Document.add_root(
creator=self.request.user,
**serializer.validated_data,
)
)
serializer.instance = obj
models.DocumentAccess.objects.create(
document=obj,
@@ -1023,16 +1016,12 @@ class DocumentViewSet(
)
serializer.is_valid(raise_exception=True)
with transaction.atomic():
# "select_for_update" locks the table to ensure safe concurrent access
locked_parent = models.Document.objects.select_for_update().get(
pk=document.pk
)
child_document = locked_parent.add_child(
child_document = create_tree_node_with_retry(
lambda: document.add_child(
creator=request.user,
**serializer.validated_data,
)
)
# Set the created instance to the serializer
serializer.instance = child_document

View File

@@ -9,7 +9,7 @@ from django.db import migrations, models
from botocore.exceptions import ClientError
import core.models
from core.utils import extract_attachments
from core.utils.yjs import extract_attachments
def populate_attachments_on_all_documents(apps, schema_editor):

View File

@@ -19,7 +19,7 @@ from django.core.cache import cache
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.core.mail import send_mail
from django.db import IntegrityError, models, transaction
from django.db import models, transaction
from django.db.models.functions import Left, Length
from django.template.loader import render_to_string
from django.utils import timezone
@@ -39,6 +39,7 @@ from core.choices import (
RoleChoices,
get_equivalent_link_definition,
)
from core.utils.treebeard import create_tree_node_with_retry
from core.validators import sub_validator
logger = getLogger(__name__)
@@ -265,8 +266,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
duplicate the sandbox document for the user
"""
if settings.USER_ONBOARDING_SANDBOX_DOCUMENT:
# transaction.atomic is used in a context manager to avoid a transaction if
# the settings USER_ONBOARDING_SANDBOX_DOCUMENT is unused
sandbox_id = settings.USER_ONBOARDING_SANDBOX_DOCUMENT
try:
template_document = Document.objects.get(id=sandbox_id)
@@ -276,27 +275,20 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
sandbox_id,
)
return
while True:
try:
with transaction.atomic():
sandbox_document = Document.add_root(
title=template_document.title,
content=template_document.content,
attachments=template_document.attachments,
duplicated_from=template_document,
creator=self,
)
DocumentAccess.objects.create(
user=self, document=sandbox_document, role=RoleChoices.OWNER
)
break
except IntegrityError as e:
if "impress_document_path_key" not in str(e):
raise
logger.warning(
"Path key conflict when creating sandbox document, retrying..."
with transaction.atomic():
sandbox_document = create_tree_node_with_retry(
lambda: Document.add_root(
title=template_document.title,
content=template_document.content,
attachments=template_document.attachments,
duplicated_from=template_document,
creator=self,
)
)
DocumentAccess.objects.create(
user=self, document=sandbox_document, role=RoleChoices.OWNER
)
def _convert_valid_invitations(self):
"""

View File

@@ -12,8 +12,11 @@ from django.utils.module_loading import import_string
import requests
from core import models, utils
from core import models
from core.enums import SearchType
from core.utils.dicts import get_value_by_pattern
from core.utils.paths import get_ancestor_to_descendants_map
from core.utils.yjs import base64_yjs_to_text
logger = logging.getLogger(__name__)
@@ -44,7 +47,7 @@ def get_batch_accesses_by_users_and_teams(paths):
Get accesses related to a list of document paths,
grouped by users and teams, including all ancestor paths.
"""
ancestor_map = utils.get_ancestor_to_descendants_map(
ancestor_map = get_ancestor_to_descendants_map(
paths, steplen=models.Document.steplen
)
ancestor_paths = list(ancestor_map.keys())
@@ -297,7 +300,7 @@ class FindDocumentIndexer(BaseDocumentIndexer):
>>> get_title({"id": 1})
""
"""
titles = utils.get_value_by_pattern(source, r"^title\.")
titles = get_value_by_pattern(source, r"^title\.")
for title in titles:
if title:
return title
@@ -318,7 +321,7 @@ class FindDocumentIndexer(BaseDocumentIndexer):
"""
doc_path = document.path
doc_content = document.content
text_content = utils.base64_yjs_to_text(doc_content) if doc_content else ""
text_content = base64_yjs_to_text(doc_content) if doc_content else ""
return {
"id": str(document.id),

View File

@@ -11,7 +11,7 @@ from django.dispatch import receiver
from core import models
from core.tasks.search import trigger_batch_document_indexer
from core.utils import get_users_sharing_documents_with_cache_key
from core.utils.users import get_users_sharing_documents_with_cache_key
@receiver(signals.post_save, sender=models.Document)

View File

@@ -12,13 +12,14 @@ import pytest
import responses
from requests import HTTPError
from core import factories, models, utils
from core import factories, models
from core.services.search_indexers import (
BaseDocumentIndexer,
FindDocumentIndexer,
get_document_indexer,
get_visited_document_ids_of,
)
from core.utils.yjs import base64_yjs_to_text
pytestmark = pytest.mark.django_db
@@ -199,7 +200,7 @@ def test_services_search_indexers_serialize_document_returns_expected_json():
"depth": 1,
"path": document.path,
"numchild": 1,
"content": utils.base64_yjs_to_text(document.content),
"content": base64_yjs_to_text(document.content),
"created_at": document.created_at.isoformat(),
"updated_at": document.updated_at.isoformat(),
"reach": document.link_reach,

View File

@@ -8,7 +8,18 @@ from django.core.cache import cache
import pycrdt
import pytest
from core import factories, utils
from core import factories
from core.utils.dicts import get_value_by_pattern
from core.utils.paths import get_ancestor_to_descendants_map
from core.utils.users import (
get_users_sharing_documents_with_cache_key,
users_sharing_documents_with,
)
from core.utils.yjs import (
base64_yjs_to_text,
base64_yjs_to_xml,
extract_attachments,
)
pytestmark = pytest.mark.django_db
@@ -34,12 +45,12 @@ TEST_BASE64_STRING = (
def test_utils_base64_yjs_to_text():
"""Test extract text from saved yjs document"""
assert utils.base64_yjs_to_text(TEST_BASE64_STRING) == "Hello w or ld"
assert base64_yjs_to_text(TEST_BASE64_STRING) == "Hello w or ld"
def test_utils_base64_yjs_to_xml():
"""Test extract xml from saved yjs document"""
content = utils.base64_yjs_to_xml(TEST_BASE64_STRING)
content = base64_yjs_to_xml(TEST_BASE64_STRING)
assert (
'<heading textAlignment="left" level="1"><italic>Hello</italic></heading>'
in content
@@ -79,13 +90,13 @@ def test_utils_extract_attachments():
update = ydoc.get_update()
base64_string = base64.b64encode(update).decode("utf-8")
# image_key2 is missing the "/media/" part and shouldn't get extracted
assert utils.extract_attachments(base64_string) == [image_key1, image_key3]
assert extract_attachments(base64_string) == [image_key1, image_key3]
def test_utils_get_ancestor_to_descendants_map_single_path():
"""Test ancestor mapping of a single path."""
paths = ["000100020005"]
result = utils.get_ancestor_to_descendants_map(paths, steplen=4)
result = get_ancestor_to_descendants_map(paths, steplen=4)
assert result == {
"0001": {"000100020005"},
@@ -97,7 +108,7 @@ def test_utils_get_ancestor_to_descendants_map_single_path():
def test_utils_get_ancestor_to_descendants_map_multiple_paths():
"""Test ancestor mapping of multiple paths with shared prefixes."""
paths = ["000100020005", "00010003"]
result = utils.get_ancestor_to_descendants_map(paths, steplen=4)
result = get_ancestor_to_descendants_map(paths, steplen=4)
assert result == {
"0001": {"000100020005", "00010003"},
@@ -119,10 +130,10 @@ def test_utils_users_sharing_documents_with_cache_miss():
factories.UserDocumentAccessFactory(user=user2, document=doc1)
factories.UserDocumentAccessFactory(user=user3, document=doc2)
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache_key = get_users_sharing_documents_with_cache_key(user1)
cache.delete(cache_key)
result = utils.users_sharing_documents_with(user1)
result = users_sharing_documents_with(user1)
assert user2.id in result
@@ -139,12 +150,12 @@ def test_utils_users_sharing_documents_with_cache_hit():
factories.UserDocumentAccessFactory(user=user1, document=doc1)
factories.UserDocumentAccessFactory(user=user2, document=doc1)
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache_key = get_users_sharing_documents_with_cache_key(user1)
test_cached_data = {user2.id: "2025-02-10"}
cache.set(cache_key, test_cached_data, 86400)
result = utils.users_sharing_documents_with(user1)
result = users_sharing_documents_with(user1)
assert result == test_cached_data
@@ -156,7 +167,7 @@ def test_utils_users_sharing_documents_with_cache_invalidation_on_create():
doc1 = factories.DocumentFactory()
# Pre-populate cache
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache_key = get_users_sharing_documents_with_cache_key(user1)
cache.set(cache_key, {}, 86400)
# Verify cache exists
@@ -182,7 +193,7 @@ def test_utils_users_sharing_documents_with_cache_invalidation_on_delete():
doc_access = factories.UserDocumentAccessFactory(user=user1, document=doc1)
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache_key = get_users_sharing_documents_with_cache_key(user1)
cache.set(cache_key, {user2.id: "2025-02-10"}, 86400)
assert cache.get(cache_key) is not None
@@ -196,10 +207,10 @@ def test_utils_users_sharing_documents_with_empty_result():
"""Test when user is not sharing any documents."""
user1 = factories.UserFactory()
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache_key = get_users_sharing_documents_with_cache_key(user1)
cache.delete(cache_key)
result = utils.users_sharing_documents_with(user1)
result = users_sharing_documents_with(user1)
assert result == {}
@@ -210,7 +221,7 @@ def test_utils_users_sharing_documents_with_empty_result():
def test_utils_get_value_by_pattern_matching_key():
"""Test extracting value from a dictionary with a matching key pattern."""
data = {"title.extension": "Bonjour", "id": 1, "content": "test"}
result = utils.get_value_by_pattern(data, r"^title\.")
result = get_value_by_pattern(data, r"^title\.")
assert set(result) == {"Bonjour"}
@@ -218,7 +229,7 @@ def test_utils_get_value_by_pattern_matching_key():
def test_utils_get_value_by_pattern_multiple_matches():
"""Test that all matching keys are returned."""
data = {"title.extension_1": "Bonjour", "title.extension_2": "Hello", "id": 1}
result = utils.get_value_by_pattern(data, r"^title\.")
result = get_value_by_pattern(data, r"^title\.")
assert set(result) == {
"Bonjour",
@@ -229,7 +240,7 @@ def test_utils_get_value_by_pattern_multiple_matches():
def test_utils_get_value_by_pattern_multiple_extensions():
"""Test that all matching keys are returned."""
data = {"title.extension_1.extension_2": "Bonjour", "id": 1}
result = utils.get_value_by_pattern(data, r"^title\.")
result = get_value_by_pattern(data, r"^title\.")
assert set(result) == {"Bonjour"}
@@ -237,6 +248,6 @@ def test_utils_get_value_by_pattern_multiple_extensions():
def test_utils_get_value_by_pattern_no_match():
"""Test that empty list is returned when no key matches the pattern."""
data = {"name": "Test", "id": 1}
result = utils.get_value_by_pattern(data, r"^title\.")
result = get_value_by_pattern(data, r"^title\.")
assert result == []

View File

@@ -0,0 +1,89 @@
"""Tests for the create_tree_node_with_retry utils."""
from unittest import mock
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import IntegrityError
import pytest
from core.factories import UserFactory
from core.models import Document
from core.utils.treebeard import _is_tree_path_collision, create_tree_node_with_retry
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"exc",
[
DjangoValidationError({"path": "not unique"}),
IntegrityError("impress_document_path_key"),
],
)
def test_utils_create_tree_node_with_retry_exceed_max_attempts(settings, exc):
"""Test exceeding the max attempts should reraise the exception."""
settings.TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS = 2
create_fn = mock.MagicMock()
create_fn.side_effect = exc
with (
pytest.raises(exc.__class__),
mock.patch(
"core.utils.treebeard._is_tree_path_collision"
) as mock__is_tree_path_collision,
):
mock__is_tree_path_collision.side_effect = _is_tree_path_collision
create_tree_node_with_retry(create_fn)
mock__is_tree_path_collision.assert_called()
assert mock__is_tree_path_collision.call_count == 2
assert create_fn.call_count == 2
@pytest.mark.parametrize(
"exc",
[
DjangoValidationError({"foo": "bar"}),
IntegrityError("not handled"),
],
)
def test_utils_create_tree_node_with_retry_exceed_exception_not_handled(settings, exc):
"""Test with an exception not handled should return reraise it immediatly."""
settings.TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS = 2
create_fn = mock.MagicMock()
create_fn.side_effect = exc
with (
pytest.raises(exc.__class__),
mock.patch(
"core.utils.treebeard._is_tree_path_collision"
) as mock__is_tree_path_collision,
):
mock__is_tree_path_collision.side_effect = _is_tree_path_collision
create_tree_node_with_retry(create_fn)
mock__is_tree_path_collision.assert_called()
assert mock__is_tree_path_collision.call_count == 1
assert create_fn.call_count == 1
def test_utils_create_tree_node_with_retry_success():
"""Test executing successfully the create_fn callback."""
user = UserFactory()
document = create_tree_node_with_retry(
lambda: Document.add_root(
creator=user,
title="success",
)
)
assert isinstance(document, Document)
assert document.title == "success"
assert document.path is not None

View File

@@ -2,7 +2,7 @@
Unit tests for the filter_root_paths utility function.
"""
from core.utils import filter_descendants
from core.utils.paths import filter_descendants
def test_utils_filter_descendants_success():

View File

@@ -4,7 +4,8 @@ from django.utils import timezone
import pytest
from core import factories, utils
from core import factories
from core.utils.users import users_sharing_documents_with
pytestmark = pytest.mark.django_db
@@ -54,7 +55,7 @@ def test_utils_users_sharing_documents_with():
doc_3_pierre_2.created_at = yesterday
doc_3_pierre_2.save()
shared_map = utils.users_sharing_documents_with(user)
shared_map = users_sharing_documents_with(user)
assert shared_map == {
pierre_1.id: last_week,

View File

@@ -1,170 +0,0 @@
"""Utils for the core app."""
import base64
import logging
import re
import time
from collections import defaultdict
from django.core.cache import cache
from django.db import models as db
from django.db.models import Subquery
import pycrdt
from bs4 import BeautifulSoup
from core import enums, models
logger = logging.getLogger(__name__)
def get_value_by_pattern(data, pattern):
"""
Get all values from keys matching a regex pattern in a dictionary.
Args:
data (dict): Source dictionary to search
pattern (str): Regex pattern to match against keys
Returns:
list: List of values for all matching keys, empty list if no matches
Example:
>>> get_value_by_pattern({"title.fr": "Bonjour", "id": 1}, r"^title\\.")
["Bonjour"]
>>> get_value_by_pattern({"title.fr": "Bonjour", "title.en": "Hello"}, r"^title\\.")
["Bonjour", "Hello"]
"""
regex = re.compile(pattern)
return [value for key, value in data.items() if regex.match(key)]
def get_ancestor_to_descendants_map(paths, steplen):
"""
Given a list of document paths, return a mapping of ancestor_path -> set of descendant_paths.
Each path is assumed to use materialized path format with fixed-length segments.
Args:
paths (list of str): List of full document paths.
steplen (int): Length of each path segment.
Returns:
dict[str, set[str]]: Mapping from ancestor path to its descendant paths (including itself).
"""
ancestor_map = defaultdict(set)
for path in paths:
for i in range(steplen, len(path) + 1, steplen):
ancestor = path[:i]
ancestor_map[ancestor].add(path)
return ancestor_map
def filter_descendants(paths, root_paths, skip_sorting=False):
"""
Filters paths to keep only those that are descendants of any path in root_paths.
A path is considered a descendant of a root path if it starts with the root path.
If `skip_sorting` is not set to True, the function will sort both lists before
processing because both `paths` and `root_paths` need to be in lexicographic order
before going through the algorithm.
Args:
paths (iterable of str): List of paths to be filtered.
root_paths (iterable of str): List of paths to check as potential prefixes.
skip_sorting (bool): If True, assumes both `paths` and `root_paths` are already sorted.
Returns:
list of str: A list of sorted paths that are descendants of any path in `root_paths`.
"""
results = []
i = 0
n = len(root_paths)
if not skip_sorting:
paths.sort()
root_paths.sort()
for path in paths:
# Try to find a matching prefix in the sorted accessible paths
while i < n:
if path.startswith(root_paths[i]):
results.append(path)
break
if root_paths[i] < path:
i += 1
else:
# If paths[i] > path, no need to keep searching
break
return results
def base64_yjs_to_xml(base64_string):
"""Extract xml from base64 yjs document."""
decoded_bytes = base64.b64decode(base64_string)
# uint8_array = bytearray(decoded_bytes)
doc = pycrdt.Doc()
doc.apply_update(decoded_bytes)
return str(doc.get("document-store", type=pycrdt.XmlFragment))
def base64_yjs_to_text(base64_string):
"""Extract text from base64 yjs document."""
blocknote_structure = base64_yjs_to_xml(base64_string)
soup = BeautifulSoup(blocknote_structure, "lxml-xml")
return soup.get_text(separator=" ", strip=True)
def extract_attachments(content):
"""Helper method to extract media paths from a document's content."""
if not content:
return []
xml_content = base64_yjs_to_xml(content)
return re.findall(enums.MEDIA_STORAGE_URL_EXTRACT, xml_content)
def get_users_sharing_documents_with_cache_key(user):
"""Generate a unique cache key for each user."""
return f"users_sharing_documents_with_{user.id}"
def users_sharing_documents_with(user):
"""
Returns a map of users sharing documents with the given user,
sorted by last shared date.
"""
start_time = time.time()
cache_key = get_users_sharing_documents_with_cache_key(user)
cached_result = cache.get(cache_key)
if cached_result is not None:
elapsed = time.time() - start_time
logger.info(
"users_sharing_documents_with cache hit for user %s (took %.3fs)",
user.id,
elapsed,
)
return cached_result
user_docs_qs = models.DocumentAccess.objects.filter(user=user).values_list(
"document_id", flat=True
)
shared_qs = (
models.DocumentAccess.objects.filter(document_id__in=Subquery(user_docs_qs))
.exclude(user=user)
.values("user")
.annotate(last_shared=db.Max("created_at"))
)
result = {item["user"]: item["last_shared"] for item in shared_qs}
cache.set(cache_key, result, 86400) # Cache for 1 day
elapsed = time.time() - start_time
logger.info(
"users_sharing_documents_with cache miss for user %s (took %.3fs)",
user.id,
elapsed,
)
return result

View File

@@ -0,0 +1 @@
"""Core utilities package."""

View File

@@ -0,0 +1,24 @@
"""Dictionary utility functions."""
import re
def get_value_by_pattern(data, pattern):
"""
Get all values from keys matching a regex pattern in a dictionary.
Args:
data (dict): Source dictionary to search
pattern (str): Regex pattern to match against keys
Returns:
list: List of values for all matching keys, empty list if no matches
Example:
>>> get_value_by_pattern({"title.fr": "Bonjour", "id": 1}, r"^title\\.")
["Bonjour"]
>>> get_value_by_pattern({"title.fr": "Bonjour", "title.en": "Hello"}, r"^title\\.")
["Bonjour", "Hello"]
"""
regex = re.compile(pattern)
return [value for key, value in data.items() if regex.match(key)]

View File

@@ -0,0 +1,63 @@
"""Path and tree structure utilities."""
from collections import defaultdict
def get_ancestor_to_descendants_map(paths, steplen):
"""
Given a list of document paths, return a mapping of ancestor_path -> set of descendant_paths.
Each path is assumed to use materialized path format with fixed-length segments.
Args:
paths (list of str): List of full document paths.
steplen (int): Length of each path segment.
Returns:
dict[str, set[str]]: Mapping from ancestor path to its descendant paths (including itself).
"""
ancestor_map = defaultdict(set)
for path in paths:
for i in range(steplen, len(path) + 1, steplen):
ancestor = path[:i]
ancestor_map[ancestor].add(path)
return ancestor_map
def filter_descendants(paths, root_paths, skip_sorting=False):
"""
Filters paths to keep only those that are descendants of any path in root_paths.
A path is considered a descendant of a root path if it starts with the root path.
If `skip_sorting` is not set to True, the function will sort both lists before
processing because both `paths` and `root_paths` need to be in lexicographic order
before going through the algorithm.
Args:
paths (iterable of str): List of paths to be filtered.
root_paths (iterable of str): List of paths to check as potential prefixes.
skip_sorting (bool): If True, assumes both `paths` and `root_paths` are already sorted.
Returns:
list of str: A list of sorted paths that are descendants of any path in `root_paths`.
"""
results = []
i = 0
n = len(root_paths)
if not skip_sorting:
paths.sort()
root_paths.sort()
for path in paths:
# Try to find a matching prefix in the sorted accessible paths
while i < n:
if path.startswith(root_paths[i]):
results.append(path)
break
if root_paths[i] < path:
i += 1
else:
# If paths[i] > path, no need to keep searching
break
return results

View File

@@ -0,0 +1,56 @@
"""Treebeard path collision handling utilities."""
import logging
from django.conf import settings
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import IntegrityError, transaction
logger = logging.getLogger(__name__)
def _is_tree_path_collision(exc):
"""Return True when `exc` is caused by a Document.path uniqueness conflict.
Treebeard computes the materialized path by reading the current siblings;
under concurrency two callers may compute the same value. Depending on
timing this surfaces either as:
- `django.core.exceptions.ValidationError` raised by `full_clean()` /
`validate_unique()` before the INSERT (BaseModel.save calls full_clean),
- or `IntegrityError` from the database unique index when the validate
step misses the conflict.
"""
if isinstance(exc, DjangoValidationError):
message_dict = getattr(exc, "message_dict", None)
if message_dict is not None:
return "path" in message_dict
return "path" in str(exc).lower()
# search in the IntegrityError exception
return "impress_document_path_key" in str(exc).lower()
def create_tree_node_with_retry(create_fn):
"""Run `create_fn` in a fresh atomic block, retrying on path collisions.
The Document.path field carries a unique constraint, which is the source of
truth that prevents duplicate paths. On collision we let the failed
transaction roll back, and call `create_fn` again so treebeard recomputes
the path from the latest state.
"""
max_attempts = settings.TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS
for attempt in range(max_attempts):
try:
with transaction.atomic():
return create_fn()
except (IntegrityError, DjangoValidationError) as exc:
if not _is_tree_path_collision(exc) or attempt == max_attempts - 1:
raise
logger.info(
"tree path collision on attempt %d/%d, retrying",
attempt + 1,
max_attempts,
)
raise RuntimeError("create_tree_node_with_retry exited without result")

View File

@@ -0,0 +1,55 @@
"""User sharing cache utilities."""
import logging
import time
from django.core.cache import cache
from django.db import models as db
from django.db.models import Subquery
from core import models
logger = logging.getLogger(__name__)
def get_users_sharing_documents_with_cache_key(user):
"""Generate a unique cache key for each user."""
return f"users_sharing_documents_with_{user.id}"
def users_sharing_documents_with(user):
"""
Returns a map of users sharing documents with the given user,
sorted by last shared date.
"""
start_time = time.time()
cache_key = get_users_sharing_documents_with_cache_key(user)
cached_result = cache.get(cache_key)
if cached_result is not None:
elapsed = time.time() - start_time
logger.info(
"users_sharing_documents_with cache hit for user %s (took %.3fs)",
user.id,
elapsed,
)
return cached_result
user_docs_qs = models.DocumentAccess.objects.filter(user=user).values_list(
"document_id", flat=True
)
shared_qs = (
models.DocumentAccess.objects.filter(document_id__in=Subquery(user_docs_qs))
.exclude(user=user)
.values("user")
.annotate(last_shared=db.Max("created_at"))
)
result = {item["user"]: item["last_shared"] for item in shared_qs}
cache.set(cache_key, result, 86400) # Cache for 1 day
elapsed = time.time() - start_time
logger.info(
"users_sharing_documents_with cache miss for user %s (took %.3fs)",
user.id,
elapsed,
)
return result

View File

@@ -0,0 +1,36 @@
"""Yjs document conversion utilities."""
import base64
import re
import pycrdt
from bs4 import BeautifulSoup
from core import enums
def base64_yjs_to_xml(base64_string):
"""Extract xml from base64 yjs document."""
decoded_bytes = base64.b64decode(base64_string)
doc = pycrdt.Doc()
doc.apply_update(decoded_bytes)
return str(doc.get("document-store", type=pycrdt.XmlFragment))
def base64_yjs_to_text(base64_string):
"""Extract text from base64 yjs document."""
blocknote_structure = base64_yjs_to_xml(base64_string)
soup = BeautifulSoup(blocknote_structure, "lxml-xml")
return soup.get_text(separator=" ", strip=True)
def extract_attachments(content):
"""Helper method to extract media paths from a document's content."""
if not content:
return []
xml_content = base64_yjs_to_xml(content)
return re.findall(enums.MEDIA_STORAGE_URL_EXTRACT, xml_content)

View File

@@ -161,8 +161,7 @@
},
"onboarding": {
"enabled": true,
"learn_more_url": "",
"ready_template_url": ""
"learn_more_url": ""
},
"help": {
"documentation_url": ""

View File

@@ -1081,6 +1081,12 @@ class Base(Configuration):
60 * 60 * 24, environ_name="CONTENT_METADATA_CACHE_TIMEOUT", environ_prefix=None
)
TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS = values.IntegerValue(
10,
environ_name="TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):

View File

@@ -688,23 +688,25 @@ test.describe('Doc Editor', () => {
test('it checks interlink feature', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-interlink', browserName, 1);
await verifyDocName(page, randomDoc);
const { name: docChild1 } = await createRootSubPage(
page,
browserName,
'doc-interlink-child-1',
);
await verifyDocName(page, docChild1);
const { name: docChild2 } = await createRootSubPage(
page,
browserName,
'doc-interlink-child-2',
);
await verifyDocName(page, docChild2);
const treeRow = await getTreeRow(page, docChild2);
// To let the time for the emoji-picker to load
await page.waitForTimeout(500);
await treeRow.locator('.--docs--doc-icon').click();
await page.getByRole('button', { name: '😀' }).first().click();

View File

@@ -104,9 +104,6 @@ test.describe('Doc Header', () => {
browserName,
1,
);
await writeInEditor({ page, text: 'Hello Content' });
await page.getByRole('button', { name: 'Share' }).click();
await updateShareLink(page, 'Public', 'Editing');
@@ -119,9 +116,7 @@ test.describe('Doc Header', () => {
docTitle,
});
await expect(otherPage.getByText('Hello Content')).toBeVisible();
// Wait for other page to broadcast sync
// Wait for other page to sync
await page.waitForTimeout(1000);
await page.keyboard.press('Escape');
@@ -129,8 +124,9 @@ test.describe('Doc Header', () => {
await expect(elTitle).toBeVisible();
await elTitle.fill('Hello World');
await elTitle.blur();
await verifyDocName(page, 'Hello World');
// Wait for other page to broadcast sync
// Wait for other page to sync
await page.waitForTimeout(1000);
// Check other user page
@@ -535,7 +531,7 @@ test.describe('Doc Header', () => {
browserName === 'webkit',
'navigator.clipboard is not working with webkit and playwright',
);
await mockedDocument(page, {
const uuid = await mockedDocument(page, {
abilities: {
destroy: false, // Means owner
link_configuration: true,
@@ -556,6 +552,7 @@ test.describe('Doc Header', () => {
name: 'Share',
exact: true,
});
await expect(shareButton).toBeVisible();
await shareButton.click();
await page.getByRole('button', { name: 'Copy link' }).click();
@@ -566,8 +563,8 @@ test.describe('Doc Header', () => {
);
const clipboardContent = await handle.jsonValue();
const url = page.url();
expect(clipboardContent.trim()).toMatch(url);
const origin = await page.evaluate(() => window.location.origin);
expect(clipboardContent.trim()).toMatch(`${origin}/docs/${uuid}/`);
});
test('it pins a document', async ({ page, browserName }) => {

View File

@@ -31,8 +31,6 @@ test.describe('Inherited share accesses', () => {
.getByRole('link')
.click();
await page.getByRole('button', { name: 'close' }).first().click();
await verifyDocName(page, parentTitle);
});

View File

@@ -185,23 +185,23 @@ test.describe('Doc Version', () => {
await page.getByLabel('Restore', { exact: true }).click();
const mainEditor = page.getByLabel('Document editor');
await page.waitForTimeout(500);
await expect(mainEditor.getByText('Hello')).toBeVisible();
await expect(mainEditor.getByText('World')).toBeHidden();
await expect(editor.getByText('Hello')).toBeVisible();
await expect(editor.getByText('World')).toBeHidden();
// The old comment is not restored
await expect(mainEditor.getByText('Hello')).toHaveCSS(
await expect(editor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
);
// We can add a new comment
await mainEditor.getByText('Hello').selectText();
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Add comment' }).click();
await thread.getByRole('paragraph').first().fill('This is a comment');
await thread.locator('[data-test="save"]').click();
await expect(mainEditor.getByText('Hello')).toHaveClass('bn-thread-mark');
await expect(editor.getByText('Hello')).toHaveClass('bn-thread-mark');
});
});

View File

@@ -153,8 +153,7 @@ test.describe('Help feature', () => {
theme_customization: {
onboarding: {
enabled: true,
learn_more_url: 'http://localhost:3000/learn-more',
ready_template_url: 'http://localhost:3000/ready-template',
learn_more_url: 'https://example.com/learn-more',
},
},
});
@@ -185,19 +184,18 @@ test.describe('Help feature', () => {
'0',
);
const step3 = page.getByTestId('onboarding-step-3');
await step3.click();
await expect(step3).toHaveAttribute('tabindex', '0');
await expect(
step3.getByRole('link', { name: 'ready-made template' }),
).toHaveAttribute('href', 'http://localhost:3000/ready-template');
await page.getByTestId('onboarding-step-3').click();
await expect(page.getByTestId('onboarding-step-3')).toHaveAttribute(
'tabindex',
'0',
);
const learnMoreLink = page.getByRole('link', {
name: 'Learn more docs features',
});
await expect(learnMoreLink).toHaveAttribute(
'href',
'http://localhost:3000/learn-more',
'https://example.com/learn-more',
);
await learnMoreLink.click();
@@ -243,16 +241,6 @@ test.describe('Help feature', () => {
await expect(
modal.getByRole('button', { name: /Suivant/i }),
).toBeVisible();
await modal
.getByText(/Tirez parti de la bibliothèque de contenu/)
.first()
.click();
await expect(
modal.getByText(/Commencez à partir de/).first(),
).toBeVisible();
await expect(modal.getByRole('link')).toHaveText(
"modèles prêts à l'emploi",
);
});
test('Modal is displayed automatically on first connection', async ({

View File

@@ -131,64 +131,42 @@ export const createDoc = async (
await openHeaderMenu(page);
}
const responsePromiseCreateDoc = page.waitForResponse(
(response) =>
response.url().includes('/api/v1.0/documents/') &&
response.status() === 201 &&
response.request().method() === 'POST',
);
await page
.getByRole('button', {
name: 'New doc',
})
.click();
await page.waitForURL('**/docs/**', {
timeout: 10000,
waitUntil: 'networkidle',
});
const responseCreateDoc = await responsePromiseCreateDoc;
expect(responseCreateDoc.ok()).toBeTruthy();
const { id: docId } = (await responseCreateDoc.json()) as { id: string };
const responsePromiseUpdateDoc = page.waitForResponse(
(response) =>
response.url().includes(`/api/v1.0/documents/${docId}`) &&
response.status() === 200 &&
response.request().method() === 'PATCH',
);
const input = page.getByLabel('Document title');
await expect(input).toBeVisible({
timeout: 10000,
});
await expect(input).toHaveText('', {
timeout: 10000,
});
await expect(input).toHaveText('');
await input.fill(randomDocs[i]);
void input.blur();
const responseUpdateDoc = await responsePromiseUpdateDoc;
expect(responseUpdateDoc.ok()).toBeTruthy();
await input.blur();
}
return randomDocs;
};
export const verifyDocName = async (page: Page, docName: string) => {
const card = page.getByLabel(
'It is the card information about the document.',
);
await expect(card).toBeVisible({
await expect(
page.getByLabel('It is the card information about the document.'),
).toBeVisible({
timeout: 10000,
});
await expect(card).toHaveText(new RegExp(docName), {
timeout: 10000,
});
/*replace toHaveText with toContainText to handle cases where emojis or other characters might be added*/
try {
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toContainText(docName, {
timeout: 3000,
});
} catch {
await expect(page.getByRole('heading', { name: docName })).toBeVisible();
}
};
export const getGridRow = async (page: Page, title: string) => {
@@ -250,9 +228,11 @@ export const updateDocTitle = async (page: Page, title: string) => {
const input = page.getByRole('textbox', { name: 'Document title' });
await expect(input).toHaveText('');
await expect(input).toBeVisible();
await input.click();
await input.fill(title, {
force: true,
});
await input.click();
await input.blur();
await verifyDocName(page, title);
};
@@ -268,11 +248,10 @@ export const waitForResponseCreateDoc = (page: Page) => {
export const mockedDocument = async (page: Page, data: object) => {
// document/[ID]/ or document/[ID]/tree/ routes
let uuid: string | undefined;
const uuid = crypto.randomUUID();
await page.route(/.*\/documents\/[^/]+\/(?:$|tree\/.*)/, async (route) => {
const request = route.request();
if (request.method().includes('GET') && !request.url().includes('page=')) {
uuid = request.url().match(/\/documents\/([^/]+)\//)?.[1];
const { abilities, ...doc } = data as unknown as {
abilities?: Record<string, unknown>;
};

View File

@@ -15,7 +15,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.59.1",
"@playwright/test": "1.58.2",
"@types/node": "*",
"@types/pdf-parse": "1.1.5",
"eslint-plugin-docs": "*",
@@ -24,7 +24,7 @@
"dependencies": {
"@types/pngjs": "6.0.5",
"convert-stream": "1.0.2",
"dotenv": "17.4.2",
"dotenv": "17.3.1",
"pdf-parse": "2.4.5",
"pixelmatch": "7.1.0",
"pngjs": "7.0.0"

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es2020",
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,

View File

@@ -23,59 +23,59 @@
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.3",
"@ai-sdk/openai": "3.0.53",
"@blocknote/code-block": "0.49.0",
"@blocknote/core": "0.49.0",
"@blocknote/mantine": "0.49.0",
"@blocknote/react": "0.49.0",
"@blocknote/xl-ai": "0.49.0",
"@blocknote/xl-docx-exporter": "0.49.0",
"@blocknote/xl-multi-column": "0.49.0",
"@blocknote/xl-odt-exporter": "0.49.0",
"@blocknote/xl-pdf-exporter": "0.49.0",
"@ai-sdk/openai": "3.0.47",
"@blocknote/code-block": "0.47.3",
"@blocknote/core": "0.47.3",
"@blocknote/mantine": "0.47.3",
"@blocknote/react": "0.47.3",
"@blocknote/xl-ai": "0.47.3",
"@blocknote/xl-docx-exporter": "0.47.3",
"@blocknote/xl-multi-column": "0.47.3",
"@blocknote/xl-odt-exporter": "0.47.3",
"@blocknote/xl-pdf-exporter": "0.47.3",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@fontsource-variable/inter": "5.2.8",
"@fontsource-variable/material-symbols-outlined": "5.2.42",
"@fontsource-variable/material-symbols-outlined": "5.2.38",
"@fontsource/material-icons": "5.2.7",
"@gouvfr-lasuite/cunningham-react": "4.3.0",
"@gouvfr-lasuite/cunningham-react": "4.2.0",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.20.1",
"@gouvfr-lasuite/ui-kit": "0.19.10",
"@hocuspocus/provider": "3.4.4",
"@mantine/core": "9.0.2",
"@mantine/hooks": "9.0.2",
"@react-aria/live-announcer": "3.5.0",
"@mantine/core": "8.3.18",
"@mantine/hooks": "8.3.18",
"@react-aria/live-announcer": "3.4.4",
"@react-pdf/renderer": "4.3.1",
"@sentry/nextjs": "10.49.0",
"@tanstack/react-query": "5.99.2",
"@sentry/nextjs": "10.45.0",
"@tanstack/react-query": "5.95.0",
"@tiptap/extensions": "*",
"ai": "6.0.168",
"ai": "6.0.134",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"crisp-sdk-web": "1.1.1",
"crisp-sdk-web": "1.0.27",
"emoji-datasource-apple": "16.0.0",
"emoji-mart": "5.6.0",
"emoji-regex": "10.6.0",
"i18next": "26.0.6",
"i18next": "25.10.4",
"i18next-browser-languagedetector": "8.2.1",
"idb": "8.0.3",
"lodash": "4.18.1",
"luxon": "3.7.2",
"next": "16.2.4",
"posthog-js": "1.369.4",
"next": "16.2.3",
"posthog-js": "1.363.1",
"react": "*",
"react-aria-components": "1.17.0",
"react-aria-components": "1.16.0",
"react-dom": "*",
"react-dropzone": "15.0.0",
"react-i18next": "17.0.4",
"react-i18next": "16.6.1",
"react-intersection-observer": "10.0.3",
"react-resizable-panels": "3.0.6",
"react-select": "5.10.2",
"styled-components": "6.4.0",
"use-debounce": "10.1.1",
"styled-components": "6.3.12",
"use-debounce": "10.1.0",
"uuid": "14.0.0",
"y-protocols": "1.0.7",
"yjs": "*",
@@ -84,7 +84,7 @@
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.99.2",
"@tanstack/react-query-devtools": "5.95.0",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
@@ -96,18 +96,18 @@
"@types/react-dom": "*",
"@vitejs/plugin-react": "6.0.1",
"cross-env": "10.1.0",
"dotenv": "17.4.2",
"dotenv": "17.3.1",
"eslint-plugin-docs": "*",
"fetch-mock": "9.11.0",
"jsdom": "29.0.2",
"jsdom": "29.0.1",
"node-fetch": "2.7.0",
"prettier": "3.8.3",
"prettier": "3.8.1",
"stylelint": "16.26.1",
"stylelint-config-standard": "39.0.1",
"stylelint-prettier": "5.0.3",
"typescript": "*",
"vitest": "4.1.4",
"webpack": "5.106.2",
"vitest": "4.1.0",
"webpack": "5.105.4",
"workbox-webpack-plugin": "7.1.0"
},
"packageManager": "yarn@1.22.22"

View File

@@ -1,13 +1,12 @@
import { ComponentPropsWithRef, Ref, forwardRef } from 'react';
import { Ref, forwardRef } from 'react';
import { css } from 'styled-components';
import { Box, BoxProps } from './Box';
import { Box, BoxType } from './Box';
export type BoxButtonType = BoxProps &
Omit<ComponentPropsWithRef<'button'>, keyof BoxProps | 'ref'> & {
disabled?: boolean;
ref?: Ref<HTMLButtonElement>;
};
export type BoxButtonType = Omit<BoxType, 'ref'> & {
disabled?: boolean;
ref?: Ref<HTMLButtonElement>;
};
/**
* Styleless button that extends the Box component.
@@ -60,7 +59,7 @@ const BoxButton = forwardRef<HTMLButtonElement, BoxButtonType>(
if (disabled) {
return;
}
props.onClick?.(event);
props.onClick?.(event as unknown as React.MouseEvent<HTMLDivElement>);
}}
/>
);

View File

@@ -34,7 +34,9 @@ export const TextStyled = styled(Box)<TextProps>`
const Text = forwardRef<HTMLElement, ComponentPropsWithRef<typeof TextStyled>>(
(props, ref) => {
return <TextStyled ref={ref} as="span" {...props} />;
return (
<TextStyled ref={ref as React.Ref<HTMLDivElement>} as="span" {...props} />
);
},
);

View File

@@ -2,7 +2,7 @@ import {
Button,
ButtonProps,
Modal,
ModalDefaultVariantProps,
ModalProps,
ModalSize,
} from '@gouvfr-lasuite/cunningham-react';
import { ReactNode, useEffect } from 'react';
@@ -20,7 +20,7 @@ export type AlertModalProps = {
title: string;
cancelLabel?: string;
confirmLabel?: string;
} & Partial<ModalDefaultVariantProps>;
} & Partial<ModalProps>;
export const AlertModal = ({
cancelLabel,

View File

@@ -49,7 +49,7 @@ export const SideModal = ({
return (
<>
<SideModalStyle width={width} side={side} $css={$css} />
<Modal {...modalProps} size={ModalSize.FULL} variant="default">
<Modal {...modalProps} size={ModalSize.FULL}>
{children}
</Modal>
</>

View File

@@ -28,7 +28,6 @@ interface ThemeCustomization {
onboarding?: {
enabled: true;
learn_more_url?: string;
ready_template_url?: string;
};
translations?: Resource;
waffle?: WaffleType;

View File

@@ -70,7 +70,6 @@
.c__modal__title {
padding: 0;
display: block;
}
.c__modal__footer {

View File

@@ -361,7 +361,7 @@
--c--globals--font--weights--medium: 500;
--c--globals--font--weights--bold: 600;
--c--globals--font--weights--extrabold: 800;
--c--globals--font--weights--black: 800;
--c--globals--font--weights--black: 900;
--c--globals--font--families--base:
inter variable, roboto flex variable, sans-serif;
--c--globals--font--families--accent:
@@ -849,18 +849,6 @@
--c--components--forms-checkbox--font-size: var(
--c--globals--font--sizes--sm
);
--c--components--forms-input--border-radius: 4px;
--c--components--forms-input--border-radius--hover: 4px;
--c--components--forms-input--border-radius--focus: 4px;
--c--components--forms-select--border-radius: 4px;
--c--components--forms-select--border-radius--hover: 4px;
--c--components--forms-select--border-radius--focus: 4px;
--c--components--forms-textarea--border-radius: 4px;
--c--components--forms-textarea--border-radius--hover: 4px;
--c--components--forms-textarea--border-radius--focus: 4px;
--c--components--forms-datepicker--border-radius: 4px;
--c--components--forms-datepicker--border-radius--hover: 4px;
--c--components--forms-datepicker--border-radius--focus: 4px;
--c--components--badge--font-size: var(--c--globals--font--sizes--xs);
--c--components--badge--border-radius: 12px;
--c--components--badge--padding-inline: var(--c--globals--spacings--xs);
@@ -1743,6 +1731,7 @@
--c--globals--font--sizes--xs-alt: 3rem;
--c--globals--font--weights--thin: 100;
--c--globals--font--weights--extrabold: 800;
--c--globals--font--weights--black: 900;
--c--globals--font--families--accent:
marianne, inter variable, roboto flex variable, sans-serif;
--c--globals--font--families--base:
@@ -2550,18 +2539,6 @@
--c--components--forms-checkbox--font-size: var(
--c--globals--font--sizes--sm
);
--c--components--forms-input--border-radius: 4px;
--c--components--forms-input--border-radius--hover: 4px;
--c--components--forms-input--border-radius--focus: 4px;
--c--components--forms-select--border-radius: 4px;
--c--components--forms-select--border-radius--hover: 4px;
--c--components--forms-select--border-radius--focus: 4px;
--c--components--forms-textarea--border-radius: 4px;
--c--components--forms-textarea--border-radius--hover: 4px;
--c--components--forms-textarea--border-radius--focus: 4px;
--c--components--forms-datepicker--border-radius: 4px;
--c--components--forms-datepicker--border-radius--hover: 4px;
--c--components--forms-datepicker--border-radius--focus: 4px;
--c--components--badge--font-size: var(--c--globals--font--sizes--xs);
--c--components--badge--border-radius: 12px;
--c--components--badge--padding-inline: var(--c--globals--spacings--xs);

View File

@@ -372,7 +372,7 @@ export const tokens = {
medium: 500,
bold: 600,
extrabold: 800,
black: 800,
black: 900,
},
families: {
base: 'Inter Variable, Roboto Flex Variable, sans-serif',
@@ -664,26 +664,6 @@ export const tokens = {
'body--background-color-hover': '#F0F0F3',
},
'forms-checkbox': { 'font-size': '0.875rem' },
'forms-input': {
'border-radius': '4px',
'border-radius--hover': '4px',
'border-radius--focus': '4px',
},
'forms-select': {
'border-radius': '4px',
'border-radius--hover': '4px',
'border-radius--focus': '4px',
},
'forms-textarea': {
'border-radius': '4px',
'border-radius--hover': '4px',
'border-radius--focus': '4px',
},
'forms-datepicker': {
'border-radius': '4px',
'border-radius--hover': '4px',
'border-radius--focus': '4px',
},
badge: {
'font-size': '0.75rem',
'border-radius': '12px',
@@ -1354,7 +1334,7 @@ export const tokens = {
'sm-alt': '3.5rem',
'xs-alt': '3rem',
},
weights: { thin: 100, extrabold: 800 },
weights: { thin: 100, extrabold: 800, black: 900 },
families: {
accent:
'Marianne, Inter Variable, Roboto Flex Variable, sans-serif',
@@ -1968,26 +1948,6 @@ export const tokens = {
'body--background-color-hover': '#F0F0F3',
},
'forms-checkbox': { 'font-size': '0.875rem' },
'forms-input': {
'border-radius': '4px',
'border-radius--hover': '4px',
'border-radius--focus': '4px',
},
'forms-select': {
'border-radius': '4px',
'border-radius--hover': '4px',
'border-radius--focus': '4px',
},
'forms-textarea': {
'border-radius': '4px',
'border-radius--hover': '4px',
'border-radius--focus': '4px',
},
'forms-datepicker': {
'border-radius': '4px',
'border-radius--hover': '4px',
'border-radius--focus': '4px',
},
badge: {
'font-size': '0.75rem',
'border-radius': '12px',

View File

@@ -35,7 +35,7 @@ const initialState: ThemeStore = {
colorsTokens: defaultTokens.globals.colors,
componentTokens: defaultTokens.components,
contextualTokens: defaultTokens.contextuals,
currentTokens: tokens.themes[DEFAULT_THEME],
currentTokens: tokens.themes[DEFAULT_THEME] as Partial<Tokens>,
fontSizesTokens: defaultTokens.globals.font.sizes,
setTheme: () => {},
spacingsTokens: defaultTokens.globals.spacings,

View File

@@ -1,13 +1,12 @@
import React, { ComponentPropsWithRef } from 'react';
import React from 'react';
import { Box, BoxProps } from '@/components';
import { Box, BoxType } from '@/components';
type AvatarSvgProps = BoxProps &
Omit<ComponentPropsWithRef<'svg'>, keyof BoxProps> & {
initials: string;
background: string;
fontFamily?: string;
};
type AvatarSvgProps = {
initials: string;
background: string;
fontFamily?: string;
} & BoxType;
export const AvatarSvg: React.FC<AvatarSvgProps> = ({
initials,

View File

@@ -11,15 +11,11 @@ vi.mock('@/stores', () => ({
useResponsiveStore: () => ({ isDesktop: false }),
}));
vi.mock('@/features/skeletons', async () => {
const actual = await vi.importActual<any>('../../../skeletons');
return {
...actual,
useSkeletonStore: () => ({
setIsSkeletonVisible: vi.fn(),
}),
};
});
vi.mock('@/features/skeletons', () => ({
useSkeletonStore: () => ({
setIsSkeletonVisible: vi.fn(),
}),
}));
vi.mock('../../doc-management', async () => {
const actual = await vi.importActual<any>('../../doc-management');

View File

@@ -15,6 +15,7 @@ import { useCreateBlockNote } from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
import { useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import type { Awareness } from 'y-protocols/awareness';
import * as Y from 'yjs';
@@ -34,14 +35,14 @@ import {
useUploadStatus,
} from '../hook';
import { useEditorStore } from '../stores';
import { DocsEditorStyle } from '../styles';
import { cssEditor } from '../styles';
import { DocsBlockNoteEditor } from '../types';
import { randomColor } from '../utils';
import BlockNoteAI from './AI';
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
import { DocsCommentsStyle, useComments } from './comments/';
import { cssComments, useComments } from './comments/';
import {
AccessibleImageBlock,
CalloutBlock,
@@ -259,12 +260,13 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
}, [setEditor, editor]);
return (
<Box ref={refEditorContainer} $height="100%">
<DocsEditorStyle />
<DocsCommentsStyle
canSeeComment={canSeeComment}
currentUserAvatarUrl={currentUserAvatarUrl}
/>
<Box
ref={refEditorContainer}
$css={css`
${cssEditor};
${cssComments(showComments, currentUserAvatarUrl)}
`}
>
{errorAttachment && (
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
<TextErrors
@@ -348,9 +350,12 @@ export const BlockNoteReader = ({
useHeadings(editor);
return (
<Box>
<DocsEditorStyle />
<DocsCommentsStyle canSeeComment={false} />
<Box
$css={css`
${cssEditor};
${cssComments(false)}
`}
>
<BlockNoteView
className="--docs--main-editor"
editor={editor}

View File

@@ -1,41 +1,37 @@
import clsx from 'clsx';
import { PropsWithChildren, useEffect, useState } from 'react';
import { css } from 'styled-components';
import { useEffect, useState } from 'react';
import { Box } from '@/components';
import { Box, Loading } from '@/components';
import { DocHeader } from '@/docs/doc-header/';
import {
Doc,
LinkReach,
getDocLinkReach,
useCollaboration,
useIsCollaborativeEditable,
useProviderStore,
} from '@/docs/doc-management';
import { TableContent } from '@/docs/doc-table-content/';
import { useAuth } from '@/features/auth/';
import { SkeletonEditorCore, useSkeletonStore } from '@/features/skeletons';
import { useSkeletonFadeOut } from '@/features/skeletons/hooks/useFadeOut';
import { useSkeletonStore } from '@/features/skeletons';
import { useAnalytics } from '@/libs';
import { useResponsiveStore } from '@/stores';
import { useCollaboration } from '../hook/useCollaboration';
import { BlockNoteEditor, BlockNoteReader } from './BlockNoteEditor';
const DOCS_EDITOR_CLASS = '--docs--doc-editor';
interface DocEditorContainerProps {
docHeader: React.ReactNode;
docEditor: React.ReactNode;
isDeletedDoc: boolean;
readOnly: boolean;
}
export const DocEditorContainer = ({
children,
docHeader,
docEditor,
isDeletedDoc,
readOnly,
}: PropsWithChildren<DocEditorContainerProps>) => {
}: DocEditorContainerProps) => {
const { isDesktop } = useResponsiveStore();
return (
@@ -43,8 +39,8 @@ export const DocEditorContainer = ({
<Box
$maxWidth="868px"
$width="100%"
$flex="1"
className={DOCS_EDITOR_CLASS}
$height="100%"
className="--docs--doc-editor"
>
<Box
$padding={{ horizontal: isDesktop ? '54px' : 'base' }}
@@ -70,7 +66,7 @@ export const DocEditorContainer = ({
})}
$height="100%"
>
{children}
{docEditor}
</Box>
</Box>
</Box>
@@ -86,19 +82,23 @@ interface DocEditorProps {
export const DocEditor = ({ doc }: DocEditorProps) => {
useCollaboration(doc.id);
const { isDesktop } = useResponsiveStore();
const { provider, isReady } = useProviderStore();
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
const isDeletedDoc = !!doc.deleted_at;
const readOnly =
!doc.abilities.partial_update || !isEditable || isLoading || isDeletedDoc;
const { setIsSkeletonVisible } = useSkeletonStore();
const isProviderReady = isReady && provider;
const { trackEvent } = useAnalytics();
const [hasTracked, setHasTracked] = useState(false);
const { authenticated } = useAuth();
const isPublicDoc = getDocLinkReach(doc) === LinkReach.PUBLIC;
const { setIsSkeletonVisible } = useSkeletonStore();
useEffect(() => {
setIsSkeletonVisible(false);
}, [setIsSkeletonVisible, doc.id]);
if (isProviderReady) {
setIsSkeletonVisible(false);
}
}, [isProviderReady, setIsSkeletonVisible]);
/**
* Track doc view event only once per doc change
@@ -124,57 +124,30 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
});
}, [authenticated, hasTracked, isPublicDoc, trackEvent]);
if (!isProviderReady || provider?.configuration.name !== doc.id) {
return <Loading />;
}
return (
<>
{isDesktop && <TableContent selector={`.${DOCS_EDITOR_CLASS}`} />}
{isDesktop && <TableContent />}
<DocEditorContainer
docHeader={<DocHeader doc={doc} />}
docEditor={
readOnly ? (
<BlockNoteReader
initialContent={provider.document.getXmlFragment(
'document-store',
)}
docId={doc.id}
/>
) : (
<BlockNoteEditor doc={doc} provider={provider} />
)
}
isDeletedDoc={isDeletedDoc}
readOnly={readOnly}
>
<DocCoreEditor doc={doc} readOnly={readOnly} />
</DocEditorContainer>
/>
</>
);
};
interface DocCoreEditorProps {
doc: Doc;
readOnly: boolean;
}
export const DocCoreEditor = ({ doc, readOnly }: DocCoreEditorProps) => {
useCollaboration(doc.id);
const { provider, isReady } = useProviderStore();
const isProviderReady = isReady && provider;
const showContent = !!(
isProviderReady && provider?.configuration.name === doc.id
);
const { skeletonVisible, isFadingOut } = useSkeletonFadeOut(showContent);
if (
skeletonVisible ||
!isProviderReady ||
provider?.configuration.name !== doc.id
) {
return (
<SkeletonEditorCore
isFadingOut={isFadingOut}
$css={css`
padding-top: 0px;
`}
/>
);
}
if (readOnly) {
return (
<BlockNoteReader
initialContent={provider.document.getXmlFragment('document-store')}
docId={doc.id}
/>
);
}
return <BlockNoteEditor doc={doc} provider={provider} />;
};

View File

@@ -67,7 +67,7 @@ export class DocsThreadStore extends ThreadStore {
continue;
}
const state:
const state = states.get(clientId) as
| {
[DocsThreadStore.COMMENTS_PING]?: {
at: number;
@@ -76,7 +76,7 @@ export class DocsThreadStore extends ThreadStore {
threadId: string;
};
}
| undefined = states.get(clientId);
| undefined;
const ping = state?.commentsPing;

View File

@@ -1,11 +1,11 @@
import { createGlobalStyle, css } from 'styled-components';
import { css } from 'styled-components';
export const DocsCommentsStyle = createGlobalStyle<{
canSeeComment: boolean;
currentUserAvatarUrl?: string;
}>`
.--docs--main-editor.bn-root,
.--docs--main-editor.bn-root .ProseMirror {
export const cssComments = (
canSeeComment: boolean,
currentUserAvatarUrl?: string,
) => css`
& .--docs--main-editor,
& .--docs--main-editor .ProseMirror {
// Comments marks in the editor
.bn-editor {
// Resets blocknote comments styles
@@ -14,31 +14,30 @@ export const DocsCommentsStyle = createGlobalStyle<{
background-color: transparent;
}
${({ canSeeComment }) =>
canSeeComment &&
css`
.bn-thread-mark:not([data-orphan='true']) {
background-color: color-mix(
in srgb,
var(--c--contextuals--background--palette--yellow--tertiary) 40%,
transparent
${canSeeComment &&
css`
.bn-thread-mark:not([data-orphan='true']) {
background-color: color-mix(
in srgb,
var(--c--contextuals--background--palette--yellow--tertiary) 40%,
transparent
);
border-bottom: 2px solid
var(--c--contextuals--background--palette--yellow--secondary);
mix-blend-mode: multiply;
transition:
background-color var(--c--globals--transitions--duration),
border-bottom-color var(--c--globals--transitions--duration);
&:has(.bn-thread-mark-selected) {
background-color: var(
--c--contextuals--background--palette--yellow--tertiary
);
border-bottom: 2px solid
var(--c--contextuals--background--palette--yellow--secondary);
mix-blend-mode: multiply;
transition:
background-color var(--c--globals--transitions--duration),
border-bottom-color var(--c--globals--transitions--duration);
&:has(.bn-thread-mark-selected) {
background-color: var(
--c--contextuals--background--palette--yellow--tertiary
);
}
}
`}
}
`}
[data-show-selection] {
color: HighlightText;
@@ -83,8 +82,6 @@ export const DocsCommentsStyle = createGlobalStyle<{
.bn-thread-comment {
padding: 8px;
flex-wrap: nowrap;
gap: 0px;
& .bn-editor {
padding-left: var(--c--globals--spacings--lg);
@@ -108,14 +105,10 @@ export const DocsCommentsStyle = createGlobalStyle<{
// Top bar (Name / Date / Actions) when actions displayed
&:has(.bn-comment-actions) {
& > .mantine-Group-root:first-child {
& > .mantine-Group-root {
max-width: 70%;
right: 0.3rem !important;
top: 0.3rem !important;
background: linear-gradient(
to left,
#fff 90%,
rgba(255, 255, 255, 0) 100%
);
}
.bn-menu-dropdown {
@@ -131,6 +124,7 @@ export const DocsCommentsStyle = createGlobalStyle<{
// Date
span.mantine-focus-auto {
display: inline-block;
}
.bn-comment-actions {
@@ -156,8 +150,7 @@ export const DocsCommentsStyle = createGlobalStyle<{
}
// Actions button edit comment
.bn-root + .bn-comment-actions-wrapper {
margin-top: var(--c--globals--spacings--2xs);
.bn-container + .bn-comment-actions-wrapper {
.bn-comment-actions {
flex-direction: row-reverse;
background: none;
@@ -208,8 +201,9 @@ export const DocsCommentsStyle = createGlobalStyle<{
width: 26px;
height: 26px;
flex: 0 0 26px;
background-image: ${({ currentUserAvatarUrl }) =>
currentUserAvatarUrl ? `url("${currentUserAvatarUrl}")` : 'none'};
background-image: ${currentUserAvatarUrl
? `url("${currentUserAvatarUrl}")`
: 'none'};
background-position: center;
background-repeat: no-repeat;
background-size: cover;

View File

@@ -41,7 +41,7 @@ export const LinkSelected = ({
const router = useRouter();
const href = `/docs/${docId}/`;
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
// If ctrl or command is pressed, it opens a new tab. If shift is pressed, it opens a new window
@@ -53,7 +53,7 @@ export const LinkSelected = ({
};
// This triggers on middle-mouse click
const handleAuxClick = (e: React.MouseEvent<HTMLButtonElement>) => {
const handleAuxClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.button !== 1) {
return;
}

View File

@@ -39,13 +39,7 @@ const withMultiColumnNoDropHandler = <
});
};
type ModulesXL =
| (Omit<typeof XLMultiColumn, 'withMultiColumn'> & {
withMultiColumn: typeof withMultiColumnNoDropHandler;
})
| undefined;
let modulesXL: ModulesXL = undefined;
let modulesXL = undefined;
if (process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false') {
modulesXL = {
...XLMultiColumn,
@@ -53,4 +47,10 @@ if (process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false') {
};
}
export default modulesXL;
type ModulesXL =
| (Omit<typeof XLMultiColumn, 'withMultiColumn'> & {
withMultiColumn: typeof withMultiColumnNoDropHandler;
})
| undefined;
export default modulesXL as ModulesXL;

View File

@@ -1,306 +1,266 @@
import { createGlobalStyle } from 'styled-components';
import { css } from 'styled-components';
export const DocsEditorStyle = createGlobalStyle`
.bn-root {
export const cssEditor = css`
.mantine-Menu-itemLabel,
.mantine-Button-label {
font-family: var(--c--components--button--font-family);
}
&,
& > .bn-container,
& .ProseMirror {
height: 100%;
}
.bn-editor {
height: 100%;
}
.mantine-Menu-itemLabel,
.mantine-Button-label {
font-family: var(--c--components--button--font-family);
}
/**
* Token Mantine
*/
/**
* Token Mantime
*/
& > .bn-container {
--bn-colors-editor-text: var(
--c--contextuals--content--semantic--neutral--primary
);
--bn-colors-side-menu: var(
--c--contextuals--content--semantic--neutral--tertiary
);
}
/**
* Ensure long placeholder text is truncated with ellipsis
*/
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: inherit;
height: inherit;
}
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child) {
position: relative;
}
/**
* Ensure long placeholder text is truncated with ellipsis
*/
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: inherit;
height: inherit;
}
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child) {
position: relative;
}
/**
* Ensure images with unsafe URLs are not interactive
*/
img.bn-visual-media[src*='-unsafe'] {
pointer-events: none;
}
/**
* Ensure images with unsafe URLs are not interactive
*/
img.bn-visual-media[src*='-unsafe'] {
pointer-events: none;
}
/**
* Collaboration cursor styles
*/
.collaboration-cursor-custom__base {
position: relative;
}
.collaboration-cursor-custom__caret {
position: absolute;
height: 100%;
width: 2px;
bottom: 4%;
left: -1px;
}
/**
* Collaboration cursor styles
*/
.collaboration-cursor-custom__base {
position: relative;
}
.collaboration-cursor-custom__caret {
position: absolute;
height: 100%;
width: 2px;
bottom: 4%;
left: -1px;
}
.collaboration-cursor-custom__label {
color: #0d0d0d;
font-size: 12px;
font-weight: 600;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
position: absolute;
top: -17px;
left: 0px;
padding: 0px 6px;
border-radius: 0px;
white-space: nowrap;
transition: clip-path 0.3s ease-in-out;
border-radius: 4px 4px 4px 0;
box-shadow: inset -2px 2px 6px #ffffff00;
clip-path: polygon(0 85%, 4% 85%, 4% 100%, 0% 100%);
}
.collaboration-cursor-custom__base[data-active]
.collaboration-cursor-custom__label {
color: #0d0d0d;
font-size: 12px;
font-weight: 600;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
position: absolute;
top: -17px;
left: 0px;
padding: 0px 6px;
border-radius: 0px;
white-space: nowrap;
transition: clip-path 0.3s ease-in-out;
border-radius: 4px 4px 4px 0;
box-shadow: inset -2px 2px 6px #ffffff00;
clip-path: polygon(0 85%, 4% 85%, 4% 100%, 0% 100%);
}
.collaboration-cursor-custom__base[data-active]
.collaboration-cursor-custom__label {
pointer-events: none;
box-shadow: inset -2px 2px 6px #ffffff88;
clip-path: polygon(0 0, 100% 0%, 100% 100%, 0% 100%);
}
pointer-events: none;
box-shadow: inset -2px 2px 6px #ffffff88;
clip-path: polygon(0 0, 100% 0%, 100% 100%, 0% 100%);
}
/**
* Side menu
*/
.bn-side-menu .mantine-UnstyledButton-root svg {
color: var(
--c--contextuals--content--semantic--neutral--tertiary
) !important;
}
/**
* Side menu
*/
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 54px;
}
.bn-side-menu[data-block-type='heading'][data-level='2'] {
height: 43px;
}
.bn-side-menu[data-block-type='heading'][data-level='3'] {
height: 35px;
}
.bn-side-menu[data-block-type='divider'] {
height: 38px;
}
.bn-side-menu .mantine-UnstyledButton-root svg {
color: var(
--c--contextuals--content--semantic--neutral--tertiary
) !important;
}
/**
* Callout, Paragraph and Heading blocks
*/
.bn-block {
border-radius: var(--c--globals--spacings--3xs);
}
.bn-block-outer {
border-radius: var(--c--globals--spacings--3xs);
}
.bn-block > .bn-block-content[data-background-color] {
padding: var(--c--globals--spacings--3xs) var(--c--globals--spacings--3xs);
border-radius: var(--c--globals--spacings--3xs);
}
.bn-block-content[data-content-type='checkListItem'][data-checked='true']
.bn-inline-content {
text-decoration: none;
}
a {
color: var(--c--globals--colors--gray-600);
cursor: pointer;
}
/**
* Callout, Paragraph and Heading blocks
*/
.bn-block {
border-radius: var(--c--globals--spacings--3xs);
}
.bn-block-outer {
border-radius: var(--c--globals--spacings--3xs);
}
.bn-block > .bn-block-content[data-background-color] {
padding: var(--c--globals--spacings--3xs) var(--c--globals--spacings--3xs);
border-radius: var(--c--globals--spacings--3xs);
}
.bn-block-content[data-content-type='checkListItem'][data-checked='true']
.bn-inline-content {
text-decoration: none;
}
.bn-default-styles h1 {
font-size: 1.875rem;
}
.bn-default-styles h2 {
font-size: 1.5rem;
}
.bn-default-styles h3 {
font-size: 1.25rem;
}
a {
color: var(--c--globals--colors--gray-600);
cursor: pointer;
}
.bn-block-group
.bn-block-group
.bn-block-group
.bn-block-outer:not([data-prev-depth-changed]):before {
border-left: none;
}
.bn-block-outer:not([data-prev-depth-changed]):before {
border-left: none;
}
.bn-toolbar {
max-width: 95vw;
}
.bn-toolbar {
max-width: 95vw;
}
/**
* Quotes
*/
blockquote {
border-left: 4px solid var(--c--globals--colors--gray-300);
font-style: italic;
}
/**
* Quotes
*/
blockquote {
border-left: 4px solid var(--c--globals--colors--gray-300);
font-style: italic;
}
/**
/**
* AI
*/
ins,
[data-type='modification'] {
background: var(--c--globals--colors--brand-100);
border-bottom: 2px solid var(--c--globals--colors--brand-300);
color: var(--c--globals--colors--brand-700);
}
ins,
[data-type='modification'] {
background: var(--c--globals--colors--brand-100);
border-bottom: 2px solid var(--c--globals--colors--brand-300);
color: var(--c--globals--colors--brand-700);
}
/**
* Divider
*/
[data-content-type='divider'] hr {
background: #d3d2cf;
margin: 1rem 0;
width: 100%;
border: 1px solid #d3d2cf;
}
.bn-side-menu[data-block-type='divider'] {
height: 38px;
}
/**
* Divider
*/
[data-content-type='divider'] hr {
background: #d3d2cf;
margin: 1rem 0;
width: 100%;
border: 1px solid #d3d2cf;
}
/**
* Checklist items
*/
.bn-block-content[data-content-type='checkListItem'] > div > input {
appearance: none;
width: 20px;
height: 20px;
border: 2px solid
var(--c--contextuals--content--semantic--neutral--tertiary);
border-radius: 4px;
cursor: pointer;
position: relative;
align-self: center;
margin-top: 2px;
}
.bn-block-content[data-content-type='checkListItem'] > div > input:checked {
background-color: var(--c--contextuals--content--semantic--brand--tertiary);
border-color: var(--c--contextuals--content--semantic--brand--tertiary);
}
.bn-block-content[data-content-type='checkListItem']
> div
> input:checked::after {
content: 'check';
font-family: 'Material Symbols Outlined Variable', sans-serif;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--c--contextuals--content--semantic--overlay--primary);
font-size: 18px;
}
/**
* Checklist items
*/
.bn-block-content[data-content-type='checkListItem'] > div > input {
appearance: none;
width: 20px;
height: 20px;
border: 2px solid
var(--c--contextuals--content--semantic--neutral--tertiary);
border-radius: 4px;
cursor: pointer;
position: relative;
align-self: center;
margin-top: 2px;
}
.bn-block-content[data-content-type='checkListItem'] > div > input:checked {
background-color: var(--c--contextuals--content--semantic--brand--tertiary);
border-color: var(--c--contextuals--content--semantic--brand--tertiary);
}
.bn-block-content[data-content-type='checkListItem']
> div
> input:checked::after {
content: 'check';
font-family: 'Material Symbols Outlined Variable', sans-serif;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--c--contextuals--content--semantic--overlay--primary);
font-size: 18px;
}
/**
* Headings
* Ensure consistent spacing between headings and paragraphs
*/
[data-content-type='heading'] {
--level: 1.875rem;
&[data-level='2'] {
--level: 1.5rem;
}
&[data-level='3'] {
--level: 1.25rem;
}
&[data-level='4'] {
--level: 1.125rem;
}
&[data-level='5'] {
--level: 1rem;
}
&[data-level='6'] {
--level: 0.875rem;
}
/**
* Ensure consistent spacing between headings and paragraphs
*/
& .bn-block-outer:not(:first-child) {
&:has(h1) {
margin-top: 32px;
}
&:has(h2) {
margin-top: 24px;
}
&:has(h3) {
margin-top: 16px;
}
}
& .bn-inline-content code {
background-color: gainsboro;
padding: 2px;
border-radius: 4px;
}
@media screen and (width <= 768px) {
& .bn-editor {
padding-right: 36px;
}
}
@media screen and (width <= 560px) {
.--docs--doc-readonly & .bn-editor {
padding-left: 10px;
}
& .bn-editor {
padding-right: 10px;
}
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 54px;
height: 46px;
}
.bn-side-menu[data-block-type='heading'][data-level='2'] {
height: 43px;
height: 40px;
}
.bn-side-menu[data-block-type='heading'][data-level='3'] {
height: 35px;
height: 40px;
}
& .bn-default-styles h1 {
font-size: 1.875rem;
& .bn-editor h1 {
font-size: 1.6rem;
}
& .bn-default-styles h2 {
font-size: 1.5rem;
& .bn-editor h2 {
font-size: 1.35rem;
}
& .bn-default-styles h3 {
font-size: 1.25rem;
& .bn-editor h3 {
font-size: 1.2rem;
}
& .bn-default-styles h4 {
font-size: 1.125rem;
}
& .bn-default-styles h5 {
font-size: 1rem;
}
& .bn-default-styles h6 {
font-size: 0.875rem;
}
& .bn-block-outer:not(:first-child) {
&:has(h1) {
margin-top: 32px;
}
&:has(h2) {
margin-top: 24px;
}
&:has(h3) {
margin-top: 16px;
}
}
& .bn-inline-content code {
background-color: gainsboro;
padding: 2px;
border-radius: 4px;
}
@media screen and (width <= 768px) {
& .bn-editor {
padding-right: 36px;
}
}
@media screen and (width <= 560px) {
.--docs--doc-readonly & .bn-editor {
padding-left: 10px;
}
& .bn-editor {
padding-right: 10px;
}
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 46px;
}
.bn-side-menu[data-block-type='heading'][data-level='2'] {
height: 40px;
}
.bn-side-menu[data-block-type='heading'][data-level='3'] {
height: 40px;
}
[data-content-type='heading'] {
--level: 1.6rem;
&[data-level='2'] {
--level: 1.35rem;
}
&[data-level='3'] {
--level: 1.2rem;
}
&[data-level='4'] {
--level: 1.125rem;
}
&[data-level='5'] {
--level: 1rem;
}
&[data-level='6'] {
--level: 0.875rem;
}
}
& .bn-editor h1 {
font-size: 1.6rem;
}
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
font-size: 14px;
}
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
font-size: 14px;
}
}
`;

View File

@@ -11,7 +11,7 @@ import {
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
import { DocumentProps, pdf } from '@react-pdf/renderer';
import jsonemoji from 'emoji-datasource-apple' with { type: 'json' };
import jsonemoji from 'emoji-datasource-apple' assert { type: 'json' };
import i18next from 'i18next';
import JSZip from 'jszip';
import { cloneElement, isValidElement, useState } from 'react';

View File

@@ -16,4 +16,6 @@ if (process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false') {
};
}
export default modulesExport;
type ModulesExport = typeof modulesExport;
export default modulesExport as ModulesExport;

View File

@@ -84,7 +84,7 @@ const PRINT_ONLY_CONTENT_CSS = `
/* Ensure BlockNote content flows properly */
.bn-editor,
.bn-root,
.bn-container,
.--docs--main-editor,
.bn-block-outer {
height: auto !important;

View File

@@ -68,7 +68,7 @@ export const BoutonShare = ({
/>
}
onClick={(e) => {
addLastFocus(e.currentTarget);
addLastFocus(e.currentTarget as HTMLElement);
open();
}}
size="medium"
@@ -85,7 +85,7 @@ export const BoutonShare = ({
color="brand"
variant="tertiary"
onClick={(e) => {
addLastFocus(e.currentTarget);
addLastFocus(e.currentTarget as HTMLElement);
open();
}}
size="medium"

View File

@@ -244,7 +244,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
<Icon iconName="download" $color="inherit" aria-hidden={true} />
}
onClick={(e) => {
addLastFocus(e.currentTarget);
addLastFocus(e.currentTarget as HTMLElement);
setIsModalExportOpen(true);
}}
size={isSmallMobile ? 'small' : 'medium'}

View File

@@ -1,3 +1,4 @@
export * from './useCollaboration';
export * from './useCopyDocLink';
export * from './useCreateChildDocTree';
export * from './useDocTitleUpdate';

View File

@@ -2,7 +2,6 @@ import { useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useCollaborationUrl } from '@/core/config';
import { KEY_DOC } from '@/docs/doc-management/api/useDoc';
import {
KEY_DOC_CONTENT,
useDocContent,
@@ -11,15 +10,13 @@ import { useProviderStore } from '@/docs/doc-management/stores/useProviderStore'
import { useIsOffline } from '@/features/service-worker/hooks/useOffline';
import { useBroadcastStore } from '@/stores/useBroadcastStore';
import { KEY_DOC } from '../api';
export const useCollaboration = (room: string) => {
const collaborationUrl = useCollaborationUrl(room);
const { addTask } = useBroadcastStore();
const queryClient = useQueryClient();
const {
setBroadcastProvider,
cleanupBroadcast,
provider: broadcastProvider,
} = useBroadcastStore();
const { setBroadcastProvider, cleanupBroadcast } = useBroadcastStore();
const {
provider,
createProvider,
@@ -68,7 +65,7 @@ export const useCollaboration = (room: string) => {
* when the document visibility changes.
*/
useEffect(() => {
if (!room || broadcastProvider?.document?.guid !== room) {
if (!room || !isReady) {
return;
}
@@ -77,7 +74,7 @@ export const useCollaboration = (room: string) => {
queryKey: [KEY_DOC, { id: room }],
});
});
}, [addTask, room, queryClient, broadcastProvider?.document?.guid]);
}, [addTask, room, queryClient, isReady]);
/**
* Set the provider when the collaboration URL and the document content are available.

View File

@@ -14,7 +14,7 @@ import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import { Heading } from './Heading';
export const TableContent = ({ selector }: { selector: string }) => {
export const TableContent = () => {
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const [containerHeight, setContainerHeight] = useState('100vh');
const { headings } = useHeadingStore();
@@ -27,29 +27,11 @@ export const TableContent = ({ selector }: { selector: string }) => {
* Calculate container height based on the scrollable content
*/
useEffect(() => {
const layout = document.querySelector<HTMLElement>(selector);
if (!layout) {
return;
const mainLayout = document.getElementById(MAIN_LAYOUT_ID);
if (mainLayout) {
setContainerHeight(`${mainLayout.scrollHeight}px`);
}
let timeout: ReturnType<typeof setTimeout>;
const updateHeight = () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
setContainerHeight(`${layout.scrollHeight}px`);
}, 300);
};
updateHeight();
const observer = new ResizeObserver(updateHeight);
observer.observe(layout);
return () => {
clearTimeout(timeout);
observer.disconnect();
};
}, [selector]);
}, []);
const onOpen = () => {
setIsOpen(true);

View File

@@ -274,16 +274,11 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
/* Remove outline from TreeViewItem wrapper elements */
.c__tree-view--row {
outline: none !important;
pointer-events: initial;
&:focus-visible {
outline: none !important;
}
}
.c__tree-view--node {
pointer-events: inherit;
}
.c__tree-view--container {
z-index: 1;
margin-top: -10px;

View File

@@ -85,14 +85,15 @@ export const DocVersionEditor = ({
return (
<DocEditorContainer
docHeader={<DocVersionHeader />}
docEditor={
<BlockNoteReader
initialContent={initialContent}
docId={version.id}
isMainEditor={false}
/>
}
isDeletedDoc={false}
readOnly={true}
>
<BlockNoteReader
initialContent={initialContent}
docId={version.id}
isMainEditor={false}
/>
</DocEditorContainer>
/>
);
};

View File

@@ -50,7 +50,7 @@ export const DocsGridItemSharedButton = ({ doc, disabled }: Props) => {
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
addLastFocus(event.currentTarget);
addLastFocus(event.currentTarget as HTMLElement);
shareModal.open();
}}
color="brand"

View File

@@ -109,7 +109,6 @@ export const useImport = ({ onDragOver }: UseImportProps) => {
});
},
noClick: true,
noKeyboard: true,
});
const { mutate: importDoc, isPending } = useImportDoc();

View File

@@ -2,9 +2,7 @@ import {
ModalSize,
OnboardingModal,
type OnboardingModalProps,
OnboardingStep,
} from '@gouvfr-lasuite/ui-kit';
import React, { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
@@ -12,29 +10,9 @@ import { useConfig } from '@/core/config/api/useConfig';
import { useOnboardingSteps } from '../hooks/useOnboardingSteps';
/**
* typing was not correct on ui-kit side for the description prop of OnboardingStep,
* it can be a string or a ReactNode but was typed as string only, so we need to override the
* type here to be able to use ReactNode
*/
type OnboardingStepFixed = Omit<OnboardingStep, 'description'> & {
description?: ReactNode;
};
type OnboardingModalPropsFixed = Omit<OnboardingModalProps, 'steps'> & {
steps?: OnboardingStepFixed[];
};
const OnboardingModalFixed =
OnboardingModal as React.ComponentType<OnboardingModalPropsFixed>;
const OnBoardingStyle = createGlobalStyle`
.c__onboarding-modal__steps{
height: auto;
& a{
color:inherit;
}
}
.c__onboarding-modal__content {
height: 350px;
@@ -54,7 +32,7 @@ const OnBoardingStyle = createGlobalStyle`
*:not(.material-icons):not(.material-icons-filled):not(
.material-symbols-outlined
) {
font-family: var(--c--globals--font--families--base);
font-family: Marianne, Inter, Roboto Flex Variable, sans-serif;
}
/* Separator between content and footer actions/link */
@@ -78,10 +56,6 @@ const OnBoardingStyle = createGlobalStyle`
display: flex;
flex-direction: column;
a{
color: inherit;
}
& .c__onboarding-modal__body{
justify-content: center;
}
@@ -107,7 +81,7 @@ export const OnBoarding = (props: OnBoardingProps) => {
return (
<>
{props.isOpen ? <OnBoardingStyle /> : null}
<OnboardingModalFixed
<OnboardingModal
size={ModalSize.LARGE}
appName={t('Discover Docs')}
mainTitle={t('Learn the core principles')}

View File

@@ -1,8 +1,7 @@
import { type OnboardingStep } from '@gouvfr-lasuite/ui-kit';
import Image from 'next/image';
import { Trans, useTranslation } from 'react-i18next';
import { useTranslation } from 'react-i18next';
import { useConfig } from '@/core';
import { useCunninghamTheme } from '@/cunningham';
import DragIndicatorIcon from '../assets/drag_indicator.svg';
@@ -17,9 +16,6 @@ export interface OnboardingStepsData {
export const useOnboardingSteps = () => {
const { t } = useTranslation();
const { data: config } = useConfig();
const readyTemplateUrl =
config?.theme_customization?.onboarding?.ready_template_url;
const { contextualTokens, colorsTokens } = useCunninghamTheme();
const activeColor =
contextualTokens.content.semantic.brand.tertiary ??
@@ -126,21 +122,8 @@ export const useOnboardingSteps = () => {
</OnboardingStepIcon>
),
title: t('Draw inspiration from the content library'),
description: (
<Trans
t={t}
i18nKey="Start from <Link>ready-made templates</Link> for common use cases, then customize them to match your workflow in minutes."
components={{
Link: (
<a
target="_blank"
rel="noopener noreferrer"
href={readyTemplateUrl}
aria-label={t('Ready-made templates (opens in a new tab)')}
/>
),
}}
/>
description: t(
'Start from ready-made templates for common use cases, then customize them to match your workflow in minutes.',
),
content: (
<Image

View File

@@ -50,7 +50,7 @@ export class RequestSerializer {
public static arrayBufferToString(buffer: ArrayBufferLike) {
const decoder = new TextDecoder();
return decoder.decode(buffer);
return decoder.decode(buffer as ArrayBuffer);
}
public static arrayBufferToJson<T>(buffer: ArrayBufferLike) {

View File

@@ -15,7 +15,7 @@ const mockServiceWorkerScope = {
(global as any).self = {
...global,
clients: mockServiceWorkerScope.clients,
};
} as unknown as ServiceWorkerGlobalScope;
describe('OfflinePlugin', () => {
afterEach(() => vi.clearAllMocks());

View File

@@ -1,12 +1,13 @@
import { css, keyframes } from 'styled-components';
import { Box, BoxType } from '@/components';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useResponsiveStore } from '@/stores';
import { SkeletonCircle, SkeletonLine } from './SkeletionUI';
export const DocEditorSkeleton = () => {
const { isDesktop } = useResponsiveStore();
const { spacingsTokens } = useCunninghamTheme();
return (
<>
{/* Main Editor Container */}
@@ -16,117 +17,80 @@ export const DocEditorSkeleton = () => {
$height="100%"
className="--docs--doc-editor-skeleton"
>
<SkeletonEditorHeader />
<SkeletonEditorCore />
{/* Header Skeleton */}
<Box
$padding={{ horizontal: isDesktop ? '70px' : 'base' }}
className="--docs--doc-editor-header-skeleton"
>
<Box
$width="100%"
$padding={{ top: isDesktop ? '65px' : 'md' }}
$gap={spacingsTokens['base']}
>
<Box
$direction="row"
$align="center"
$width="100%"
$padding={{ bottom: 'xs' }}
>
<Box
$direction="row"
$justify="space-between"
$css="flex:1;"
$gap="0.5rem 1rem"
$align="center"
$maxWidth="100%"
>
{/* Title and metadata skeleton */}
<Box $gap="0.25rem" $css="flex:1;">
{/* Title - "Untitled Document" style */}
<SkeletonLine $width="35%" $height="40px" />
{/* Metadata (role and last update) */}
<Box $direction="row" $gap="0.5rem" $align="center">
<SkeletonLine $maxWidth="260px" $height="12px" />
</Box>
</Box>
{/* Toolbox skeleton (buttons) */}
<Box $direction="row" $gap="0.75rem" $align="center">
{/* Share button */}
<SkeletonLine $width="90px" $height="40px" />
{/* Download icon */}
<SkeletonCircle $width="40px" $height="40px" />
{/* Menu icon */}
<SkeletonCircle $width="40px" $height="40px" />
</Box>
</Box>
</Box>
{/* Separator */}
<SkeletonLine $height="1px" />
</Box>
</Box>
{/* Content Skeleton */}
<Box
$direction="row"
$width="100%"
$css="overflow-x: clip; flex: 1;"
$position="relative"
className="--docs--doc-editor-content-skeleton"
>
<Box
$css="flex:1;"
$position="relative"
$width="100%"
$padding={{ horizontal: isDesktop ? '70px' : 'base', top: 'lg' }}
>
{/* Placeholder text similar to screenshot */}
<Box $gap="0rem">
{/* Single placeholder line like in the screenshot */}
<SkeletonLine $width="85%" $height="20px" />
</Box>
</Box>
</Box>
</Box>
</>
);
};
const SkeletonEditorHeader = () => {
const { isDesktop } = useResponsiveStore();
const { spacingsTokens } = useCunninghamTheme();
return (
<Box
$padding={{ horizontal: isDesktop ? '54px' : 'base' }}
className="--docs--doc-editor-header-skeleton"
>
<Box
$width="100%"
$padding={{ top: isDesktop ? '65px' : 'md' }}
$gap={spacingsTokens['base']}
>
<Box
$direction="row"
$align="center"
$width="100%"
$padding={{ bottom: 'xs' }}
>
<Box
$direction="row"
$justify="space-between"
$css="flex:1;"
$gap="0.5rem 1rem"
$align="center"
$maxWidth="100%"
>
{/* Title and metadata skeleton */}
<Box $gap="0.25rem" $css="flex:1;">
{/* Title - "Untitled Document" style */}
<SkeletonLine $width="35%" $height="40px" />
{/* Metadata (role and last update) */}
<Box $direction="row" $gap="0.5rem" $align="center">
<SkeletonLine $maxWidth="260px" $height="12px" />
</Box>
</Box>
{/* Toolbox skeleton (buttons) */}
<Box $direction="row" $gap={spacingsTokens['t']} $align="center">
{/* Share button */}
<SkeletonLine $width="90px" $height="40px" />
{/* Download icon */}
<SkeletonCircle $width="40px" $height="40px" />
{/* Menu icon */}
<SkeletonCircle $width="40px" $height="40px" />
</Box>
</Box>
</Box>
{/* Separator */}
<SkeletonLine $height="1px" />
</Box>
</Box>
);
};
export const SKELETON_FADE_DURATION_MS = 150;
const skeletonFadeOut = keyframes`
from { opacity: 1; }
to { opacity: 0; }
`;
type SkeletonEditorCoreProps = Partial<BoxType> & {
isFadingOut?: boolean;
};
export const SkeletonEditorCore = ({
isFadingOut,
$css,
...props
}: SkeletonEditorCoreProps) => {
const { isDesktop } = useResponsiveStore();
return (
<Box
$direction="row"
$width="100%"
$css="overflow-x: clip; flex: 1;"
$position="relative"
className="--docs--doc-editor-content-skeleton"
>
<Box
$position="relative"
$width="100%"
$padding={{ horizontal: isDesktop ? '54px' : 'base', top: 'md' }}
$flex="1"
$css={css`
${$css}
${isFadingOut &&
css`
animation: ${skeletonFadeOut} ${SKELETON_FADE_DURATION_MS}ms
ease-in-out forwards;
`}
`}
{...props}
>
<Box $gap="1.5rem">
<SkeletonLine $width="65%" $height="35px" />
<SkeletonLine $width="55%" $height="25px" />
<SkeletonLine $width="35%" $height="20px" />
</Box>
</Box>
</Box>
);
};

View File

@@ -1,24 +0,0 @@
import { useEffect, useState } from 'react';
import { SKELETON_FADE_DURATION_MS } from '../components/DocEditorSkeleton';
export const useSkeletonFadeOut = (showContent: boolean) => {
const [skeletonVisible, setSkeletonVisible] = useState(!showContent);
const [isFadingOut, setIsFadingOut] = useState(false);
useEffect(() => {
if (showContent) {
setIsFadingOut(true);
const timer = setTimeout(
() => setSkeletonVisible(false),
SKELETON_FADE_DURATION_MS,
);
return () => clearTimeout(timer);
} else {
setSkeletonVisible(true);
setIsFadingOut(false);
}
}, [showContent]);
return { skeletonVisible, isFadingOut };
};

View File

@@ -36,6 +36,7 @@ if (!isInitialized && !i18next.isInitialized) {
lowerCaseLng: true,
nsSeparator: false,
keySeparator: false,
showSupportNotice: false,
})
.then(() => {
if (typeof document !== 'undefined') {

View File

@@ -1482,7 +1482,7 @@
"Simple document icon": "Icône simple du document",
"Something bad happens, please retry.": "Une erreur inattendue s'est produite, veuillez réessayer.",
"Start Writing": "Commencer à écrire",
"Start from <Link>ready-made templates</Link> for common use cases, then customize them to match your workflow in minutes.": "Commencez à partir de <Link>modèles prêts à l'emploi</Link> pour les cas d'utilisation courants, puis personnalisez-les pour correspondre à votre flux de travail en quelques minutes.",
"Start from ready-made templates for common use cases, then customize them to match your workflow in minutes.": "Commencez à partir de modèles prêts à l'emploi pour les cas d'utilisation courants, puis personnalisez-les pour correspondre à votre flux de travail en quelques minutes.",
"Stop": "Arrêter",
"Summarize": "Résumer",
"Summary": "Sommaire",

View File

@@ -8,16 +8,12 @@
body {
margin: 0;
padding: 0;
overflow: hidden;
}
/* stylelint-disable-next-line selector-id-pattern */
body > #__next > .c__app > div:has(> .c__loader) {
min-height: 100vh;
width: 100vw;
display: flex;
align-items: center;
justify-content: center;
}
* {

View File

@@ -109,7 +109,5 @@ export const useBroadcastStore = create<BroadcastState>((set, get) => ({
Object.values(get().tasks).forEach(({ task, observer }) => {
task.unobserve(observer);
});
set({ tasks: {}, provider: undefined });
},
}));

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es2020",
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,

View File

@@ -31,17 +31,16 @@
"server:test": "yarn COLLABORATION_SERVER run test"
},
"resolutions": {
"@tiptap/extensions": "3.22.4",
"@types/node": "24.12.2",
"@tiptap/extensions": "3.20.4",
"@types/node": "24.12.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"eslint": "10.2.1",
"eslint": "10.1.0",
"postcss": "8.5.10",
"prosemirror-view": "1.41.8",
"react": "19.2.5",
"react-dom": "19.2.5",
"serialize-javascript": "7.0.5",
"typescript": "6.0.3",
"prosemirror-view": "1.41.7",
"react": "19.2.4",
"react-dom": "19.2.4",
"typescript": "5.9.3",
"uuid": "14.0.0",
"wrap-ansi": "10.0.0",
"yjs": "13.6.30"

View File

@@ -18,23 +18,23 @@
},
"dependencies": {
"@eslint/js": "10.0.1",
"@next/eslint-plugin-next": "16.2.4",
"@tanstack/eslint-plugin-query": "5.99.2",
"@typescript-eslint/eslint-plugin": "8.59.0",
"@typescript-eslint/parser": "8.59.0",
"@typescript-eslint/utils": "8.59.0",
"@vitest/eslint-plugin": "1.6.16",
"eslint-config-next": "16.2.4",
"@next/eslint-plugin-next": "16.2.1",
"@tanstack/eslint-plugin-query": "5.95.0",
"@typescript-eslint/eslint-plugin": "8.57.1",
"@typescript-eslint/parser": "8.57.1",
"@typescript-eslint/utils": "8.57.1",
"@vitest/eslint-plugin": "1.6.13",
"eslint-config-next": "16.2.1",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-import-x": "4.16.2",
"eslint-plugin-jest": "29.15.2",
"eslint-plugin-jest": "29.15.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-playwright": "2.10.2",
"eslint-plugin-playwright": "2.10.1",
"eslint-plugin-prettier": "5.5.5",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-testing-library": "7.16.2",
"prettier": "3.8.3"
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-testing-library": "7.16.1",
"prettier": "3.8.1"
},
"packageManager": "yarn@1.22.22"
}

View File

@@ -21,7 +21,7 @@
"eslint-plugin-import": "2.32.0",
"i18next-parser": "9.4.0",
"jest": "30.3.0",
"ts-jest": "29.4.9",
"ts-jest": "29.4.6",
"typescript": "*",
"yargs": "18.0.0"
},

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es2020",
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,

View File

@@ -16,12 +16,12 @@
"node": ">=22"
},
"dependencies": {
"@blocknote/server-util": "0.49.0",
"@blocknote/server-util": "0.47.3",
"@hocuspocus/server": "3.4.4",
"@sentry/node": "10.49.0",
"@sentry/profiling-node": "10.49.0",
"@sentry/node": "10.45.0",
"@sentry/profiling-node": "10.45.0",
"@tiptap/extensions": "*",
"axios": "1.15.2",
"axios": "1.15.0",
"cors": "2.8.6",
"express": "5.2.1",
"express-ws": "5.0.2",
@@ -30,7 +30,7 @@
"yjs": "*"
},
"devDependencies": {
"@blocknote/core": "0.49.0",
"@blocknote/core": "0.47.3",
"@hocuspocus/provider": "3.4.4",
"@types/cors": "2.8.19",
"@types/express": "5.0.6",
@@ -45,8 +45,8 @@
"ts-node": "10.9.2",
"tsc-alias": "1.8.16",
"typescript": "*",
"vitest": "4.1.4",
"vitest-mock-extended": "4.0.0",
"vitest": "4.1.0",
"vitest-mock-extended": "3.1.0",
"ws": "8.20.0"
},
"packageManager": "yarn@1.22.22"

View File

@@ -62,7 +62,7 @@ const readers: InputReader[] = [
const ydoc = new Y.Doc();
try {
Y.applyUpdate(ydoc, data);
return editor.yDocToBlocks(ydoc, 'document-store');
return editor.yDocToBlocks(ydoc, 'document-store') as PartialBlock[];
} finally {
ydoc.destroy();
}

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es2020",
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,

File diff suppressed because it is too large Load Diff

View File

@@ -6,4 +6,4 @@ DOCS_DIR_MAILS="${DOCS_DIR_MAILS:-../backend/core/templates/mail}/html/"
if [ ! -d "${DOCS_DIR_MAILS}" ]; then
mkdir -p "${DOCS_DIR_MAILS}";
fi
mjml mjml/*.mjml -o "${DOCS_DIR_MAILS}" --config.allowIncludes true;
mjml mjml/*.mjml -o "${DOCS_DIR_MAILS}";

View File

@@ -1,16 +1,20 @@
<mj-head>
<mj-title>{{ title }}</mj-title>
<mj-preview>{{ title }}</mj-preview>
<mj-font name="Inter" href="https://fonts.bunny.net/css?family=inter:300,400,500,700" />
<mj-preview>
<!--
We load django tags here, in this way there are put within the body in html output
so the html-to-text command includes it within its output
-->
{% load i18n static extra_tags %}
{{ title }}
</mj-preview>
<mj-attributes>
<mj-all
font-family="Inter, sans-serif"
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Cantarell, 'Helvetica Neue', sans-serif"
font-size="16px"
line-height="normal"
color="#3A3A3A"
/>
<mj-text font-family="Inter, sans-serif" />
<mj-button font-family="Inter, sans-serif" />
</mj-attributes>
<mj-style>
/* Reset */

View File

@@ -2,11 +2,6 @@
<mj-include path="./partial/header.mjml" />
<mj-body mj-class="bg--blue-100">
<!--
We load django tags here so they appear in the body of the HTML output.
This ensures html-to-text also includes them in the plain text template.
-->
<mj-raw>{% load i18n static extra_tags %}</mj-raw>
<mj-wrapper css-class="wrapper" padding="5px 25px 0px 25px">
<mj-section css-class="wrapper-logo">
<mj-column>
@@ -21,11 +16,11 @@
</mj-section>
<mj-section mj-class="bg--white-100" padding="0px 20px 60px 20px">
<mj-column>
<mj-text align="center" font-family="Inter, sans-serif">
<mj-text align="center">
<h1>{{title|capfirst}}</h1>
</mj-text>
<!-- Main Message -->
<mj-text font-family="Inter, sans-serif">
<mj-text>
{{message|capfirst}}
<a href="{{link}}">{{link_label}}</a>
</mj-text>
@@ -34,7 +29,6 @@
background-color="#000091"
color="white"
padding-bottom="30px"
font-family="Inter, sans-serif"
>
{{button_label}}
</mj-button>
@@ -45,13 +39,13 @@
width="30%"
align="center"
/>
<mj-text font-family="Inter, sans-serif">
<mj-text>
{% blocktrans %}
Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team.
{% endblocktrans %}
</mj-text>
<!-- Signature -->
<mj-text font-family="Inter, sans-serif">
<mj-text>
<p>
{% blocktrans %}
Brought to you by {{brandname}}

View File

@@ -5,7 +5,7 @@
"type": "module",
"dependencies": {
"@html-to/text-cli": "0.5.4",
"mjml": "5.0.1"
"mjml": "4.18.0"
},
"resolutions": {
"minimatch": "^10.0.0"

File diff suppressed because it is too large Load Diff