Compare commits

..

37 Commits

Author SHA1 Message Date
Ivan
5c4149337b F: Add side panel for schema preview pt1
Some checks failed
Frontend CI / build (22.x) (push) Waiting to run
Frontend CI / notify-failure (push) Blocked by required conditions
Backend CI / build (3.12) (push) Has been cancelled
Backend CI / notify-failure (push) Has been cancelled
2025-07-02 20:15:27 +03:00
Ivan
ac38e9f4b5 B: Fix transparent bg for tabs 2025-07-02 15:55:16 +03:00
Ivan
1ee3e9ef4f F: Improve typography 2025-07-02 13:13:01 +03:00
Ivan
9b6a014fb3 R: Remove index files from components 2025-07-02 12:37:47 +03:00
Ivan
f365b20814 R: Refactor components to isolate reusable parts 2025-07-02 12:14:46 +03:00
Ivan
cb0f8783e5 B: Fix panel animation 2025-07-01 21:55:42 +03:00
Ivan
44c21dd7fd npm update 2025-07-01 20:14:04 +03:00
Ivan
65a65aba41 M: Replace functional typification label 2025-07-01 19:44:42 +03:00
Ivan
6b36a3fd41 F: Improve OSS UI 2025-07-01 16:13:24 +03:00
Ivan
976ab669e7 B: Tentative fix for longpress on iOS 2025-07-01 13:50:13 +03:00
Ivan
52b039259a R: Replace clsx with merging styles 2025-06-24 22:22:19 +03:00
Ivan
893875c0cf F: Add hide button for stats 2025-06-24 22:13:02 +03:00
Ivan
ef8355dc57 M: Simplify color tooltips 2025-06-24 14:25:27 +03:00
Ivan
8eedd70ffd Update index.css 2025-06-24 13:44:39 +03:00
Ivan
d2b37f9cad M: Add delay for some hover effects 2025-06-24 13:33:38 +03:00
Ivan
4a65e2a0c4 M: Improve text position in node 2025-06-21 16:58:40 +03:00
Ivan
ffebe27d48 B: Adjust selection for Constituenta editor 2025-06-19 18:12:34 +03:00
Ivan
807c22a3c8 M: Minor UI fixes 2025-06-19 17:49:51 +03:00
Ivan
a7f6f994fe F: Upgrade hover animations pt2 2025-06-19 13:12:48 +03:00
Ivan
fd6d448efd B: Fix UI for small screens 2025-06-19 10:57:28 +03:00
Ivan
59fa2c36de B: Fix dark theme loading 2025-06-19 00:16:02 +03:00
Ivan
e6d0b50833 M: Minor UI color improvements 2025-06-18 22:54:03 +03:00
Ivan
096efef357 F: Rework location picker 2025-06-18 22:30:55 +03:00
Ivan
8a3748bc2d F: Reworking colors and hovering pt1 2025-06-18 16:20:59 +03:00
Ivan
9b00125996 M: Add animation to details element 2025-06-18 10:15:03 +03:00
Ivan
4278505f86 M: Improve hover for footer 2025-06-18 09:25:37 +03:00
Ivan
983874af7e M: Improve hover effects on pagination 2025-06-18 00:39:54 +03:00
Ivan
d4e45a4c73 F: Improve placeholder for RSExpressions 2025-06-18 00:16:13 +03:00
Ivan
d75d834865 F: Improve hover buttons design 2025-06-17 22:28:37 +03:00
Ivan
cd26b0a10a M: Fix nav bar colors and transitions 2025-06-17 20:52:16 +03:00
Ivan
f78dbc6065 M: Always show search for RSList 2025-06-17 20:43:10 +03:00
Ivan
8cbb1faa50 npm update 2025-06-17 20:34:14 +03:00
Ivan
507a7c32db R: Migrating to cursor IDE 2025-06-17 19:46:45 +03:00
Ivan
be11b4a59a F: Improve layout when parent is changed 2025-06-12 19:43:43 +03:00
Ivan
af80099292 B: Fix initial data 2025-06-11 19:51:43 +03:00
Ivan
3e32233764 B: Fix node double click 2025-06-11 19:48:20 +03:00
Ivan
3d9930c44f R: Refactor layout data structure 2025-06-11 16:38:56 +03:00
170 changed files with 3776 additions and 2424 deletions

73
.cursorignore Normal file
View File

@ -0,0 +1,73 @@
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
# SECURITY SENSITIVE FILES
secrets/
nginx/cert/*.pem
# External distributions
rsconcept/backend/import/*.whl
rsconcept/backend/static
rsconcept/backend/media
rsconcept/frontend/dist
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Distribution / packaging
.Python
build/
eggs/
.eggs/
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
.mypy_cache/
# Django
*.log
db.sqlite3
db.sqlite3-journal
visualizeDB.dot
# React
.DS_*
*.log
*.tsbuildinfo
logs
**/*.backup.*
**/*.back.*
node_modules
bower_components
*.sublime*
# NextJS
**/.next/
**/out/
# Environments
venv/
/GitExtensions.settings
rsconcept/frontend/public/privacy.pdf
/rsconcept/frontend/playwright-report
/rsconcept/frontend/test-results

View File

@ -68,8 +68,6 @@ class TestLibraryViewset(EndpointTester):
self.assertEqual(response.data['access_policy'], data['access_policy']) self.assertEqual(response.data['access_policy'], data['access_policy'])
self.assertEqual(response.data['visible'], data['visible']) self.assertEqual(response.data['visible'], data['visible'])
self.assertEqual(response.data['read_only'], data['read_only']) self.assertEqual(response.data['read_only'], data['read_only'])
self.assertEqual(oss.layout().data['operations'], [])
self.assertEqual(oss.layout().data['blocks'], [])
self.logout() self.logout()
data = {'title': 'Title2'} data = {'title': 'Title2'}

View File

@ -41,7 +41,7 @@ class LibraryViewSet(viewsets.ModelViewSet):
else: else:
serializer.save() serializer.save()
if serializer.data.get('item_type') == m.LibraryItemType.OPERATION_SCHEMA: if serializer.data.get('item_type') == m.LibraryItemType.OPERATION_SCHEMA:
Layout.objects.create(oss=serializer.instance, data={'operations': [], 'blocks': []}) Layout.objects.create(oss=serializer.instance, data=[])
def perform_update(self, serializer) -> None: def perform_update(self, serializer) -> None:
instance = serializer.save() instance = serializer.save()

View File

@ -0,0 +1,41 @@
from django.db import migrations
def migrate_layout(apps, schema_editor):
Layout = apps.get_model('oss', 'Layout')
for layout in Layout.objects.all():
previous_data = layout.data
new_layout = []
for operation in previous_data['operations']:
new_layout.append({
'nodeID': 'o' + str(operation['id']),
'x': operation['x'],
'y': operation['y'],
'width': 150,
'height': 40
})
for block in previous_data['blocks']:
new_layout.append({
'nodeID': 'b' + str(block['id']),
'x': block['x'],
'y': block['y'],
'width': block['width'],
'height': block['height']
})
layout.data = new_layout
layout.save(update_fields=['data'])
class Migration(migrations.Migration):
dependencies = [
('oss', '0011_remove_operation_position_x_and_more'),
]
operations = [
migrations.RunPython(migrate_layout),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.1 on 2025-06-11 10:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('oss', '0012 restructure_layout'),
]
operations = [
migrations.AlterField(
model_name='layout',
name='data',
field=models.JSONField(default=list, verbose_name='Расположение'),
),
]

View File

@ -13,7 +13,7 @@ class Layout(Model):
data = JSONField( data = JSONField(
verbose_name='Расположение', verbose_name='Расположение',
default=dict default=list
) )
class Meta: class Meta:

View File

@ -40,7 +40,7 @@ class OperationSchema:
def create(**kwargs) -> 'OperationSchema': def create(**kwargs) -> 'OperationSchema':
''' Create LibraryItem via OperationSchema. ''' ''' Create LibraryItem via OperationSchema. '''
model = LibraryItem.objects.create(item_type=LibraryItemType.OPERATION_SCHEMA, **kwargs) model = LibraryItem.objects.create(item_type=LibraryItemType.OPERATION_SCHEMA, **kwargs)
Layout.objects.create(oss=model, data={'operations': [], 'blocks': []}) Layout.objects.create(oss=model, data=[])
return OperationSchema(model) return OperationSchema(model)
@staticmethod @staticmethod

View File

@ -2,16 +2,9 @@
from rest_framework import serializers from rest_framework import serializers
class OperationNodeSerializer(serializers.Serializer): class NodeSerializer(serializers.Serializer):
''' Operation position. '''
id = serializers.IntegerField()
x = serializers.FloatField()
y = serializers.FloatField()
class BlockNodeSerializer(serializers.Serializer):
''' Block position. ''' ''' Block position. '''
id = serializers.IntegerField() nodeID = serializers.CharField()
x = serializers.FloatField() x = serializers.FloatField()
y = serializers.FloatField() y = serializers.FloatField()
width = serializers.FloatField() width = serializers.FloatField()
@ -19,13 +12,8 @@ class BlockNodeSerializer(serializers.Serializer):
class LayoutSerializer(serializers.Serializer): class LayoutSerializer(serializers.Serializer):
''' Layout for OperationSchema. ''' ''' Serializer: Layout data. '''
blocks = serializers.ListField( data = serializers.ListField(child=NodeSerializer()) # type: ignore
child=BlockNodeSerializer()
)
operations = serializers.ListField(
child=OperationNodeSerializer()
)
class SubstitutionExSerializer(serializers.Serializer): class SubstitutionExSerializer(serializers.Serializer):

View File

@ -13,7 +13,7 @@ from apps.rsform.serializers import SubstitutionSerializerBase
from shared import messages as msg from shared import messages as msg
from ..models import Argument, Block, Inheritance, Operation, OperationSchema, OperationType from ..models import Argument, Block, Inheritance, Operation, OperationSchema, OperationType
from .basics import LayoutSerializer, SubstitutionExSerializer from .basics import NodeSerializer, SubstitutionExSerializer
class OperationSerializer(serializers.ModelSerializer): class OperationSerializer(serializers.ModelSerializer):
@ -52,7 +52,9 @@ class CreateBlockSerializer(serializers.Serializer):
model = Block model = Block
fields = 'title', 'description', 'parent' fields = 'title', 'description', 'parent'
layout = LayoutSerializer() layout = serializers.ListField(
child=NodeSerializer()
)
item_data = BlockCreateData() item_data = BlockCreateData()
width = serializers.FloatField() width = serializers.FloatField()
height = serializers.FloatField() height = serializers.FloatField()
@ -100,7 +102,10 @@ class UpdateBlockSerializer(serializers.Serializer):
model = Block model = Block
fields = 'title', 'description', 'parent' fields = 'title', 'description', 'parent'
layout = LayoutSerializer(required=False) layout = serializers.ListField(
child=NodeSerializer(),
required=False
)
target = PKField(many=False, queryset=Block.objects.all()) target = PKField(many=False, queryset=Block.objects.all())
item_data = UpdateBlockData() item_data = UpdateBlockData()
@ -127,7 +132,9 @@ class UpdateBlockSerializer(serializers.Serializer):
class DeleteBlockSerializer(serializers.Serializer): class DeleteBlockSerializer(serializers.Serializer):
''' Serializer: Delete block. ''' ''' Serializer: Delete block. '''
layout = LayoutSerializer() layout = serializers.ListField(
child=NodeSerializer()
)
target = PKField(many=False, queryset=Block.objects.all().only('oss_id')) target = PKField(many=False, queryset=Block.objects.all().only('oss_id'))
def validate(self, attrs): def validate(self, attrs):
@ -142,7 +149,9 @@ class DeleteBlockSerializer(serializers.Serializer):
class MoveItemsSerializer(serializers.Serializer): class MoveItemsSerializer(serializers.Serializer):
''' Serializer: Move items to another parent. ''' ''' Serializer: Move items to another parent. '''
layout = LayoutSerializer() layout = serializers.ListField(
child=NodeSerializer()
)
operations = PKField(many=True, queryset=Operation.objects.all().only('oss_id', 'parent')) operations = PKField(many=True, queryset=Operation.objects.all().only('oss_id', 'parent'))
blocks = PKField(many=True, queryset=Block.objects.all().only('oss_id', 'parent')) blocks = PKField(many=True, queryset=Block.objects.all().only('oss_id', 'parent'))
destination = PKField(many=False, queryset=Block.objects.all().only('oss_id'), allow_null=True) destination = PKField(many=False, queryset=Block.objects.all().only('oss_id'), allow_null=True)
@ -196,8 +205,12 @@ class CreateOperationSerializer(serializers.Serializer):
'alias', 'operation_type', 'title', \ 'alias', 'operation_type', 'title', \
'description', 'result', 'parent' 'description', 'result', 'parent'
layout = LayoutSerializer() layout = serializers.ListField(
child=NodeSerializer()
)
item_data = CreateOperationData() item_data = CreateOperationData()
width = serializers.FloatField()
height = serializers.FloatField()
position_x = serializers.FloatField() position_x = serializers.FloatField()
position_y = serializers.FloatField() position_y = serializers.FloatField()
create_schema = serializers.BooleanField(default=False, required=False) create_schema = serializers.BooleanField(default=False, required=False)
@ -230,7 +243,10 @@ class UpdateOperationSerializer(serializers.Serializer):
model = Operation model = Operation
fields = 'alias', 'title', 'description', 'parent' fields = 'alias', 'title', 'description', 'parent'
layout = LayoutSerializer(required=False) layout = serializers.ListField(
child=NodeSerializer(),
required=False
)
target = PKField(many=False, queryset=Operation.objects.all()) target = PKField(many=False, queryset=Operation.objects.all())
item_data = UpdateOperationData() item_data = UpdateOperationData()
arguments = PKField(many=True, queryset=Operation.objects.all().only('oss_id', 'result_id'), required=False) arguments = PKField(many=True, queryset=Operation.objects.all().only('oss_id', 'result_id'), required=False)
@ -297,7 +313,9 @@ class UpdateOperationSerializer(serializers.Serializer):
class DeleteOperationSerializer(serializers.Serializer): class DeleteOperationSerializer(serializers.Serializer):
''' Serializer: Delete operation. ''' ''' Serializer: Delete operation. '''
layout = LayoutSerializer() layout = serializers.ListField(
child=NodeSerializer()
)
target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'result')) target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'result'))
keep_constituents = serializers.BooleanField(default=False, required=False) keep_constituents = serializers.BooleanField(default=False, required=False)
delete_schema = serializers.BooleanField(default=False, required=False) delete_schema = serializers.BooleanField(default=False, required=False)
@ -314,7 +332,9 @@ class DeleteOperationSerializer(serializers.Serializer):
class TargetOperationSerializer(serializers.Serializer): class TargetOperationSerializer(serializers.Serializer):
''' Serializer: Target single operation. ''' ''' Serializer: Target single operation. '''
layout = LayoutSerializer() layout = serializers.ListField(
child=NodeSerializer()
)
target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'result_id')) target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'result_id'))
def validate(self, attrs): def validate(self, attrs):
@ -329,7 +349,9 @@ class TargetOperationSerializer(serializers.Serializer):
class SetOperationInputSerializer(serializers.Serializer): class SetOperationInputSerializer(serializers.Serializer):
''' Serializer: Set input schema for operation. ''' ''' Serializer: Set input schema for operation. '''
layout = LayoutSerializer() layout = serializers.ListField(
child=NodeSerializer()
)
target = PKField(many=False, queryset=Operation.objects.all()) target = PKField(many=False, queryset=Operation.objects.all())
input = PKField( input = PKField(
many=False, many=False,
@ -366,7 +388,9 @@ class OperationSchemaSerializer(serializers.ModelSerializer):
substitutions = serializers.ListField( substitutions = serializers.ListField(
child=SubstitutionExSerializer() child=SubstitutionExSerializer()
) )
layout = LayoutSerializer() layout = serializers.ListField(
child=NodeSerializer()
)
class Meta: class Meta:
''' serializer metadata. ''' ''' serializer metadata. '''
@ -459,7 +483,7 @@ class RelocateConstituentsSerializer(serializers.Serializer):
return attrs return attrs
# ====== Internals ================================================================================= # ====== Internals ============
def _collect_descendants(start_blocks: list[Block]) -> set[int]: def _collect_descendants(start_blocks: list[Block]) -> set[int]:

View File

@ -59,14 +59,11 @@ class TestChangeAttributes(EndpointTester):
self.operation3.refresh_from_db() self.operation3.refresh_from_db()
self.ks3 = RSForm(self.operation3.result) self.ks3 = RSForm(self.operation3.result)
self.layout_data = { self.layout_data = [
'operations': [ {'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'id': self.operation1.pk, 'x': 0, 'y': 0}, {'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'id': self.operation2.pk, 'x': 0, 'y': 0}, {'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'id': self.operation3.pk, 'x': 0, 'y': 0}, ]
],
'blocks': []
}
layout = self.owned.layout() layout = self.owned.layout()
layout.data = self.layout_data layout.data = self.layout_data
layout.save() layout.save()

View File

@ -57,14 +57,11 @@ class TestChangeConstituents(EndpointTester):
self.ks3 = RSForm(self.operation3.result) self.ks3 = RSForm(self.operation3.result)
self.assertEqual(self.ks3.constituents().count(), 4) self.assertEqual(self.ks3.constituents().count(), 4)
self.layout_data = { self.layout_data = [
'operations': [ {'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'id': self.operation1.pk, 'x': 0, 'y': 0}, {'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'id': self.operation2.pk, 'x': 0, 'y': 0}, {'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'id': self.operation3.pk, 'x': 0, 'y': 0}, ]
],
'blocks': []
}
layout = self.owned.layout() layout = self.owned.layout()
layout.data = self.layout_data layout.data = self.layout_data
layout.save() layout.save()

View File

@ -107,16 +107,13 @@ class TestChangeOperations(EndpointTester):
convention='KS5D4' convention='KS5D4'
) )
self.layout_data = { self.layout_data = [
'operations': [ {'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'id': self.operation1.pk, 'x': 0, 'y': 0}, {'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'id': self.operation2.pk, 'x': 0, 'y': 0}, {'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'id': self.operation3.pk, 'x': 0, 'y': 0}, {'nodeID': 'o' + str(self.operation4.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'id': self.operation4.pk, 'x': 0, 'y': 0}, {'nodeID': 'o' + str(self.operation5.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}
{'id': self.operation5.pk, 'x': 0, 'y': 0}, ]
],
'blocks': []
}
layout = self.owned.layout() layout = self.owned.layout()
layout.data = self.layout_data layout.data = self.layout_data
layout.save() layout.save()

View File

@ -107,16 +107,13 @@ class TestChangeSubstitutions(EndpointTester):
convention='KS5D4' convention='KS5D4'
) )
self.layout_data = { self.layout_data = [
'operations': [ {'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'id': self.operation1.pk, 'x': 0, 'y': 0}, {'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'id': self.operation2.pk, 'x': 0, 'y': 0}, {'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'id': self.operation3.pk, 'x': 0, 'y': 0}, {'nodeID': 'o' + str(self.operation4.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'id': self.operation4.pk, 'x': 0, 'y': 0}, {'nodeID': 'o' + str(self.operation5.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'id': self.operation5.pk, 'x': 0, 'y': 0}, ]
],
'blocks': []
}
layout = self.owned.layout() layout = self.owned.layout()
layout.data = self.layout_data layout.data = self.layout_data
layout.save() layout.save()

View File

@ -49,16 +49,14 @@ class TestOssBlocks(EndpointTester):
title='3', title='3',
parent=self.block1 parent=self.block1
) )
self.layout_data = { self.layout_data = [
'operations': [ {'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'id': self.operation1.pk, 'x': 0, 'y': 0}, {'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'id': self.operation2.pk, 'x': 0, 'y': 0},
], {'nodeID': 'b' + str(self.block1.pk), 'x': 0, 'y': 0, 'width': 0.5, 'height': 0.5},
'blocks': [ {'nodeID': 'b' + str(self.block2.pk), 'x': 0, 'y': 0, 'width': 0.5, 'height': 0.5},
{'id': self.block1.pk, 'x': 0, 'y': 0, 'width': 0.5, 'height': 0.5},
{'id': self.block2.pk, 'x': 0, 'y': 0, 'width': 0.5, 'height': 0.5},
] ]
}
layout = self.owned.layout() layout = self.owned.layout()
layout.data = self.layout_data layout.data = self.layout_data
layout.save() layout.save()
@ -88,7 +86,7 @@ class TestOssBlocks(EndpointTester):
self.assertEqual(len(response.data['oss']['blocks']), 3) self.assertEqual(len(response.data['oss']['blocks']), 3)
new_block = response.data['new_block'] new_block = response.data['new_block']
layout = response.data['oss']['layout'] layout = response.data['oss']['layout']
item = [item for item in layout['blocks'] if item['id'] == new_block['id']][0] item = [item for item in layout if item['nodeID'] == 'b' + str(new_block['id'])][0]
self.assertEqual(new_block['title'], data['item_data']['title']) self.assertEqual(new_block['title'], data['item_data']['title'])
self.assertEqual(new_block['description'], data['item_data']['description']) self.assertEqual(new_block['description'], data['item_data']['description'])
self.assertEqual(new_block['parent'], None) self.assertEqual(new_block['parent'], None)

View File

@ -54,14 +54,11 @@ class TestOssOperations(EndpointTester):
alias='3', alias='3',
operation_type=OperationType.SYNTHESIS operation_type=OperationType.SYNTHESIS
) )
self.layout_data = { self.layout_data = [
'operations': [ {'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'id': self.operation1.pk, 'x': 0, 'y': 0}, {'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'id': self.operation2.pk, 'x': 0, 'y': 0}, {'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'id': self.operation3.pk, 'x': 0, 'y': 0}, ]
],
'blocks': []
}
layout = self.owned.layout() layout = self.owned.layout()
layout.data = self.layout_data layout.data = self.layout_data
layout.save() layout.save()
@ -87,7 +84,9 @@ class TestOssOperations(EndpointTester):
}, },
'layout': self.layout_data, 'layout': self.layout_data,
'position_x': 1, 'position_x': 1,
'position_y': 1 'position_y': 1,
'width': 500,
'height': 50
} }
self.executeBadData(data=data) self.executeBadData(data=data)
@ -102,7 +101,7 @@ class TestOssOperations(EndpointTester):
self.assertEqual(len(response.data['oss']['operations']), 4) self.assertEqual(len(response.data['oss']['operations']), 4)
new_operation = response.data['new_operation'] new_operation = response.data['new_operation']
layout = response.data['oss']['layout'] layout = response.data['oss']['layout']
item = [item for item in layout['operations'] if item['id'] == new_operation['id']][0] item = [item for item in layout if item['nodeID'] == 'o' + str(new_operation['id'])][0]
self.assertEqual(new_operation['alias'], data['item_data']['alias']) self.assertEqual(new_operation['alias'], data['item_data']['alias'])
self.assertEqual(new_operation['operation_type'], data['item_data']['operation_type']) self.assertEqual(new_operation['operation_type'], data['item_data']['operation_type'])
self.assertEqual(new_operation['title'], data['item_data']['title']) self.assertEqual(new_operation['title'], data['item_data']['title'])
@ -111,6 +110,8 @@ class TestOssOperations(EndpointTester):
self.assertEqual(new_operation['parent'], None) self.assertEqual(new_operation['parent'], None)
self.assertEqual(item['x'], data['position_x']) self.assertEqual(item['x'], data['position_x'])
self.assertEqual(item['y'], data['position_y']) self.assertEqual(item['y'], data['position_y'])
self.assertEqual(item['width'], data['width'])
self.assertEqual(item['height'], data['height'])
self.operation1.refresh_from_db() self.operation1.refresh_from_db()
self.executeForbidden(data=data, item=self.unowned_id) self.executeForbidden(data=data, item=self.unowned_id)
@ -132,7 +133,9 @@ class TestOssOperations(EndpointTester):
}, },
'layout': self.layout_data, 'layout': self.layout_data,
'position_x': 1, 'position_x': 1,
'position_y': 1 'position_y': 1,
'width': 500,
'height': 50
} }
self.executeBadData(data=data, item=self.owned_id) self.executeBadData(data=data, item=self.owned_id)
@ -160,6 +163,8 @@ class TestOssOperations(EndpointTester):
'layout': self.layout_data, 'layout': self.layout_data,
'position_x': 1, 'position_x': 1,
'position_y': 1, 'position_y': 1,
'width': 500,
'height': 50,
'arguments': [self.operation1.pk, self.operation3.pk] 'arguments': [self.operation1.pk, self.operation3.pk]
} }
response = self.executeCreated(data=data, item=self.owned_id) response = self.executeCreated(data=data, item=self.owned_id)
@ -185,7 +190,9 @@ class TestOssOperations(EndpointTester):
}, },
'layout': self.layout_data, 'layout': self.layout_data,
'position_x': 1, 'position_x': 1,
'position_y': 1 'position_y': 1,
'width': 500,
'height': 50
} }
response = self.executeCreated(data=data, item=self.owned_id) response = self.executeCreated(data=data, item=self.owned_id)
new_operation = response.data['new_operation'] new_operation = response.data['new_operation']
@ -207,7 +214,9 @@ class TestOssOperations(EndpointTester):
'create_schema': True, 'create_schema': True,
'layout': self.layout_data, 'layout': self.layout_data,
'position_x': 1, 'position_x': 1,
'position_y': 1 'position_y': 1,
'width': 500,
'height': 50
} }
self.executeBadData(data=data, item=self.owned_id) self.executeBadData(data=data, item=self.owned_id)
data['item_data']['result'] = None data['item_data']['result'] = None
@ -244,7 +253,7 @@ class TestOssOperations(EndpointTester):
self.login() self.login()
response = self.executeOK(data=data) response = self.executeOK(data=data)
layout = response.data['layout'] layout = response.data['layout']
deleted_items = [item for item in layout['operations'] if item['id'] == data['target']] deleted_items = [item for item in layout if item['nodeID'] == 'o' + str(data['target'])]
self.assertEqual(len(response.data['operations']), 2) self.assertEqual(len(response.data['operations']), 2)
self.assertEqual(len(deleted_items), 0) self.assertEqual(len(deleted_items), 0)

View File

@ -55,11 +55,11 @@ class TestOssViewset(EndpointTester):
alias='3', alias='3',
operation_type=OperationType.SYNTHESIS operation_type=OperationType.SYNTHESIS
) )
self.layout_data = {'operations': [ self.layout_data = [
{'id': self.operation1.pk, 'x': 0, 'y': 0}, {'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'id': self.operation2.pk, 'x': 0, 'y': 0}, {'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'id': self.operation3.pk, 'x': 0, 'y': 0}, {'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}
], 'blocks': []} ]
layout = self.owned.layout() layout = self.owned.layout()
layout.data = self.layout_data layout.data = self.layout_data
layout.save() layout.save()
@ -107,10 +107,9 @@ class TestOssViewset(EndpointTester):
self.assertEqual(arguments[1]['argument'], self.operation2.pk) self.assertEqual(arguments[1]['argument'], self.operation2.pk)
layout = response.data['layout'] layout = response.data['layout']
self.assertEqual(layout['blocks'], []) self.assertEqual(layout[0], self.layout_data[0])
self.assertEqual(layout['operations'][0], {'id': self.operation1.pk, 'x': 0, 'y': 0}) self.assertEqual(layout[1], self.layout_data[1])
self.assertEqual(layout['operations'][1], {'id': self.operation2.pk, 'x': 0, 'y': 0}) self.assertEqual(layout[2], self.layout_data[2])
self.assertEqual(layout['operations'][2], {'id': self.operation3.pk, 'x': 0, 'y': 0})
self.executeOK(item=self.unowned_id) self.executeOK(item=self.unowned_id)
self.executeForbidden(item=self.private_id) self.executeForbidden(item=self.private_id)
@ -126,23 +125,21 @@ class TestOssViewset(EndpointTester):
self.populateData() self.populateData()
self.executeBadData(item=self.owned_id) self.executeBadData(item=self.owned_id)
data = {'operations': [], 'blocks': []} data = {'data': []}
self.executeOK(data=data) self.executeOK(data=data)
data = { data = {'data': [
'operations': [ {'nodeID': 'o' + str(self.operation1.pk), 'x': 42.1, 'y': 1337, 'width': 150, 'height': 40},
{'id': self.operation1.pk, 'x': 42.1, 'y': 1337}, {'nodeID': 'o' + str(self.operation2.pk), 'x': 36.1, 'y': 1437, 'width': 150, 'height': 40},
{'id': self.operation2.pk, 'x': 36.1, 'y': 1437}, {'nodeID': 'o' + str(self.operation3.pk), 'x': 36.1, 'y': 1435, 'width': 150, 'height': 40}
{'id': self.operation3.pk, 'x': 36.1, 'y': 1435} ]}
], 'blocks': []
}
self.toggle_admin(True) self.toggle_admin(True)
self.executeOK(data=data, item=self.unowned_id) self.executeOK(data=data, item=self.unowned_id)
self.toggle_admin(False) self.toggle_admin(False)
self.executeOK(data=data, item=self.owned_id) self.executeOK(data=data, item=self.owned_id)
self.owned.refresh_from_db() self.owned.refresh_from_db()
self.assertEqual(self.owned.layout().data, data) self.assertEqual(self.owned.layout().data, data['data'])
self.executeForbidden(data=data, item=self.unowned_id) self.executeForbidden(data=data, item=self.unowned_id)
self.executeForbidden(data=data, item=self.private_id) self.executeForbidden(data=data, item=self.private_id)

View File

@ -91,7 +91,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
''' Endpoint: Update schema layout. ''' ''' Endpoint: Update schema layout. '''
serializer = s.LayoutSerializer(data=request.data) serializer = s.LayoutSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
m.OperationSchema(self.get_object()).update_layout(serializer.validated_data) m.OperationSchema(self.get_object()).update_layout(serializer.validated_data['data'])
return Response(status=c.HTTP_200_OK) return Response(status=c.HTTP_200_OK)
@extend_schema( @extend_schema(
@ -120,8 +120,8 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
children_operations: list[m.Operation] = serializer.validated_data['children_operations'] children_operations: list[m.Operation] = serializer.validated_data['children_operations']
with transaction.atomic(): with transaction.atomic():
new_block = oss.create_block(**serializer.validated_data['item_data']) new_block = oss.create_block(**serializer.validated_data['item_data'])
layout['blocks'].append({ layout.append({
'id': new_block.pk, 'nodeID': 'b' + str(new_block.pk),
'x': serializer.validated_data['position_x'], 'x': serializer.validated_data['position_x'],
'y': serializer.validated_data['position_y'], 'y': serializer.validated_data['position_y'],
'width': serializer.validated_data['width'], 'width': serializer.validated_data['width'],
@ -205,7 +205,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
oss = m.OperationSchema(self.get_object()) oss = m.OperationSchema(self.get_object())
block = cast(m.Block, serializer.validated_data['target']) block = cast(m.Block, serializer.validated_data['target'])
layout = serializer.validated_data['layout'] layout = serializer.validated_data['layout']
layout['blocks'] = [x for x in layout['blocks'] if x['id'] != block.pk] layout = [x for x in layout if x['nodeID'] != 'b' + str(block.pk)]
with transaction.atomic(): with transaction.atomic():
oss.delete_block(block) oss.delete_block(block)
oss.update_layout(layout) oss.update_layout(layout)
@ -274,10 +274,12 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
layout = serializer.validated_data['layout'] layout = serializer.validated_data['layout']
with transaction.atomic(): with transaction.atomic():
new_operation = oss.create_operation(**serializer.validated_data['item_data']) new_operation = oss.create_operation(**serializer.validated_data['item_data'])
layout['operations'].append({ layout.append({
'id': new_operation.pk, 'nodeID': 'o' + str(new_operation.pk),
'x': serializer.validated_data['position_x'], 'x': serializer.validated_data['position_x'],
'y': serializer.validated_data['position_y'] 'y': serializer.validated_data['position_y'],
'width': serializer.validated_data['width'],
'height': serializer.validated_data['height']
}) })
oss.update_layout(layout) oss.update_layout(layout)
@ -342,7 +344,9 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
operation.title = serializer.validated_data['item_data']['title'] operation.title = serializer.validated_data['item_data']['title']
if 'description' in serializer.validated_data['item_data']: if 'description' in serializer.validated_data['item_data']:
operation.description = serializer.validated_data['item_data']['description'] operation.description = serializer.validated_data['item_data']['description']
operation.save(update_fields=['alias', 'title', 'description']) if 'parent' in serializer.validated_data['item_data']:
operation.parent = serializer.validated_data['item_data']['parent']
operation.save(update_fields=['alias', 'title', 'description', 'parent'])
if operation.result is not None: if operation.result is not None:
can_edit = permissions.can_edit_item(request.user, operation.result) can_edit = permissions.can_edit_item(request.user, operation.result)
@ -384,7 +388,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
operation = cast(m.Operation, serializer.validated_data['target']) operation = cast(m.Operation, serializer.validated_data['target'])
old_schema = operation.result old_schema = operation.result
layout = serializer.validated_data['layout'] layout = serializer.validated_data['layout']
layout['operations'] = [x for x in layout['operations'] if x['id'] != operation.pk] layout = [x for x in layout if x['nodeID'] != 'o' + str(operation.pk)]
with transaction.atomic(): with transaction.atomic():
oss.delete_operation(operation.pk, serializer.validated_data['keep_constituents']) oss.delete_operation(operation.pk, serializer.validated_data['keep_constituents'])
oss.update_layout(layout) oss.update_layout(layout)

View File

@ -7475,36 +7475,44 @@
"pk": 1, "pk": 1,
"fields": { "fields": {
"oss": 41, "oss": 41,
"data": { "data": [
"blocks": [],
"operations": [
{ {
"x": 530.0, "x": 530.0,
"y": 370.0, "y": 370.0,
"id": 1 "nodeID": "o1",
"width": 150.0,
"height": 40.0
}, },
{ {
"x": 710.0, "x": 710.0,
"y": 370.0, "y": 370.0,
"id": 2 "nodeID": "o2",
"width": 150.0,
"height": 40.0
}, },
{ {
"x": 890.0, "x": 890.0,
"y": 370.0, "y": 370.0,
"id": 4 "nodeID": "o4",
"width": 150.0,
"height": 40.0
}, },
{ {
"x": 620.0, "x": 620.0,
"y": 470.0, "y": 470.0,
"id": 9 "nodeID": "o9",
"width": 150.0,
"height": 40.0
}, },
{ {
"x": 760.0, "x": 760.0,
"y": 570.0, "y": 570.0,
"id": 10 "nodeID": "o10",
"width": 150.0,
"height": 40.0
} }
] ]
} }
} }
}
] ]

View File

@ -29,6 +29,18 @@
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script>
try {
const preferences = JSON.parse(localStorage.getItem('portal.preferences') || '{}').state;
const isDark = preferences ? preferences.darkMode : window.matchMedia('(prefers-color-scheme: dark)').matches;
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
document.documentElement.setAttribute('data-color-scheme', isDark ? 'dark' : 'light');
} catch (e) {}
</script>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

View File

@ -14,73 +14,73 @@
"preview": "vite preview --port 3000" "preview": "vite preview --port 3000"
}, },
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.5",
"@hookform/resolvers": "^5.0.1", "@hookform/resolvers": "^5.1.1",
"@lezer/lr": "^1.4.2", "@lezer/lr": "^1.4.2",
"@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-select": "^2.2.5", "@radix-ui/react-select": "^2.2.5",
"@tanstack/react-query": "^5.80.5", "@tanstack/react-query": "^5.81.5",
"@tanstack/react-query-devtools": "^5.80.5", "@tanstack/react-query-devtools": "^5.81.5",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@uiw/codemirror-themes": "^4.23.12", "@uiw/codemirror-themes": "^4.23.14",
"@uiw/react-codemirror": "^4.23.12", "@uiw/react-codemirror": "^4.23.14",
"axios": "^1.9.0", "axios": "^1.10.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"global": "^4.4.0", "global": "^4.4.0",
"js-file-download": "^0.4.12", "js-file-download": "^0.4.12",
"lucide-react": "^0.511.0", "lucide-react": "^0.525.0",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-error-boundary": "^6.0.0", "react-error-boundary": "^6.0.0",
"react-hook-form": "^7.57.0", "react-hook-form": "^7.59.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-intl": "^7.1.11", "react-intl": "^7.1.11",
"react-router": "^7.6.2", "react-router": "^7.6.3",
"react-scan": "^0.3.4", "react-scan": "^0.3.6",
"react-tabs": "^6.1.0", "react-tabs": "^6.1.0",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"react-tooltip": "^5.28.1", "react-tooltip": "^5.29.1",
"react-zoom-pan-pinch": "^3.7.0", "react-zoom-pan-pinch": "^3.7.0",
"reactflow": "^11.11.4", "reactflow": "^11.11.4",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.3.4", "tw-animate-css": "^1.3.4",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.5",
"zod": "^3.25.51", "zod": "^3.25.67",
"zustand": "^5.0.5" "zustand": "^5.0.6"
}, },
"devDependencies": { "devDependencies": {
"@lezer/generator": "^1.7.3", "@lezer/generator": "^1.8.0",
"@playwright/test": "^1.52.0", "@playwright/test": "^1.53.2",
"@tailwindcss/vite": "^4.1.8", "@tailwindcss/vite": "^4.1.11",
"@types/jest": "^29.5.14", "@types/jest": "^30.0.0",
"@types/node": "^22.15.29", "@types/node": "^24.0.8",
"@types/react": "^19.1.6", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@typescript-eslint/eslint-plugin": "^8.0.1", "@typescript-eslint/eslint-plugin": "^8.0.1",
"@typescript-eslint/parser": "^8.0.1", "@typescript-eslint/parser": "^8.0.1",
"@vitejs/plugin-react": "^4.5.1", "@vitejs/plugin-react": "^4.6.0",
"babel-plugin-react-compiler": "^19.1.0-rc.1", "babel-plugin-react-compiler": "^19.1.0-rc.1",
"eslint": "^9.28.0", "eslint": "^9.30.0",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.32.0",
"eslint-plugin-playwright": "^2.2.0", "eslint-plugin-playwright": "^2.2.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.1.0-rc.1", "eslint-plugin-react-compiler": "^19.1.0-rc.1",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"globals": "^16.2.0", "globals": "^16.3.0",
"jest": "^29.7.0", "jest": "^30.0.3",
"stylelint": "^16.20.0", "stylelint": "^16.21.0",
"stylelint-config-recommended": "^16.0.0", "stylelint-config-recommended": "^16.0.0",
"stylelint-config-standard": "^38.0.0", "stylelint-config-standard": "^38.0.0",
"stylelint-config-tailwindcss": "^1.0.0", "stylelint-config-tailwindcss": "^1.0.0",
"tailwindcss": "^4.0.7", "tailwindcss": "^4.0.7",
"ts-jest": "^29.3.4", "ts-jest": "^29.4.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.33.1", "typescript-eslint": "^8.35.1",
"vite": "^6.3.5" "vite": "^7.0.0"
}, },
"jest": { "jest": {
"preset": "ts-jest", "preset": "ts-jest",

View File

@ -14,10 +14,10 @@ export function Footer() {
)} )}
> >
<nav className='flex gap-3' aria-label='Вторичная навигация'> <nav className='flex gap-3' aria-label='Вторичная навигация'>
<TextURL text='Библиотека' href='/library' color='' /> <TextURL text='Библиотека' href='/library' color='hover:text-foreground' />
<TextURL text='Справка' href='/manuals' color='' /> <TextURL text='Справка' href='/manuals' color='hover:text-foreground' />
<TextURL text='Центр Концепт' href={external_urls.concept} color='' /> <TextURL text='Центр Концепт' href={external_urls.concept} color='hover:text-foreground' />
<TextURL text='Экстеор' href='/manuals?topic=exteor' color='' /> <TextURL text='Экстеор' href='/manuals?topic=exteor' color='hover:text-foreground' />
</nav> </nav>
<p>© 2025 ЦИВТ КОНЦЕПТ</p> <p>© 2025 ЦИВТ КОНЦЕПТ</p>

View File

@ -10,8 +10,9 @@ export const GlobalTooltips = () => {
float float
id={globalIDs.tooltip} id={globalIDs.tooltip}
layer='z-topmost' layer='z-topmost'
place='right-start' place='bottom-start'
className='mt-8 max-w-80 break-words rounded-lg!' offset={24}
className='max-w-80 break-words rounded-lg! select-none'
/> />
<Tooltip <Tooltip
float float

View File

@ -25,8 +25,12 @@ export function ToggleNavigation() {
data-tooltip-content={noNavigationAnimation ? 'Показать навигацию' : 'Скрыть навигацию'} data-tooltip-content={noNavigationAnimation ? 'Показать навигацию' : 'Скрыть навигацию'}
aria-label={noNavigationAnimation ? 'Показать навигацию' : 'Скрыть навигацию'} aria-label={noNavigationAnimation ? 'Показать навигацию' : 'Скрыть навигацию'}
> >
{!noNavigationAnimation ? <IconPin size='0.75rem' /> : null} {!noNavigationAnimation ? (
{noNavigationAnimation ? <IconUnpin size='0.75rem' /> : null} <IconPin size='0.75rem' className='hover:text-primary cc-animate-color cc-hover-pulse' />
) : null}
{noNavigationAnimation ? (
<IconUnpin size='0.75rem' className='hover:text-primary cc-animate-color cc-hover-pulse' />
) : null}
</button> </button>
{!noNavigationAnimation ? ( {!noNavigationAnimation ? (
<button <button
@ -38,8 +42,12 @@ export function ToggleNavigation() {
data-tooltip-content={darkMode ? 'Тема: Темная' : 'Тема: Светлая'} data-tooltip-content={darkMode ? 'Тема: Темная' : 'Тема: Светлая'}
aria-label={darkMode ? 'Тема: Темная' : 'Тема: Светлая'} aria-label={darkMode ? 'Тема: Темная' : 'Тема: Светлая'}
> >
{darkMode ? <IconDarkTheme size='0.75rem' /> : null} {darkMode ? (
{!darkMode ? <IconLightTheme size='0.75rem' /> : null} <IconDarkTheme size='0.75rem' className='hover:text-primary cc-animate-color cc-hover-pulse' />
) : null}
{!darkMode ? (
<IconLightTheme size='0.75rem' className='hover:text-primary cc-animate-color cc-hover-pulse' />
) : null}
</button> </button>
) : null} ) : null}
</div> </div>

View File

@ -18,7 +18,6 @@ export function UserButton({ onLogin, onClickUser, isOpen }: UserButtonProps) {
if (isAnonymous) { if (isAnonymous) {
return ( return (
<NavigationButton <NavigationButton
className='cc-fade-in'
title='Перейти на страницу логина' title='Перейти на страницу логина'
icon={<IconLogin size='1.5rem' className='icon-primary' />} icon={<IconLogin size='1.5rem' className='icon-primary' />}
onClick={onLogin} onClick={onLogin}
@ -27,7 +26,6 @@ export function UserButton({ onLogin, onClickUser, isOpen }: UserButtonProps) {
} else { } else {
return ( return (
<NavigationButton <NavigationButton
className='cc-fade-in'
title='Пользователь' title='Пользователь'
hideTitle={isOpen} hideTitle={isOpen}
aria-haspopup='true' aria-haspopup='true'

View File

@ -40,7 +40,7 @@ export function Button({
className={cn( className={cn(
'inline-flex gap-2 items-center justify-center', 'inline-flex gap-2 items-center justify-center',
'font-medium select-none disabled:cursor-auto disabled:opacity-75', 'font-medium select-none disabled:cursor-auto disabled:opacity-75',
'bg-secondary text-secondary-foreground cc-hover cc-animate-color', 'bg-secondary text-secondary-foreground cc-hover-bg cc-animate-color',
dense ? 'px-1' : 'px-3 py-1', dense ? 'px-1' : 'px-3 py-1',
loading ? 'cursor-progress' : 'cursor-pointer', loading ? 'cursor-progress' : 'cursor-pointer',
noOutline ? 'outline-hidden focus-visible:bg-selected' : 'focus-outline', noOutline ? 'outline-hidden focus-visible:bg-selected' : 'focus-outline',

View File

@ -1,8 +1,7 @@
import clsx from 'clsx';
import { globalIDs } from '@/utils/constants'; import { globalIDs } from '@/utils/constants';
import { type Button } from '../props'; import { type Button } from '../props';
import { cn } from '../utils';
interface MiniButtonProps extends Button { interface MiniButtonProps extends Button {
/** Button type. */ /** Button type. */
@ -37,11 +36,12 @@ export function MiniButton({
<button <button
type={type} type={type}
tabIndex={tabIndex ?? -1} tabIndex={tabIndex ?? -1}
className={clsx( className={cn(
'rounded-lg', 'rounded-lg',
'cc-controls cc-animate-background', 'text-muted-foreground cc-animate-color',
'cursor-pointer disabled:cursor-auto', 'cursor-pointer disabled:cursor-auto disabled:opacity-75',
noHover ? 'outline-hidden' : 'cc-hover', (!tabIndex || tabIndex === -1) && 'outline-hidden',
!noHover && 'cc-hover-pulse',
!noPadding && 'px-1 py-1', !noPadding && 'px-1 py-1',
className className
)} )}

View File

@ -30,9 +30,9 @@ export function SelectorButton({
className={cn( className={cn(
'px-1 flex flex-start items-center gap-1', 'px-1 flex flex-start items-center gap-1',
'text-sm font-controls select-none', 'text-sm font-controls select-none',
'text-btn cc-controls', 'disabled:cursor-auto cursor-pointer outline-hidden',
'disabled:cursor-auto cursor-pointer', 'text-muted-foreground cc-hover-text cc-animate-color disabled:opacity-75',
'cc-hover cc-animate-color', !text && 'cc-hover-pulse',
className className
)} )}
data-tooltip-id={!!title || !!titleHtml ? globalIDs.tooltip : undefined} data-tooltip-id={!!title || !!titleHtml ? globalIDs.tooltip : undefined}

View File

@ -20,8 +20,10 @@ export function PaginationTools<TData>({
onChangePaginationOption, onChangePaginationOption,
paginationOptions paginationOptions
}: PaginationToolsProps<TData>) { }: PaginationToolsProps<TData>) {
const buttonClass =
'cc-hover-text cc-animate-color focus-outline rounded-md disabled:opacity-75 not-[:disabled]:cursor-pointer';
return ( return (
<div className='flex justify-end items-center my-2 text-sm cc-controls select-none'> <div className='flex justify-end items-center my-2 text-muted-foreground text-sm select-none'>
<span className='mr-3'> <span className='mr-3'>
{`${table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1} {`${table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}
- -
@ -36,7 +38,7 @@ export function PaginationTools<TData>({
<button <button
type='button' type='button'
aria-label='Первая страница' aria-label='Первая страница'
className='cc-hover cc-controls cc-animate-color focus-outline' className={buttonClass}
onClick={() => table.setPageIndex(0)} onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()} disabled={!table.getCanPreviousPage()}
> >
@ -45,7 +47,7 @@ export function PaginationTools<TData>({
<button <button
type='button' type='button'
aria-label='Предыдущая страница' aria-label='Предыдущая страница'
className='cc-hover cc-controls cc-animate-color focus-outline' className={buttonClass}
onClick={() => table.previousPage()} onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()} disabled={!table.getCanPreviousPage()}
> >
@ -55,7 +57,7 @@ export function PaginationTools<TData>({
id={id ? `${id}__page` : undefined} id={id ? `${id}__page` : undefined}
title='Номер страницы. Выделите для ручного ввода' title='Номер страницы. Выделите для ручного ввода'
aria-label='Номер страницы' aria-label='Номер страницы'
className='w-6 text-center bg-transparent focus-outline' className='w-6 text-center bg-transparent focus-outline rounded-md'
value={table.getState().pagination.pageIndex + 1} value={table.getState().pagination.pageIndex + 1}
onChange={event => { onChange={event => {
const page = event.target.value ? Number(event.target.value) - 1 : 0; const page = event.target.value ? Number(event.target.value) - 1 : 0;
@ -67,7 +69,7 @@ export function PaginationTools<TData>({
<button <button
type='button' type='button'
aria-label='Следующая страница' aria-label='Следующая страница'
className='cc-hover cc-controls cc-animate-color focus-outline' className={buttonClass}
onClick={() => table.nextPage()} onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()} disabled={!table.getCanNextPage()}
> >
@ -76,7 +78,7 @@ export function PaginationTools<TData>({
<button <button
type='button' type='button'
aria-label='Последняя страница' aria-label='Последняя страница'
className='cc-hover cc-controls cc-animate-color focus-outline' className={buttonClass}
onClick={() => table.setPageIndex(table.getPageCount() - 1)} onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()} disabled={!table.getCanNextPage()}
> >

View File

@ -3,6 +3,7 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { type Table } from '@tanstack/react-table'; import { type Table } from '@tanstack/react-table';
import clsx from 'clsx';
import { prefixes } from '@/utils/constants'; import { prefixes } from '@/utils/constants';
@ -30,7 +31,12 @@ export function SelectPagination<TData>({ id, table, paginationOptions, onChange
<SelectTrigger <SelectTrigger
id={id} id={id}
aria-label='Выбор количества строчек на странице' aria-label='Выбор количества строчек на странице'
className='mx-2 cursor-pointer bg-transparent focus-outline border-0 w-28 max-h-6 px-2 justify-end' className={clsx(
'w-28 max-h-6 mx-2',
'px-2 justify-end',
'bg-transparent cc-hover-text cc-animate-color focus-outline border-0 rounded-md',
'cursor-pointer'
)}
> >
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>

View File

@ -70,7 +70,7 @@ export function TableRow<TData>({
<tr <tr
className={cn( className={cn(
'cc-scroll-row', 'cc-scroll-row',
'cc-hover cc-animate-background duration-fade', 'cc-hover-bg cc-animate-background duration-fade',
!noHeader && 'scroll-mt-[calc(2px+2rem)]', !noHeader && 'scroll-mt-[calc(2px+2rem)]',
table.options.enableRowSelection && row.getIsSelected() table.options.enableRowSelection && row.getIsSelected()
? 'cc-selected' ? 'cc-selected'

View File

@ -36,9 +36,9 @@ export function DropdownButton({
className={cn( className={cn(
'px-3 py-1 inline-flex items-center gap-2', 'px-3 py-1 inline-flex items-center gap-2',
'text-left text-sm text-ellipsis whitespace-nowrap', 'text-left text-sm text-ellipsis whitespace-nowrap',
'disabled:cc-controls disabled:opacity-75', 'disabled:text-muted-foreground disabled:opacity-75',
'focus-outline cc-animate-background', 'focus-outline cc-animate-background',
!!onClick ? 'cc-hover cursor-pointer disabled:cursor-auto' : 'bg-secondary text-secondary-foreground', !!onClick ? 'cc-hover-bg cursor-pointer disabled:cursor-auto' : 'bg-secondary text-secondary-foreground',
className className
)} )}
data-tooltip-id={!!title || !!titleHtml ? globalIDs.tooltip : undefined} data-tooltip-id={!!title || !!titleHtml ? globalIDs.tooltip : undefined}

View File

@ -25,6 +25,10 @@ export { LuQrCode as IconQR } from 'react-icons/lu';
export { LuFilterX as IconFilterReset } from 'react-icons/lu'; export { LuFilterX as IconFilterReset } from 'react-icons/lu';
export {BiDownArrowCircle as IconOpenList } from 'react-icons/bi'; export {BiDownArrowCircle as IconOpenList } from 'react-icons/bi';
export { LuTriangleAlert as IconAlert } from 'react-icons/lu'; export { LuTriangleAlert as IconAlert } from 'react-icons/lu';
export { LuPanelLeftOpen as IconLeftOpen } from 'react-icons/lu';
export { LuPanelLeftClose as IconLeftClose } from 'react-icons/lu';
export { LuPanelBottomOpen as IconBottomOpen } from 'react-icons/lu';
export { LuPanelBottomClose as IconBottomClose } from 'react-icons/lu';
// ===== UI elements ======= // ===== UI elements =======
export { BiX as IconClose } from 'react-icons/bi'; export { BiX as IconClose } from 'react-icons/bi';
@ -97,9 +101,7 @@ export { LuDatabase as IconDatabase } from 'react-icons/lu';
export { LuView as IconDBStructure } from 'react-icons/lu'; export { LuView as IconDBStructure } from 'react-icons/lu';
export { LuPlaneTakeoff as IconRESTapi } from 'react-icons/lu'; export { LuPlaneTakeoff as IconRESTapi } from 'react-icons/lu';
export { LuImage as IconImage } from 'react-icons/lu'; export { LuImage as IconImage } from 'react-icons/lu';
export { TbColumns as IconList } from 'react-icons/tb';
export { GoVersions as IconVersions } from 'react-icons/go'; export { GoVersions as IconVersions } from 'react-icons/go';
export { TbColumnsOff as IconListOff } from 'react-icons/tb';
export { LuAtSign as IconTerm } from 'react-icons/lu'; export { LuAtSign as IconTerm } from 'react-icons/lu';
export { LuSubscript as IconAlias } from 'react-icons/lu'; export { LuSubscript as IconAlias } from 'react-icons/lu';
export { TbMathFunction as IconFormula } from 'react-icons/tb'; export { TbMathFunction as IconFormula } from 'react-icons/tb';
@ -107,7 +109,8 @@ export { BiFontFamily as IconText } from 'react-icons/bi';
export { BiFont as IconTextOff } from 'react-icons/bi'; export { BiFont as IconTextOff } from 'react-icons/bi';
export { TbCircleLetterM as IconTypeGraph } from 'react-icons/tb'; export { TbCircleLetterM as IconTypeGraph } from 'react-icons/tb';
export { RiTreeLine as IconTree } from 'react-icons/ri'; export { RiTreeLine as IconTree } from 'react-icons/ri';
export { FaRegKeyboard as IconControls } from 'react-icons/fa6'; export { LuKeyboard as IconKeyboard } from 'react-icons/lu';
export { LuKeyboardOff as IconKeyboardOff } from 'react-icons/lu';
export { RiLockLine as IconImmutable } from 'react-icons/ri'; export { RiLockLine as IconImmutable } from 'react-icons/ri';
export { RiLockUnlockLine as IconMutable } from 'react-icons/ri'; export { RiLockUnlockLine as IconMutable } from 'react-icons/ri';
export { RiOpenSourceLine as IconPublic } from 'react-icons/ri'; export { RiOpenSourceLine as IconPublic } from 'react-icons/ri';
@ -141,7 +144,6 @@ export { GrConnect as IconConnect } from 'react-icons/gr';
export { BiPlayCircle as IconExecute } from 'react-icons/bi'; export { BiPlayCircle as IconExecute } from 'react-icons/bi';
// ======== Graph UI ======= // ======== Graph UI =======
export { LuLayoutDashboard as IconFixLayout } from 'react-icons/lu';
export { BiCollapse as IconGraphCollapse } from 'react-icons/bi'; export { BiCollapse as IconGraphCollapse } from 'react-icons/bi';
export { BiExpand as IconGraphExpand } from 'react-icons/bi'; export { BiExpand as IconGraphExpand } from 'react-icons/bi';
export { LuMaximize as IconGraphMaximize } from 'react-icons/lu'; export { LuMaximize as IconGraphMaximize } from 'react-icons/lu';

View File

@ -92,7 +92,12 @@ export function ComboBox<Option>({
hidden={hidden && !open} hidden={hidden && !open}
> >
<span className='truncate'>{value ? labelValueFunc(value) : placeholder}</span> <span className='truncate'>{value ? labelValueFunc(value) : placeholder}</span>
<ChevronDownIcon className={cn('text-muted-foreground', clearable && !!value && 'opacity-0')} /> <ChevronDownIcon
className={cn(
'text-muted-foreground cc-hover-pulse hover:text-primary',
clearable && !!value && 'opacity-0'
)}
/>
{clearable && !!value ? ( {clearable && !!value ? (
<IconRemove <IconRemove
tabIndex={-1} tabIndex={-1}

View File

@ -115,7 +115,7 @@ export function ComboMulti<Option>({
<IconRemove <IconRemove
tabIndex={-1} tabIndex={-1}
size='1rem' size='1rem'
className='cc-remove absolute pointer-events-auto right-3' className='cc-remove absolute pointer-events-auto right-3 cc-hover-pulse hover:text-primary'
onClick={handleClear} onClick={handleClear}
/> />
) : null} ) : null}

View File

@ -1,7 +1,7 @@
import { type FieldError, type GlobalError } from 'react-hook-form'; import { type FieldError, type GlobalError } from 'react-hook-form';
import clsx from 'clsx';
import { type Styling } from '../props'; import { type Styling } from '../props';
import { cn } from '../utils';
interface ErrorFieldProps extends Styling { interface ErrorFieldProps extends Styling {
error?: FieldError | GlobalError; error?: FieldError | GlobalError;
@ -15,7 +15,7 @@ export function ErrorField({ error, className, ...restProps }: ErrorFieldProps):
return null; return null;
} }
return ( return (
<div className={clsx('text-sm text-destructive select-none', className)} {...restProps}> <div className={cn('text-sm text-destructive select-none', className)} {...restProps}>
{error.message} {error.message}
</div> </div>
); );

View File

@ -90,7 +90,7 @@ export function SelectTree<ItemType>({
<div <div
key={`${prefix}${index}`} key={`${prefix}${index}`}
className={clsx( className={clsx(
'cc-tree-item relative cc-scroll-row cc-hover', 'cc-tree-item relative cc-scroll-row cc-hover-bg',
isActive ? 'max-h-7 py-1 border-b' : 'max-h-0 opacity-0 pointer-events-none', isActive ? 'max-h-7 py-1 border-b' : 'max-h-0 opacity-0 pointer-events-none',
value === item && 'cc-selected' value === item && 'cc-selected'
)} )}
@ -101,9 +101,8 @@ export function SelectTree<ItemType>({
{foldable.has(item) ? ( {foldable.has(item) ? (
<MiniButton <MiniButton
aria-label={!folded.includes(item) ? 'Свернуть' : 'Развернуть'} aria-label={!folded.includes(item) ? 'Свернуть' : 'Развернуть'}
className={clsx('absolute left-1', !folded.includes(item) ? 'top-1.5' : 'top-1')} className={clsx('absolute left-1 hover:text-primary', !folded.includes(item) ? 'top-1.5' : 'top-1')}
noPadding noPadding
noHover
icon={!folded.includes(item) ? <IconDropArrow size='1rem' /> : <IconPageRight size='1.25rem' />} icon={!folded.includes(item) ? <IconDropArrow size='1rem' /> : <IconPageRight size='1.25rem' />}
onClick={event => handleClickFold(event, item)} onClick={event => handleClickFold(event, item)}
/> />

View File

@ -46,7 +46,7 @@ function SelectTrigger({
> >
{children} {children}
<SelectPrimitive.Icon asChild> <SelectPrimitive.Icon asChild>
<ChevronDownIcon className='size-4' /> <ChevronDownIcon className='size-4 cc-hover-pulse hover:text-primary' />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
); );
@ -154,7 +154,7 @@ function SelectScrollDownButton({
className={cn('flex cursor-default items-center justify-center py-1', className)} className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props} {...props}
> >
<ChevronDownIcon className='size-4' /> <ChevronDownIcon className='size-4 cc-hover-pulse hover:text-primary' />
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
); );
} }

View File

@ -8,7 +8,7 @@ export function ModalBackdrop({ onHide }: ModalBackdropProps) {
return ( return (
<> <>
<div className='z-bottom fixed inset-0 backdrop-blur-[3px] opacity-50' /> <div className='z-bottom fixed inset-0 backdrop-blur-[3px] opacity-50' />
<div className='z-bottom fixed inset-0 bg-popover opacity-25' onClick={onHide} /> <div className='z-bottom fixed inset-0 bg-foreground opacity-5' onClick={onHide} />
</> </>
); );
} }

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { type HelpTopic } from '@/features/help'; import { type HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components'; import { BadgeHelp } from '@/features/help/components/badge-help';
import { useEscapeKey } from '@/hooks/use-escape-key'; import { useEscapeKey } from '@/hooks/use-escape-key';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
@ -89,7 +89,7 @@ export function ModalForm({
<div className='cc-modal-wrapper'> <div className='cc-modal-wrapper'>
<ModalBackdrop onHide={handleCancel} /> <ModalBackdrop onHide={handleCancel} />
<form <form
className='cc-animate-modal relative grid border rounded-xl bg-background' className='cc-animate-modal relative grid border-2 px-1 pb-1 rounded-xl bg-background'
role='dialog' role='dialog'
onSubmit={handleSubmit} onSubmit={handleSubmit}
aria-labelledby='modal-title' aria-labelledby='modal-title'

View File

@ -6,7 +6,7 @@ export function ModalLoader() {
return ( return (
<div className='cc-modal-wrapper'> <div className='cc-modal-wrapper'>
<ModalBackdrop /> <ModalBackdrop />
<div className='cc-animate-modal p-20 border rounded-xl bg-background'> <div className='cc-animate-modal p-20 border-2 rounded-xl bg-background'>
<Loader circular scale={6} /> <Loader circular scale={6} />
</div> </div>
</div> </div>

View File

@ -2,7 +2,7 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { BadgeHelp } from '@/features/help/components'; import { BadgeHelp } from '@/features/help/components/badge-help';
import { useEscapeKey } from '@/hooks/use-escape-key'; import { useEscapeKey } from '@/hooks/use-escape-key';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
@ -39,7 +39,7 @@ export function ModalView({
return ( return (
<div className='cc-modal-wrapper'> <div className='cc-modal-wrapper'>
<ModalBackdrop onHide={hideDialog} /> <ModalBackdrop onHide={hideDialog} />
<div className='cc-animate-modal relative grid border rounded-xl bg-background' role='dialog'> <div className='cc-animate-modal relative grid border-2 px-1 pb-1 rounded-xl bg-background' role='dialog'>
{helpTopic && !hideHelpWhen?.() ? ( {helpTopic && !hideHelpWhen?.() ? (
<BadgeHelp <BadgeHelp
topic={helpTopic} topic={helpTopic}

View File

@ -22,6 +22,7 @@ export function TabLabel({
className, className,
disabled, disabled,
role = 'tab', role = 'tab',
selectedClassName = 'text-foreground! bg-secondary',
...otherProps ...otherProps
}: TabLabelProps) { }: TabLabelProps) {
return ( return (
@ -29,12 +30,12 @@ export function TabLabel({
className={clsx( className={clsx(
'min-w-20 h-full', 'min-w-20 h-full',
'px-2 py-1 flex justify-center', 'px-2 py-1 flex justify-center',
'cc-animate-color duration-select', 'cc-animate-color duration-select text-muted-foreground',
'text-sm whitespace-nowrap font-controls', 'text-sm whitespace-nowrap font-controls',
'select-none', 'select-none',
'outline-hidden', 'outline-hidden',
!disabled && 'hover:cursor-pointer cc-hover', !disabled && 'hover:cursor-pointer cc-hover-text',
disabled && 'text-muted-foreground', disabled && 'bg-secondary',
className className
)} )}
tabIndex='-1' tabIndex='-1'
@ -44,6 +45,7 @@ export function TabLabel({
data-tooltip-hidden={hideTitle} data-tooltip-hidden={hideTitle}
role={role} role={role}
disabled={disabled} disabled={disabled}
selectedClassName={selectedClassName}
{...otherProps} {...otherProps}
> >
{label} {label}

View File

@ -1,8 +1,8 @@
import clsx from 'clsx';
import { type Styling, type Titled } from '@/components/props'; import { type Styling, type Titled } from '@/components/props';
import { globalIDs } from '@/utils/constants'; import { globalIDs } from '@/utils/constants';
import { cn } from '../utils';
interface IndicatorProps extends Titled, Styling { interface IndicatorProps extends Titled, Styling {
/** Icon to display. */ /** Icon to display. */
icon: React.ReactNode; icon: React.ReactNode;
@ -17,8 +17,8 @@ interface IndicatorProps extends Titled, Styling {
export function Indicator({ icon, title, titleHtml, hideTitle, noPadding, className, ...restProps }: IndicatorProps) { export function Indicator({ icon, title, titleHtml, hideTitle, noPadding, className, ...restProps }: IndicatorProps) {
return ( return (
<div <div
className={clsx( className={cn(
'cc-controls', // 'text-muted-foreground', //
'outline-hidden', 'outline-hidden',
!noPadding && 'px-1 py-1', !noPadding && 'px-1 py-1',
className className

View File

@ -57,7 +57,7 @@ export function ValueIcon({
data-tooltip-hidden={hideTitle} data-tooltip-hidden={hideTitle}
aria-label={title} aria-label={title}
> >
{onClick ? <MiniButton noHover noPadding icon={icon} onClick={onClick} disabled={disabled} /> : icon} {onClick ? <MiniButton noPadding icon={icon} onClick={onClick} disabled={disabled} /> : icon}
<span id={id}>{value}</span> <span id={id}>{value}</span>
</div> </div>
); );

View File

@ -1,2 +0,0 @@
export { ExpectedAnonymous } from './expected-anonymous';
export { RequireAuth } from './require-auth';

View File

@ -42,7 +42,7 @@ export function BadgeHelp({ topic, padding = 'p-1', className, contentClass, sty
} }
return ( return (
<div tabIndex={-1} id={`help-${topic}`} className={cn(padding, className)} style={style}> <div tabIndex={-1} id={`help-${topic}`} className={cn(padding, className)} style={style}>
<IconHelp size='1.25rem' className='icon-primary' /> <IconHelp size='1.25rem' className='text-muted-foreground hover:text-primary cc-animate-color' />
<Tooltip <Tooltip
clickable clickable
anchorSelect={`#help-${topic}`} anchorSelect={`#help-${topic}`}

View File

@ -1 +1 @@
export { BadgeHelp } from './badge-help';

View File

@ -12,26 +12,30 @@ export function HelpFormulaTree() {
</ul> </ul>
<h2>Виды узлов</h2> <h2>Виды узлов</h2>
<ul> <p className='m-0'>
<li> <span className='cc-sample-color bg-(--acc-bg-green)' />
<span className='bg-(--acc-bg-green)'>объявление идентификатора</span> объявление идентификатора
</li> </p>
<li> <p className='m-0'>
<span className='bg-(--acc-bg-teal)'>глобальный идентификатор</span> <span className='cc-sample-color bg-(--acc-bg-teal)' />
</li> глобальный идентификатор
<li> </p>
<span className='bg-(--acc-bg-orange)'>логическое выражение</span> <p className='m-0'>
</li> <span className='cc-sample-color bg-(--acc-bg-orange)' />
<li> логическое выражение
<span className='bg-(--acc-bg-blue)'>типизированное выражение</span> </p>
</li> <p className='m-0'>
<li> <span className='cc-sample-color bg-(--acc-bg-blue)' />
<span className='bg-(--acc-bg-red)'>присвоение и итерация</span> типизированное выражение
</li> </p>
<li> <p className='m-0'>
<span className='bg-secondary'>составные выражения</span> <span className='cc-sample-color bg-(--acc-bg-red)' />
</li> присвоение и итерация
</ul> </p>
<p className='m-0'>
<span className='cc-sample-color bg-secondary' />
составные выражения
</p>
<h2>Команды</h2> <h2>Команды</h2>
<ul> <ul>

View File

@ -62,7 +62,7 @@ export function HelpLibrary() {
<IconFilterReset size='1rem' className='inline-icon' /> сбросить фильтры <IconFilterReset size='1rem' className='inline-icon' /> сбросить фильтры
</li> </li>
<li> <li>
<IconFolderTree size='1rem' className='inline-icon' /> переключение между Проводник и Поиск <IconFolderTree size='1rem' className='inline-icon' /> переключение между Проводник и Таблица
</li> </li>
</ul> </ul>

View File

@ -11,8 +11,8 @@ import {
IconEdit2, IconEdit2,
IconExecute, IconExecute,
IconFitImage, IconFitImage,
IconFixLayout,
IconGrid, IconGrid,
IconLeftOpen,
IconLineStraight, IconLineStraight,
IconLineWave, IconLineWave,
IconNewItem, IconNewItem,
@ -31,7 +31,7 @@ export function HelpOssGraph() {
<div className='flex flex-col'> <div className='flex flex-col'>
<h1 className='sm:pr-24'>Граф синтеза</h1> <h1 className='sm:pr-24'>Граф синтеза</h1>
<div className='flex flex-col sm:flex-row'> <div className='flex flex-col sm:flex-row'>
<div className='sm:w-56'> <div className='sm:w-64'>
<h2>Настройка графа</h2> <h2>Настройка графа</h2>
<ul> <ul>
<li> <li>
@ -41,7 +41,7 @@ export function HelpOssGraph() {
<IconFitImage className='inline-icon' /> Вписать в экран <IconFitImage className='inline-icon' /> Вписать в экран
</li> </li>
<li> <li>
<IconFixLayout className='inline-icon' /> Исправить расположения <IconLeftOpen className='inline-icon' /> Панель связанной КС
</li> </li>
<li> <li>
<IconSettings className='inline-icon' /> Диалог настроек <IconSettings className='inline-icon' /> Диалог настроек
@ -70,7 +70,7 @@ export function HelpOssGraph() {
<Divider vertical margins='mx-3 mt-3' className='hidden sm:block' /> <Divider vertical margins='mx-3 mt-3' className='hidden sm:block' />
<div className='sm:w-84'> <div className='sm:w-76'>
<h2>Изменение узлов</h2> <h2>Изменение узлов</h2>
<ul> <ul>
<li> <li>
@ -101,7 +101,7 @@ export function HelpOssGraph() {
<Divider margins='my-2' className='hidden sm:block' /> <Divider margins='my-2' className='hidden sm:block' />
<div className='flex flex-col-reverse mb-3 sm:flex-row'> <div className='flex flex-col-reverse mb-3 sm:flex-row'>
<div className='sm:w-56'> <div className='sm:w-64'>
<h2>Общие</h2> <h2>Общие</h2>
<ul> <ul>
<li> <li>
@ -111,14 +111,14 @@ export function HelpOssGraph() {
<kbd>Space</kbd> перемещение экрана <kbd>Space</kbd> перемещение экрана
</li> </li>
<li> <li>
<kbd>Shift</kbd> перемещение выделенных элементов в границах родителя <kbd>Shift</kbd> перемещение в границах блока
</li> </li>
</ul> </ul>
</div> </div>
<Divider vertical margins='mx-3' className='hidden sm:block' /> <Divider vertical margins='mx-3' className='hidden sm:block' />
<div className='dense w-84'> <div className='dense w-76'>
<h2>Контекстное меню</h2> <h2>Контекстное меню</h2>
<ul> <ul>
<li> <li>

View File

@ -1,9 +1,8 @@
import { import {
IconClone,
IconDestroy, IconDestroy,
IconDownload,
IconEditor, IconEditor,
IconImmutable, IconImmutable,
IconLeftOpen,
IconOSS, IconOSS,
IconOwner, IconOwner,
IconPublic, IconPublic,
@ -16,17 +15,17 @@ import { HelpTopic } from '../../models/help-topic';
export function HelpRSCard() { export function HelpRSCard() {
return ( return (
<div className='dense'> <div className='dense'>
<h1>Карточка схемы</h1> <h1>Паспорт схемы</h1>
<p>Карточка содержит общую информацию и статистику</p> <p>Паспорт содержит общую информацию и статистику</p>
<p> <p>
Карточка позволяет управлять атрибутами и <LinkTopic text='версиями' topic={HelpTopic.VERSIONS} /> Паспорт позволяет управлять атрибутами и <LinkTopic text='версиями' topic={HelpTopic.VERSIONS} />
</p> </p>
<p> <p>
Карточка позволяет назначать <IconEditor className='inline-icon' /> Редакторов Паспорт позволяет назначать <IconEditor className='inline-icon' /> Редакторов
</p> </p>
<p> <p>
Карточка позволяет изменить <IconOwner className='inline-icon icon-green' /> Владельца Паспорт позволяет изменить <IconOwner className='inline-icon icon-green' /> Владельца
</p> </p>
<h2>Управление</h2> <h2>Управление</h2>
@ -49,15 +48,12 @@ export function HelpRSCard() {
<li> <li>
<IconImmutable className='inline-icon' /> Неизменные схемы <IconImmutable className='inline-icon' /> Неизменные схемы
</li> </li>
<li>
<IconClone className='inline-icon icon-green' /> Клонировать создать копию схемы
</li>
<li>
<IconDownload className='inline-icon' /> Загрузить/Выгрузить взаимодействие с Экстеор
</li>
<li> <li>
<IconDestroy className='inline-icon icon-red' /> Удалить полностью удаляет схему из базы Портала <IconDestroy className='inline-icon icon-red' /> Удалить полностью удаляет схему из базы Портала
</li> </li>
<li>
<IconLeftOpen className='inline-icon' /> Отображение статистики
</li>
</ul> </ul>
</div> </div>
); );

View File

@ -1,11 +1,11 @@
import { import {
IconChild, IconChild,
IconClone, IconClone,
IconControls,
IconDestroy, IconDestroy,
IconEdit, IconEdit,
IconFilter, IconFilter,
IconList, IconKeyboard,
IconLeftOpen,
IconMoveDown, IconMoveDown,
IconMoveUp, IconMoveUp,
IconNewItem, IconNewItem,
@ -37,7 +37,7 @@ export function HelpRSEditor() {
<IconPredecessor className='inline-icon' /> переход к исходной <IconPredecessor className='inline-icon' /> переход к исходной
</li> </li>
<li> <li>
<IconList className='inline-icon' /> список конституент <IconLeftOpen className='inline-icon' /> список конституент
</li> </li>
<li> <li>
<IconSave className='inline-icon' /> сохранить: <kbd>Ctrl + S</kbd> <IconSave className='inline-icon' /> сохранить: <kbd>Ctrl + S</kbd>
@ -72,17 +72,16 @@ export function HelpRSEditor() {
<IconChild className='inline-icon' /> отображение наследованных <IconChild className='inline-icon' /> отображение наследованных
</li> </li>
<li> <li>
<span className='bg-selected'>текущая конституента</span> <span className='cc-sample-color bg-selected' />
выбранная конституента
</li> </li>
<li> <li>
<span className='bg-accent-green50'> <span className='cc-sample-color bg-accent-green50' />
<LinkTopic text='основа' topic={HelpTopic.CC_RELATIONS} /> текущей <LinkTopic text='основа' topic={HelpTopic.CC_RELATIONS} /> выбранной
</span>
</li> </li>
<li> <li>
<span className='bg-accent-orange50'> <span className='cc-sample-color bg-accent-orange50' />
<LinkTopic text='порожденные' topic={HelpTopic.CC_RELATIONS} /> текущей <LinkTopic text='порожденные' topic={HelpTopic.CC_RELATIONS} /> выбранной
</span>
</li> </li>
</ul> </ul>
</div> </div>
@ -94,7 +93,7 @@ export function HelpRSEditor() {
<IconStatusOK className='inline-icon' /> индикатор статуса определения сверху <IconStatusOK className='inline-icon' /> индикатор статуса определения сверху
</li> </li>
<li> <li>
<IconControls className='inline-icon' /> специальная клавиатура и горячие клавиши <IconKeyboard className='inline-icon' /> специальная клавиатура и горячие клавиши
</li> </li>
<li> <li>
<IconTypeGraph className='inline-icon' /> отображение{' '} <IconTypeGraph className='inline-icon' /> отображение{' '}

View File

@ -32,18 +32,17 @@ export function HelpRSMenu() {
<h2>Вкладки</h2> <h2>Вкладки</h2>
<ul> <ul>
<li> <li>
<LinkTopic text='Карточка' topic={HelpTopic.UI_RS_CARD} /> редактирование атрибутов схемы и версии <LinkTopic text='Паспорт' topic={HelpTopic.UI_RS_CARD} /> редактирование атрибутов схемы и версии
</li> </li>
<li> <li>
<LinkTopic text='Содержание' topic={HelpTopic.UI_RS_LIST} /> работа со списком конституент в табличной форме <LinkTopic text='Список' topic={HelpTopic.UI_RS_LIST} /> работа со списком конституент в табличной форме
</li> </li>
<li> <li>
<LinkTopic text='Редактор' topic={HelpTopic.UI_RS_EDITOR} /> редактирование отдельной{' '} <LinkTopic text='Понятие' topic={HelpTopic.UI_RS_EDITOR} /> редактирование отдельной{' '}
<LinkTopic text='Конституенты' topic={HelpTopic.CC_CONSTITUENTA} /> <LinkTopic text='Конституенты' topic={HelpTopic.CC_CONSTITUENTA} />
</li> </li>
<li> <li>
<LinkTopic text='Граф термов' topic={HelpTopic.UI_GRAPH_TERM} /> графическое представление связей <LinkTopic text='Граф' topic={HelpTopic.UI_GRAPH_TERM} /> графическое представление связей конституент
конституент
</li> </li>
</ul> </ul>

View File

@ -23,17 +23,18 @@ export function HelpTypeGraph() {
<h2>Цвета узлов</h2> <h2>Цвета узлов</h2>
<ul> <p className='m-0'>
<li> <span className='cc-sample-color bg-secondary' />
<span className='bg-secondary'>ступень-основание</span> ступень-основание
</li> </p>
<li> <p className='m-0'>
<span className='bg-accent-teal'>ступень-булеан</span> <span className='cc-sample-color bg-accent-teal' />
</li> ступень-булеан
<li> </p>
<span className='bg-accent-orange'>ступень декартова произведения</span> <p className='m-0'>
</li> <span className='cc-sample-color bg-accent-orange' />
</ul> ступень декартова произведения
</p>
<h2>Команды</h2> <h2>Команды</h2>
<ul> <ul>

View File

@ -13,7 +13,7 @@ export function labelHelpTopic(topic: HelpTopic): string {
case HelpTopic.INTERFACE: return '🌀 Интерфейс'; case HelpTopic.INTERFACE: return '🌀 Интерфейс';
case HelpTopic.UI_LIBRARY: return 'Библиотека'; case HelpTopic.UI_LIBRARY: return 'Библиотека';
case HelpTopic.UI_RS_MENU: return 'Меню схемы'; case HelpTopic.UI_RS_MENU: return 'Меню схемы';
case HelpTopic.UI_RS_CARD: return 'Карточка схемы'; case HelpTopic.UI_RS_CARD: return 'Паспорт схемы';
case HelpTopic.UI_RS_LIST: return 'Список конституент'; case HelpTopic.UI_RS_LIST: return 'Список конституент';
case HelpTopic.UI_RS_EDITOR: return 'Редактор конституенты'; case HelpTopic.UI_RS_EDITOR: return 'Редактор конституенты';
case HelpTopic.UI_GRAPH_TERM: return 'Граф термов'; case HelpTopic.UI_GRAPH_TERM: return 'Граф термов';

View File

@ -3,7 +3,8 @@ import { useIntl } from 'react-intl';
import { urls, useConceptNavigation } from '@/app'; import { urls, useConceptNavigation } from '@/app';
import { useLabelUser, useRoleStore, UserRole } from '@/features/users'; import { useLabelUser, useRoleStore, UserRole } from '@/features/users';
import { InfoUsers, SelectUser } from '@/features/users/components'; import { InfoUsers } from '@/features/users/components/info-users';
import { SelectUser } from '@/features/users/components/select-user';
import { Tooltip } from '@/components/container'; import { Tooltip } from '@/components/container';
import { MiniButton } from '@/components/control'; import { MiniButton } from '@/components/control';
@ -86,7 +87,6 @@ export function EditorLibraryItem({ schema, isAttachedToOSS }: EditorLibraryItem
<div className='relative flex justify-stretch sm:mb-1 max-w-120 gap-3'> <div className='relative flex justify-stretch sm:mb-1 max-w-120 gap-3'>
<MiniButton <MiniButton
title='Открыть в библиотеке' title='Открыть в библиотеке'
noHover
noPadding noPadding
icon={<IconFolderOpened size='1.25rem' className='icon-primary' />} icon={<IconFolderOpened size='1.25rem' className='icon-primary' />}
onClick={handleOpenLibrary} onClick={handleOpenLibrary}
@ -137,14 +137,14 @@ export function EditorLibraryItem({ schema, isAttachedToOSS }: EditorLibraryItem
<ValueIcon <ValueIcon
title='Дата обновления' title='Дата обновления'
dense dense
icon={<IconDateUpdate size='1.25rem' className='text-constructive' />} icon={<IconDateUpdate size='1.25rem' />}
value={new Date(schema.time_update).toLocaleString(intl.locale)} value={new Date(schema.time_update).toLocaleString(intl.locale)}
/> />
<ValueIcon <ValueIcon
title='Дата создания' title='Дата создания'
dense dense
icon={<IconDateCreate size='1.25rem' className='text-constructive' />} icon={<IconDateCreate size='1.25rem' />}
value={new Date(schema.time_create).toLocaleString(intl.locale, { value={new Date(schema.time_create).toLocaleString(intl.locale, {
year: '2-digit', year: '2-digit',
month: '2-digit', month: '2-digit',

View File

@ -1,21 +1,16 @@
import { UserRole } from '@/features/users'; import { UserRole } from '@/features/users';
import { IconAdmin, IconEditor, IconOwner, IconReader } from '@/components/icons'; import { type DomIconProps, IconAdmin, IconEditor, IconOwner, IconReader } from '@/components/icons';
interface IconRoleProps { export function IconRole({ value, size = '1.25rem', className }: DomIconProps<UserRole>) {
role: UserRole; switch (value) {
size?: string;
}
export function IconRole({ role, size = '1.25rem' }: IconRoleProps) {
switch (role) {
case UserRole.ADMIN: case UserRole.ADMIN:
return <IconAdmin size={size} className='icon-primary' />; return <IconAdmin size={size} className={className ?? 'icon-primary'} />;
case UserRole.OWNER: case UserRole.OWNER:
return <IconOwner size={size} className='icon-primary' />; return <IconOwner size={size} className={className ?? 'icon-primary'} />;
case UserRole.EDITOR: case UserRole.EDITOR:
return <IconEditor size={size} className='icon-primary' />; return <IconEditor size={size} className={className ?? 'icon-primary'} />;
case UserRole.READER: case UserRole.READER:
return <IconReader size={size} className='icon-primary' />; return <IconReader size={size} className={className ?? 'icon-primary'} />;
} }
} }

View File

@ -0,0 +1,23 @@
import { type DomIconProps, IconBottomClose, IconBottomOpen, IconLeftClose, IconLeftOpen } from '@/components/icons';
/** Icon for sidebar visibility. */
export function IconShowSidebar({
value,
size = '1.25rem',
className,
isBottom
}: DomIconProps<boolean> & { isBottom: boolean }) {
if (isBottom) {
if (value) {
return <IconBottomClose size={size} className={className ?? 'icon-primary'} />;
} else {
return <IconBottomOpen size={size} className={className ?? 'icon-primary'} />;
}
} else {
if (value) {
return <IconLeftOpen size={size} className={className ?? 'icon-primary'} />;
} else {
return <IconLeftClose size={size} className={className ?? 'icon-primary'} />;
}
}
}

View File

@ -1,8 +0,0 @@
export { EditorLibraryItem } from './editor-library-item';
export { MenuRole } from './menu-role';
export { MiniSelectorOSS } from './mini-selector-oss';
export { PickSchema } from './pick-schema';
export { SelectLibraryItem } from './select-library-item';
export { SelectVersion } from './select-version';
export { ToolbarItemAccess } from './toolbar-item-access';
export { ToolbarItemCard } from './toolbar-item-card';

View File

@ -3,7 +3,7 @@ import { useAuthSuspense } from '@/features/auth';
import { useRoleStore, UserRole } from '@/features/users'; import { useRoleStore, UserRole } from '@/features/users';
import { describeUserRole, labelUserRole } from '@/features/users/labels'; import { describeUserRole, labelUserRole } from '@/features/users/labels';
import { Button } from '@/components/control'; import { MiniButton } from '@/components/control';
import { Dropdown, DropdownButton, useDropdown } from '@/components/dropdown'; import { Dropdown, DropdownButton, useDropdown } from '@/components/dropdown';
import { IconAlert } from '@/components/icons'; import { IconAlert } from '@/components/icons';
@ -29,13 +29,11 @@ export function MenuRole({ isOwned, isEditor }: MenuRoleProps) {
if (isAnonymous) { if (isAnonymous) {
return ( return (
<Button <MiniButton
dense noPadding
noBorder
noOutline
titleHtml='<b>Анонимный режим</b><br />Войти в Портал' titleHtml='<b>Анонимный режим</b><br />Войти в Портал'
hideTitle={accessMenu.isOpen} hideTitle={accessMenu.isOpen}
className='h-full pr-2' className='h-full pr-2 pl-3 bg-transparent'
icon={<IconAlert size='1.25rem' className='icon-red' />} icon={<IconAlert size='1.25rem' className='icon-red' />}
onClick={() => router.push({ path: urls.login })} onClick={() => router.push({ path: urls.login })}
/> />
@ -44,44 +42,45 @@ export function MenuRole({ isOwned, isEditor }: MenuRoleProps) {
return ( return (
<div ref={accessMenu.ref} onBlur={accessMenu.handleBlur} className='relative'> <div ref={accessMenu.ref} onBlur={accessMenu.handleBlur} className='relative'>
<Button <MiniButton
dense noHover
noBorder noPadding
noOutline
title={`Режим ${labelUserRole(role)}`} title={`Режим ${labelUserRole(role)}`}
hideTitle={accessMenu.isOpen} hideTitle={accessMenu.isOpen}
className='h-full pr-2' className='h-full pr-2 text-muted-foreground hover:text-primary cc-animate-color'
icon={<IconRole role={role} size='1.25rem' />} icon={<IconRole value={role} size='1.25rem' className='' />}
onClick={accessMenu.toggle} onClick={accessMenu.toggle}
/> />
<Dropdown isOpen={accessMenu.isOpen} margin='mt-3'> <Dropdown isOpen={accessMenu.isOpen} margin='mt-3'>
<DropdownButton <DropdownButton
text={labelUserRole(UserRole.READER)} text={labelUserRole(UserRole.READER)}
title={describeUserRole(UserRole.READER)} title={describeUserRole(UserRole.READER)}
icon={<IconRole role={UserRole.READER} size='1rem' />} icon={<IconRole value={UserRole.READER} size='1rem' />}
onClick={() => handleChangeMode(UserRole.READER)} onClick={() => handleChangeMode(UserRole.READER)}
/> />
<DropdownButton <DropdownButton
text={labelUserRole(UserRole.EDITOR)} text={labelUserRole(UserRole.EDITOR)}
title={describeUserRole(UserRole.EDITOR)} title={describeUserRole(UserRole.EDITOR)}
icon={<IconRole role={UserRole.EDITOR} size='1rem' />} icon={<IconRole value={UserRole.EDITOR} size='1rem' />}
onClick={() => handleChangeMode(UserRole.EDITOR)} onClick={() => handleChangeMode(UserRole.EDITOR)}
disabled={!isOwned && !isEditor} disabled={!isOwned && !isEditor}
/> />
<DropdownButton <DropdownButton
text={labelUserRole(UserRole.OWNER)} text={labelUserRole(UserRole.OWNER)}
title={describeUserRole(UserRole.OWNER)} title={describeUserRole(UserRole.OWNER)}
icon={<IconRole role={UserRole.OWNER} size='1rem' />} icon={<IconRole value={UserRole.OWNER} size='1rem' />}
onClick={() => handleChangeMode(UserRole.OWNER)} onClick={() => handleChangeMode(UserRole.OWNER)}
disabled={!isOwned} disabled={!isOwned}
/> />
{user.is_staff ? (
<DropdownButton <DropdownButton
text={labelUserRole(UserRole.ADMIN)} text={labelUserRole(UserRole.ADMIN)}
title={describeUserRole(UserRole.ADMIN)} title={describeUserRole(UserRole.ADMIN)}
icon={<IconRole role={UserRole.ADMIN} size='1rem' />} icon={<IconRole value={UserRole.ADMIN} size='1rem' />}
onClick={() => handleChangeMode(UserRole.ADMIN)} onClick={() => handleChangeMode(UserRole.ADMIN)}
disabled={!user.is_staff} disabled={!user.is_staff}
/> />
) : null}
</Dropdown> </Dropdown>
</div> </div>
); );

View File

@ -5,7 +5,7 @@ import clsx from 'clsx';
import { useAuthSuspense } from '@/features/auth'; import { useAuthSuspense } from '@/features/auth';
import { Label, TextArea } from '@/components/input'; import { TextArea } from '@/components/input';
import { type Styling } from '@/components/props'; import { type Styling } from '@/components/props';
import { LocationHead } from '../../models/library'; import { LocationHead } from '../../models/library';
@ -35,18 +35,16 @@ export function PickLocation({
const { user } = useAuthSuspense(); const { user } = useAuthSuspense();
return ( return (
<div className={clsx('flex', className)} {...restProps}> <div className={clsx('flex relative', className)} {...restProps}>
<div className='flex flex-col gap-2 min-w-28'>
<Label className='select-none' text='Корень' />
<SelectLocationHead <SelectLocationHead
className='absolute right-0 top-0'
value={value.substring(0, 2) as LocationHead} value={value.substring(0, 2) as LocationHead}
onChange={newValue => onChange(combineLocation(newValue, value.substring(3)))} onChange={newValue => onChange(combineLocation(newValue, value.substring(3)))}
excluded={!user.is_staff ? [LocationHead.LIBRARY] : []} excluded={!user.is_staff ? [LocationHead.LIBRARY] : []}
/> />
</div>
<SelectLocationContext <SelectLocationContext
className='-mt-1 -ml-8' className='absolute left-28 -top-1'
dropdownHeight={dropdownHeight} // dropdownHeight={dropdownHeight} //
value={value} value={value}
onChange={onChange} onChange={onChange}
@ -54,7 +52,7 @@ export function PickLocation({
<TextArea <TextArea
id='dlg_location' id='dlg_location'
label='Путь' label='Расположение'
rows={rows} rows={rows}
value={value.substring(3)} value={value.substring(3)}
onChange={event => onChange(combineLocation(value.substring(0, 2), event.target.value))} onChange={event => onChange(combineLocation(value.substring(0, 2), event.target.value))}

View File

@ -38,13 +38,13 @@ export function SelectLocationContext({
<div <div
ref={menu.ref} // ref={menu.ref} //
onBlur={menu.handleBlur} onBlur={menu.handleBlur}
className={clsx('relative text-right self-start', className)} className={clsx('text-right self-start', className)}
{...restProps} {...restProps}
> >
<MiniButton <MiniButton
title={title} title={title}
hideTitle={menu.isOpen} hideTitle={menu.isOpen}
icon={<IconFolderTree size='1.25rem' className='icon-green' />} icon={<IconFolderTree size='1.25rem' className='icon-primary' />}
onClick={() => menu.toggle()} onClick={() => menu.toggle()}
/> />
<Dropdown isOpen={menu.isOpen} className={clsx('w-80 z-tooltip', dropdownHeight)}> <Dropdown isOpen={menu.isOpen} className={clsx('w-80 z-tooltip', dropdownHeight)}>

View File

@ -48,7 +48,7 @@ export function SelectLocationHead({
onClick={menu.toggle} onClick={menu.toggle}
/> />
<Dropdown isOpen={menu.isOpen} margin='mt-2'> <Dropdown isOpen={menu.isOpen} stretchLeft margin='mt-2'>
{Object.values(LocationHead) {Object.values(LocationHead)
.filter(head => !excluded.includes(head)) .filter(head => !excluded.includes(head))
.map((head, index) => { .map((head, index) => {

View File

@ -62,7 +62,7 @@ export function SelectLocation({ value, dense, prefix, onClick, className, style
!dense && 'h-7 sm:h-8', !dense && 'h-7 sm:h-8',
'pr-3 py-1 flex items-center gap-2', 'pr-3 py-1 flex items-center gap-2',
'cc-scroll-row', 'cc-scroll-row',
'cc-hover cc-animate-color duration-fade', 'cc-hover-bg cc-animate-color duration-fade',
'cursor-pointer', 'cursor-pointer',
'leading-3 sm:leading-4', 'leading-3 sm:leading-4',
activeNode === item && 'cc-selected' activeNode === item && 'cc-selected'
@ -73,7 +73,6 @@ export function SelectLocation({ value, dense, prefix, onClick, className, style
{item.children.size > 0 ? ( {item.children.size > 0 ? (
<MiniButton <MiniButton
noPadding noPadding
noHover
icon={ icon={
folded.includes(item) ? ( folded.includes(item) ? (
item.filesInside ? ( item.filesInside ? (
@ -93,7 +92,7 @@ export function SelectLocation({ value, dense, prefix, onClick, className, style
{item.filesInside ? ( {item.filesInside ? (
<IconFolder size='1rem' className='text-foreground' /> <IconFolder size='1rem' className='text-foreground' />
) : ( ) : (
<IconFolderEmpty size='1rem' className='cc-controls' /> <IconFolderEmpty size='1rem' className='text-foreground-muted' />
)} )}
</div> </div>
)} )}

View File

@ -1,7 +1,7 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { HelpTopic } from '@/features/help'; import { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components'; import { BadgeHelp } from '@/features/help/components/badge-help';
import { useRoleStore, UserRole } from '@/features/users'; import { useRoleStore, UserRole } from '@/features/users';
import { MiniButton } from '@/components/control'; import { MiniButton } from '@/components/control';

View File

@ -2,7 +2,7 @@
import { urls, useConceptNavigation } from '@/app'; import { urls, useConceptNavigation } from '@/app';
import { HelpTopic } from '@/features/help'; import { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components'; import { BadgeHelp } from '@/features/help/components/badge-help';
import { type IRSForm } from '@/features/rsform'; import { type IRSForm } from '@/features/rsform';
import { useRoleStore, UserRole } from '@/features/users'; import { useRoleStore, UserRole } from '@/features/users';
@ -10,29 +10,46 @@ import { MiniButton } from '@/components/control';
import { IconDestroy, IconSave, IconShare } from '@/components/icons'; import { IconDestroy, IconSave, IconShare } from '@/components/icons';
import { cn } from '@/components/utils'; import { cn } from '@/components/utils';
import { useModificationStore } from '@/stores/modification'; import { useModificationStore } from '@/stores/modification';
import { usePreferencesStore } from '@/stores/preferences';
import { tooltipText } from '@/utils/labels'; import { tooltipText } from '@/utils/labels';
import { prepareTooltip, sharePage } from '@/utils/utils'; import { prepareTooltip, sharePage } from '@/utils/utils';
import { AccessPolicy, type ILibraryItem, LibraryItemType } from '../backend/types'; import { AccessPolicy, type ILibraryItem, LibraryItemType } from '../backend/types';
import { useMutatingLibrary } from '../backend/use-mutating-library'; import { useMutatingLibrary } from '../backend/use-mutating-library';
import { IconShowSidebar } from './icon-show-sidebar';
import { MiniSelectorOSS } from './mini-selector-oss'; import { MiniSelectorOSS } from './mini-selector-oss';
interface ToolbarItemCardProps { interface ToolbarItemCardProps {
className?: string; className?: string;
isNarrow: boolean;
onSubmit: () => void; onSubmit: () => void;
isMutable: boolean; isMutable: boolean;
schema: ILibraryItem; schema: ILibraryItem;
deleteSchema: () => void; deleteSchema: () => void;
} }
export function ToolbarItemCard({ className, schema, onSubmit, isMutable, deleteSchema }: ToolbarItemCardProps) { export function ToolbarItemCard({
className,
isNarrow,
schema,
onSubmit,
isMutable,
deleteSchema
}: ToolbarItemCardProps) {
const role = useRoleStore(state => state.role); const role = useRoleStore(state => state.role);
const router = useConceptNavigation(); const router = useConceptNavigation();
const { isModified } = useModificationStore(); const { isModified } = useModificationStore();
const isProcessing = useMutatingLibrary(); const isProcessing = useMutatingLibrary();
const canSave = isModified && !isProcessing; const canSave = isModified && !isProcessing;
const showRSFormStats = usePreferencesStore(state => state.showRSFormStats);
const toggleShowRSFormStats = usePreferencesStore(state => state.toggleShowRSFormStats);
const showOSSStats = usePreferencesStore(state => state.showOSSStats);
const toggleShowOSSStats = usePreferencesStore(state => state.toggleShowOSSStats);
const isRSForm = schema.item_type === LibraryItemType.RSFORM;
const isOSS = schema.item_type === LibraryItemType.OSS;
const ossSelector = (() => { const ossSelector = (() => {
if (schema.item_type !== LibraryItemType.RSFORM) { if (schema.item_type !== LibraryItemType.RSFORM) {
return null; return null;
@ -76,6 +93,15 @@ export function ToolbarItemCard({ className, schema, onSubmit, isMutable, delete
disabled={!isMutable || isProcessing || role < UserRole.OWNER} disabled={!isMutable || isProcessing || role < UserRole.OWNER}
/> />
) : null} ) : null}
{(isRSForm || isOSS) && (
<MiniButton
title='Отображение статистики'
icon={
<IconShowSidebar value={isRSForm ? showRSFormStats : showOSSStats} isBottom={isNarrow} size='1.25rem' />
}
onClick={isRSForm ? toggleShowRSFormStats : toggleShowOSSStats}
/>
)}
<BadgeHelp topic={HelpTopic.UI_RS_CARD} offset={4} /> <BadgeHelp topic={HelpTopic.UI_RS_CARD} offset={4} />
</div> </div>
); );

View File

@ -3,7 +3,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { useUsers } from '@/features/users'; import { useUsers } from '@/features/users';
import { SelectUser, TableUsers } from '@/features/users/components'; import { SelectUser } from '@/features/users/components/select-user';
import { TableUsers } from '@/features/users/components/table-users';
import { MiniButton } from '@/components/control'; import { MiniButton } from '@/components/control';
import { IconRemove } from '@/components/icons'; import { IconRemove } from '@/components/icons';
@ -48,9 +49,8 @@ export function DlgEditEditors() {
<span>Всего редакторов [{selected.length}]</span> <span>Всего редакторов [{selected.length}]</span>
<MiniButton <MiniButton
title='Очистить список' title='Очистить список'
noHover
className='py-0 align-middle' className='py-0 align-middle'
icon={<IconRemove size='1.5rem' className='cc-remove' />} icon={<IconRemove size='1.25rem' className='cc-remove' />}
onClick={() => setSelected([])} onClick={() => setSelected([])}
disabled={selected.length === 0} disabled={selected.length === 0}
/> />

View File

@ -63,7 +63,6 @@ export function TableVersions({ processing, items, onDelete, selected, onSelect
<MiniButton <MiniButton
title='Удалить версию' title='Удалить версию'
className='align-middle' className='align-middle'
noHover
noPadding noPadding
icon={<IconRemove size='1.25rem' className='cc-remove' />} icon={<IconRemove size='1.25rem' className='cc-remove' />}
onClick={event => handleDeleteVersion(event, props.row.original.id)} onClick={event => handleDeleteVersion(event, props.row.original.id)}

View File

@ -1,4 +1,4 @@
import { RequireAuth } from '@/features/auth/components'; import { RequireAuth } from '@/features/auth/components/require-auth';
import { FormCreateItem } from './form-create-item'; import { FormCreateItem } from './form-create-item';

View File

@ -58,7 +58,7 @@ export function LibraryPage() {
<MiniButton <MiniButton
title='Выгрузить в формате CSV' title='Выгрузить в формате CSV'
className='absolute z-tooltip -top-8 right-6 hidden sm:block' className='absolute z-tooltip -top-8 right-6 hidden sm:block'
icon={<IconCSV size='1.25rem' className='icon-green' />} icon={<IconCSV size='1.25rem' className='text-muted-foreground hover:text-constructive' />}
onClick={handleDownloadCSV} onClick={handleDownloadCSV}
/> />

View File

@ -2,7 +2,7 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { SelectUser } from '@/features/users/components'; import { SelectUser } from '@/features/users/components/select-user';
import { MiniButton, SelectorButton } from '@/components/control'; import { MiniButton, SelectorButton } from '@/components/control';
import { Dropdown, DropdownButton, useDropdown } from '@/components/dropdown'; import { Dropdown, DropdownButton, useDropdown } from '@/components/dropdown';
@ -156,28 +156,21 @@ export function ToolbarSearch({ className, total, filtered }: ToolbarSearchProps
(head ? describeLocationHead(head) : 'Выберите каталог') + '<br/><kbd>Ctrl + клик</kbd> - Проводник' (head ? describeLocationHead(head) : 'Выберите каталог') + '<br/><kbd>Ctrl + клик</kbd> - Проводник'
} }
hideTitle={headMenu.isOpen} hideTitle={headMenu.isOpen}
icon={ icon={head ? <IconLocationHead value={head} size='1.25rem' /> : <IconFolderSearch size='1.25rem' />}
head ? (
<IconLocationHead value={head} size='1.25rem' />
) : (
<IconFolderSearch size='1.25rem' className='cc-controls' />
)
}
onClick={handleFolderClick} onClick={handleFolderClick}
text={head ?? '//'}
/> />
<Dropdown isOpen={headMenu.isOpen} stretchLeft> <Dropdown isOpen={headMenu.isOpen} stretchLeft>
<DropdownButton <DropdownButton
text='проводник...' text='проводник...'
title='Переключение в режим Проводник' title='Переключение в режим Проводник'
icon={<IconFolderTree size='1rem' className='cc-controls' />} icon={<IconFolderTree size='1rem' className='icon-primary' />}
onClick={handleToggleFolder} onClick={handleToggleFolder}
/> />
<DropdownButton <DropdownButton
text='отображать все' text='отображать все'
title='Очистить фильтр по расположению' title='Очистить фильтр по расположению'
icon={<IconFolder size='1rem' className='cc-controls' />} icon={<IconFolder size='1rem' className='icon-primary' />}
onClick={() => handleChange(null)} onClick={() => handleChange(null)}
/> />
{Object.values(LocationHead).map((head, index) => { {Object.values(LocationHead).map((head, index) => {
@ -200,7 +193,7 @@ export function ToolbarSearch({ className, total, filtered }: ToolbarSearchProps
placeholder='Путь' placeholder='Путь'
noIcon noIcon
noBorder noBorder
className='w-18 sm:w-20 grow' className='w-18 sm:w-20 grow ml-1'
query={path} query={path}
onChangeQuery={setPath} onChangeQuery={setPath}
/> />

View File

@ -39,10 +39,9 @@ export function useLibraryColumns() {
titleHtml='Переключение в режим Проводник' titleHtml='Переключение в режим Проводник'
aria-label='Переключатель режима Проводник' aria-label='Переключатель режима Проводник'
noPadding noPadding
noHover className='ml-2 max-h-4 -translate-y-0.5'
className='pl-2 max-h-4 -translate-y-0.5'
onClick={handleToggleFolder} onClick={handleToggleFolder}
icon={<IconFolderTree size='1.25rem' className='cc-controls' />} icon={<IconFolderTree size='1.25rem' className='text-primary' />}
/> />
), ),
size: 50, size: 50,

View File

@ -3,7 +3,7 @@ import clsx from 'clsx';
import { useAuthSuspense } from '@/features/auth'; import { useAuthSuspense } from '@/features/auth';
import { HelpTopic } from '@/features/help'; import { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components'; import { BadgeHelp } from '@/features/help/components/badge-help';
import { MiniButton } from '@/components/control'; import { MiniButton } from '@/components/control';
import { IconFolderEdit, IconFolderTree } from '@/components/icons'; import { IconFolderEdit, IconFolderTree } from '@/components/icons';
@ -86,8 +86,8 @@ export function ViewSideLocation({ isVisible, onRenameLocation }: ViewSideLocati
/> />
) : null} ) : null}
<MiniButton <MiniButton
title='Переключение в режим Поиск' title='Переключение в режим Таблица'
icon={<IconFolderTree size='1.25rem' className='icon-green' />} icon={<IconFolderTree size='1.25rem' className='text-primary' />}
onClick={toggleFolderMode} onClick={toggleFolderMode}
/> />
</div> </div>

View File

@ -50,7 +50,7 @@ export const ossApi = {
axiosPatch({ axiosPatch({
endpoint: `/api/oss/${itemID}/update-layout`, endpoint: `/api/oss/${itemID}/update-layout`,
request: { request: {
data: data, data: { data: data },
successMessage: isSilent ? undefined : infoMsg.changesSaved successMessage: isSilent ? undefined : infoMsg.changesSaved
} }
}), }),

View File

@ -90,7 +90,7 @@ export class OssLoader {
this.graph.topologicalOrder().forEach(operationID => { this.graph.topologicalOrder().forEach(operationID => {
const operation = this.operationByID.get(operationID)!; const operation = this.operationByID.get(operationID)!;
const schema = this.items.find(item => item.id === operation.result); const schema = this.items.find(item => item.id === operation.result);
const position = this.oss.layout.operations.find(item => item.id === operationID); const position = this.oss.layout.find(item => item.nodeID === operation.nodeID);
operation.x = position?.x ?? 0; operation.x = position?.x ?? 0;
operation.y = position?.y ?? 0; operation.y = position?.y ?? 0;
operation.is_consolidation = this.inferConsolidation(operationID); operation.is_consolidation = this.inferConsolidation(operationID);
@ -104,7 +104,7 @@ export class OssLoader {
private inferBlockAttributes() { private inferBlockAttributes() {
this.oss.blocks.forEach(block => { this.oss.blocks.forEach(block => {
const geometry = this.oss.layout.blocks.find(item => item.id === block.id); const geometry = this.oss.layout.find(item => item.nodeID === block.nodeID);
block.x = geometry?.x ?? 0; block.x = geometry?.x ?? 0;
block.y = geometry?.y ?? 0; block.y = geometry?.y ?? 0;
block.width = geometry?.width ?? BLOCK_NODE_MIN_WIDTH; block.width = geometry?.width ?? BLOCK_NODE_MIN_WIDTH;

View File

@ -72,11 +72,8 @@ export type IRelocateConstituentsDTO = z.infer<typeof schemaRelocateConstituents
/** Represents {@link IConstituenta} reference. */ /** Represents {@link IConstituenta} reference. */
export type IConstituentaReference = z.infer<typeof schemaConstituentaReference>; export type IConstituentaReference = z.infer<typeof schemaConstituentaReference>;
/** Represents {@link IOperation} position. */ /** Represents {@link IOperationSchema} node position. */
export type IOperationPosition = z.infer<typeof schemaOperationPosition>; export type INodePosition = z.infer<typeof schemaNodePosition>;
/** Represents {@link IBlock} position. */
export type IBlockPosition = z.infer<typeof schemaBlockPosition>;
// ====== Schemas ====== // ====== Schemas ======
export const schemaOperationType = z.enum(Object.values(OperationType) as [OperationType, ...OperationType[]]); export const schemaOperationType = z.enum(Object.values(OperationType) as [OperationType, ...OperationType[]]);
@ -108,24 +105,15 @@ export const schemaCstSubstituteInfo = schemaSubstituteConstituents.extend({
substitution_term: z.string() substitution_term: z.string()
}); });
export const schemaOperationPosition = z.strictObject({ export const schemaNodePosition = z.strictObject({
id: z.number(), nodeID: z.string(),
x: z.number(),
y: z.number()
});
export const schemaBlockPosition = z.strictObject({
id: z.number(),
x: z.number(), x: z.number(),
y: z.number(), y: z.number(),
width: z.number(), width: z.number(),
height: z.number() height: z.number()
}); });
export const schemaOssLayout = z.strictObject({ export const schemaOssLayout = z.array(schemaNodePosition);
operations: z.array(schemaOperationPosition),
blocks: z.array(schemaBlockPosition)
});
export const schemaOperationSchema = schemaLibraryItem.extend({ export const schemaOperationSchema = schemaLibraryItem.extend({
editors: z.number().array(), editors: z.number().array(),
@ -188,6 +176,8 @@ export const schemaCreateOperation = z.strictObject({
}), }),
position_x: z.number(), position_x: z.number(),
position_y: z.number(), position_y: z.number(),
width: z.number(),
height: z.number(),
arguments: z.array(z.number()), arguments: z.array(z.number()),
create_schema: z.boolean() create_schema: z.boolean()
}); });

View File

@ -9,7 +9,7 @@ import {
import { cn } from '@/components/utils'; import { cn } from '@/components/utils';
import { ValueStats } from '@/components/view'; import { ValueStats } from '@/components/view';
import { type IOperationSchemaStats } from '../../../models/oss'; import { type IOperationSchemaStats } from '../models/oss';
interface OssStatsProps { interface OssStatsProps {
className?: string; className?: string;
@ -18,48 +18,43 @@ interface OssStatsProps {
export function OssStats({ className, stats }: OssStatsProps) { export function OssStats({ className, stats }: OssStatsProps) {
return ( return (
<div className={cn('grid grid-cols-4 gap-1 justify-items-end', className)}> <aside className={cn('grid grid-cols-4 gap-1 justify-items-end h-min', className)}>
<div id='count_operations' className='w-fit flex gap-3 hover:cursor-default '> <div id='count_operations' className='w-fit flex gap-3 hover:cursor-default '>
<span>Всего</span> <span>Всего</span>
<span>{stats.count_all}</span> <span>{stats.count_all}</span>
</div> </div>
<ValueStats <ValueStats id='count_block' title='Блоки' icon={<IconConceptBlock size='1.25rem' />} value={stats.count_block} />
id='count_block'
title='Блоки'
icon={<IconConceptBlock size='1.25rem' className='text-primary' />}
value={stats.count_block}
/>
<ValueStats <ValueStats
id='count_inputs' id='count_inputs'
title='Загрузка' title='Загрузка'
icon={<IconDownload size='1.25rem' className='text-primary' />} icon={<IconDownload size='1.25rem' />}
value={stats.count_inputs} value={stats.count_inputs}
/> />
<ValueStats <ValueStats
id='count_synthesis' id='count_synthesis'
title='Синтез' title='Синтез'
icon={<IconSynthesis size='1.25rem' className='text-primary' />} icon={<IconSynthesis size='1.25rem' />}
value={stats.count_synthesis} value={stats.count_synthesis}
/> />
<ValueStats <ValueStats
id='count_schemas' id='count_schemas'
title='Прикрепленные схемы' title='Прикрепленные схемы'
icon={<IconRSForm size='1.25rem' className='text-primary' />} icon={<IconRSForm size='1.25rem' />}
value={stats.count_schemas} value={stats.count_schemas}
/> />
<ValueStats <ValueStats
id='count_owned' id='count_owned'
title='Собственные' title='Собственные'
icon={<IconRSFormOwned size='1.25rem' className='text-primary' />} icon={<IconRSFormOwned size='1.25rem' />}
value={stats.count_owned} value={stats.count_owned}
/> />
<ValueStats <ValueStats
id='count_imported' id='count_imported'
title='Внешние' title='Внешние'
icon={<IconRSFormImported size='1.25rem' className='text-primary' />} icon={<IconRSFormImported size='1.25rem' />}
value={stats.count_schemas - stats.count_owned} value={stats.count_schemas - stats.count_owned}
/> />
</div> </aside>
); );
} }

View File

@ -98,21 +98,18 @@ export function PickContents({
<div className='flex w-fit'> <div className='flex w-fit'>
<MiniButton <MiniButton
title='Удалить' title='Удалить'
noHover
className='px-0' className='px-0'
icon={<IconRemove size='1rem' className='icon-red' />} icon={<IconRemove size='1rem' className='icon-red' />}
onClick={() => handleDelete(props.row.original)} onClick={() => handleDelete(props.row.original)}
/> />
<MiniButton <MiniButton
title='Переместить выше' title='Переместить выше'
noHover
className='px-0' className='px-0'
icon={<IconMoveUp size='1rem' className='icon-primary' />} icon={<IconMoveUp size='1rem' className='icon-primary' />}
onClick={() => handleMoveUp(props.row.original)} onClick={() => handleMoveUp(props.row.original)}
/> />
<MiniButton <MiniButton
title='Переместить ниже' title='Переместить ниже'
noHover
className='px-0' className='px-0'
icon={<IconMoveDown size='1rem' className='icon-primary' />} icon={<IconMoveDown size='1rem' className='icon-primary' />}
onClick={() => handleMoveDown(props.row.original)} onClick={() => handleMoveDown(props.row.original)}

View File

@ -82,21 +82,18 @@ export function PickMultiOperation({ rows, items, value, onChange, className, ..
<div className='flex w-fit'> <div className='flex w-fit'>
<MiniButton <MiniButton
title='Удалить' title='Удалить'
noHover
className='px-0' className='px-0'
icon={<IconRemove size='1rem' className='icon-red' />} icon={<IconRemove size='1rem' className='icon-red' />}
onClick={() => handleDelete(props.row.original.id)} onClick={() => handleDelete(props.row.original.id)}
/> />
<MiniButton <MiniButton
title='Переместить выше' title='Переместить выше'
noHover
className='px-0' className='px-0'
icon={<IconMoveUp size='1rem' className='icon-primary' />} icon={<IconMoveUp size='1rem' className='icon-primary' />}
onClick={() => handleMoveUp(props.row.original.id)} onClick={() => handleMoveUp(props.row.original.id)}
/> />
<MiniButton <MiniButton
title='Переместить ниже' title='Переместить ниже'
noHover
className='px-0' className='px-0'
icon={<IconMoveDown size='1rem' className='icon-primary' />} icon={<IconMoveDown size='1rem' className='icon-primary' />}
onClick={() => handleMoveDown(props.row.original.id)} onClick={() => handleMoveDown(props.row.original.id)}

View File

@ -5,7 +5,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { type ILibraryItem, LibraryItemType } from '@/features/library'; import { type ILibraryItem, LibraryItemType } from '@/features/library';
import { useLibrary } from '@/features/library/backend/use-library'; import { useLibrary } from '@/features/library/backend/use-library';
import { PickSchema } from '@/features/library/components'; import { PickSchema } from '@/features/library/components/pick-schema';
import { MiniButton } from '@/components/control'; import { MiniButton } from '@/components/control';
import { IconReset } from '@/components/icons'; import { IconReset } from '@/components/icons';
@ -61,7 +61,6 @@ export function DlgChangeInputSchema() {
<Label text='Загружаемая концептуальная схема' /> <Label text='Загружаемая концептуальная схема' />
<MiniButton <MiniButton
title='Сбросить выбор схемы' title='Сбросить выбор схемы'
noHover
noPadding noPadding
icon={<IconReset size='1.25rem' className='icon-primary' />} icon={<IconReset size='1.25rem' className='icon-primary' />}
onClick={() => setValue('input', null)} onClick={() => setValue('input', null)}

View File

@ -84,14 +84,9 @@ export function DlgCreateBlock() {
className='w-160 px-6 h-110' className='w-160 px-6 h-110'
helpTopic={HelpTopic.CC_OSS} helpTopic={HelpTopic.CC_OSS}
> >
<Tabs <Tabs className='grid' selectedIndex={activeTab} onSelect={index => setActiveTab(index as TabID)}>
selectedTabClassName='cc-selected' <TabList className='z-pop mx-auto -mb-5 flex border divide-x rounded-none'>
className='grid' <TabLabel title='Основные атрибуты блока' label='Паспорт' />
selectedIndex={activeTab}
onSelect={index => setActiveTab(index as TabID)}
>
<TabList className='z-pop mx-auto -mb-5 flex border divide-x rounded-none bg-secondary'>
<TabLabel title='Основные атрибуты блока' label='Карточка' />
<TabLabel <TabLabel
title={`Выбор вложенных узлов: [${children_operations.length + children_blocks.length}]`} title={`Выбор вложенных узлов: [${children_operations.length + children_blocks.length}]`}
label={`Содержимое${children_operations.length + children_blocks.length > 0 ? '*' : ''}`} label={`Содержимое${children_operations.length + children_blocks.length > 0 ? '*' : ''}`}

View File

@ -13,7 +13,7 @@ import { useDialogsStore } from '@/stores/dialogs';
import { type ICreateOperationDTO, OperationType, schemaCreateOperation } from '../../backend/types'; import { type ICreateOperationDTO, OperationType, schemaCreateOperation } from '../../backend/types';
import { useCreateOperation } from '../../backend/use-create-operation'; import { useCreateOperation } from '../../backend/use-create-operation';
import { describeOperationType, labelOperationType } from '../../labels'; import { describeOperationType, labelOperationType } from '../../labels';
import { type LayoutManager } from '../../models/oss-layout-api'; import { type LayoutManager, OPERATION_NODE_HEIGHT, OPERATION_NODE_WIDTH } from '../../models/oss-layout-api';
import { TabInputOperation } from './tab-input-operation'; import { TabInputOperation } from './tab-input-operation';
import { TabSynthesisOperation } from './tab-synthesis-operation'; import { TabSynthesisOperation } from './tab-synthesis-operation';
@ -54,6 +54,8 @@ export function DlgCreateOperation() {
position_x: defaultX, position_x: defaultX,
position_y: defaultY, position_y: defaultY,
arguments: initialInputs, arguments: initialInputs,
width: OPERATION_NODE_WIDTH,
height: OPERATION_NODE_HEIGHT,
create_schema: false, create_schema: false,
layout: manager.layout layout: manager.layout
}, },
@ -98,12 +100,11 @@ export function DlgCreateOperation() {
helpTopic={HelpTopic.CC_OSS} helpTopic={HelpTopic.CC_OSS}
> >
<Tabs <Tabs
selectedTabClassName='cc-selected'
className='grid' className='grid'
selectedIndex={activeTab} selectedIndex={activeTab}
onSelect={(index, last) => handleSelectTab(index as TabID, last as TabID)} onSelect={(index, last) => handleSelectTab(index as TabID, last as TabID)}
> >
<TabList className='z-pop mx-auto -mb-5 flex border divide-x rounded-none bg-secondary'> <TabList className='z-pop mx-auto -mb-5 flex border divide-x rounded-none'>
<TabLabel <TabLabel
title={describeOperationType(OperationType.INPUT)} title={describeOperationType(OperationType.INPUT)}
label={labelOperationType(OperationType.INPUT)} label={labelOperationType(OperationType.INPUT)}

View File

@ -4,7 +4,7 @@ import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { type ILibraryItem, LibraryItemType } from '@/features/library'; import { type ILibraryItem, LibraryItemType } from '@/features/library';
import { useLibrary } from '@/features/library/backend/use-library'; import { useLibrary } from '@/features/library/backend/use-library';
import { PickSchema } from '@/features/library/components'; import { PickSchema } from '@/features/library/components/pick-schema';
import { MiniButton } from '@/components/control'; import { MiniButton } from '@/components/control';
import { IconReset } from '@/components/icons'; import { IconReset } from '@/components/icons';
@ -97,7 +97,6 @@ export function TabInputOperation() {
<Label text='Загружаемая концептуальная схема' /> <Label text='Загружаемая концептуальная схема' />
<MiniButton <MiniButton
title='Сбросить выбор схемы' title='Сбросить выбор схемы'
noHover
noPadding noPadding
icon={<IconReset size='1.25rem' className='icon-primary' />} icon={<IconReset size='1.25rem' className='icon-primary' />}
onClick={() => setValue('item_data.result', null)} onClick={() => setValue('item_data.result', null)}

View File

@ -43,7 +43,7 @@ export function DlgEditBlock() {
function onSubmit(data: IUpdateBlockDTO) { function onSubmit(data: IUpdateBlockDTO) {
if (data.item_data.parent !== target.parent) { if (data.item_data.parent !== target.parent) {
manager.onBlockChangeParent(data.target, data.item_data.parent); manager.onChangeParent(target.nodeID, data.item_data.parent === null ? null : `b${data.item_data.parent}`);
data.layout = manager.layout; data.layout = manager.layout;
} }
return updateBlock({ itemID: manager.oss.id, data }); return updateBlock({ itemID: manager.oss.id, data });

View File

@ -59,7 +59,7 @@ export function DlgEditOperation() {
function onSubmit(data: IUpdateOperationDTO) { function onSubmit(data: IUpdateOperationDTO) {
if (data.item_data.parent !== target.parent) { if (data.item_data.parent !== target.parent) {
manager.onOperationChangeParent(data.target, data.item_data.parent); manager.onChangeParent(target.nodeID, data.item_data.parent === null ? null : `b${data.item_data.parent}`);
data.layout = manager.layout; data.layout = manager.layout;
} }
return updateOperation({ itemID: manager.oss.id, data }); return updateOperation({ itemID: manager.oss.id, data });
@ -75,16 +75,11 @@ export function DlgEditOperation() {
helpTopic={HelpTopic.UI_SUBSTITUTIONS} helpTopic={HelpTopic.UI_SUBSTITUTIONS}
hideHelpWhen={() => activeTab !== TabID.SUBSTITUTION} hideHelpWhen={() => activeTab !== TabID.SUBSTITUTION}
> >
<Tabs <Tabs className='grid' selectedIndex={activeTab} onSelect={index => setActiveTab(index as TabID)}>
selectedTabClassName='cc-selected' <TabList className='mb-3 mx-auto w-fit flex border divide-x rounded-none'>
className='grid'
selectedIndex={activeTab}
onSelect={index => setActiveTab(index as TabID)}
>
<TabList className='mb-3 mx-auto w-fit flex border divide-x rounded-none bg-secondary'>
<TabLabel <TabLabel
title='Текстовые поля' // title='Текстовые поля' //
label='Карточка' label='Паспорт'
className='w-32' className='w-32'
/> />
<TabLabel <TabLabel

View File

@ -3,7 +3,7 @@
import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { useRSForms } from '@/features/rsform/backend/use-rsforms'; import { useRSForms } from '@/features/rsform/backend/use-rsforms';
import { PickSubstitutions } from '@/features/rsform/components'; import { PickSubstitutions } from '@/features/rsform/components/pick-substitutions';
import { TextArea } from '@/components/input'; import { TextArea } from '@/components/input';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';

View File

@ -7,9 +7,9 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { HelpTopic } from '@/features/help'; import { HelpTopic } from '@/features/help';
import { type ILibraryItem } from '@/features/library'; import { type ILibraryItem } from '@/features/library';
import { useLibrary } from '@/features/library/backend/use-library'; import { useLibrary } from '@/features/library/backend/use-library';
import { SelectLibraryItem } from '@/features/library/components'; import { SelectLibraryItem } from '@/features/library/components/select-library-item';
import { useRSForm } from '@/features/rsform/backend/use-rsform'; import { useRSForm } from '@/features/rsform/backend/use-rsform';
import { PickMultiConstituenta } from '@/features/rsform/components'; import { PickMultiConstituenta } from '@/features/rsform/components/pick-multi-constituenta';
import { MiniButton } from '@/components/control'; import { MiniButton } from '@/components/control';
import { Loader } from '@/components/loader'; import { Loader } from '@/components/loader';

View File

@ -1,10 +1,4 @@
import { import { type ICreateBlockDTO, type ICreateOperationDTO, type INodePosition, type IOssLayout } from '../backend/types';
type IBlockPosition,
type ICreateBlockDTO,
type ICreateOperationDTO,
type IOperationPosition,
type IOssLayout
} from '../backend/types';
import { type IOperationSchema } from './oss'; import { type IOperationSchema } from './oss';
import { type Position2D, type Rectangle2D } from './oss-layout'; import { type Position2D, type Rectangle2D } from './oss-layout';
@ -12,8 +6,8 @@ import { type Position2D, type Rectangle2D } from './oss-layout';
export const GRID_SIZE = 10; // pixels - size of OSS grid export const GRID_SIZE = 10; // pixels - size of OSS grid
const MIN_DISTANCE = 2 * GRID_SIZE; // pixels - minimum distance between nodes const MIN_DISTANCE = 2 * GRID_SIZE; // pixels - minimum distance between nodes
const OPERATION_NODE_WIDTH = 150; export const OPERATION_NODE_WIDTH = 150;
const OPERATION_NODE_HEIGHT = 40; export const OPERATION_NODE_HEIGHT = 40;
/** Layout manipulations for {@link IOperationSchema}. */ /** Layout manipulations for {@link IOperationSchema}. */
export class LayoutManager { export class LayoutManager {
@ -30,52 +24,40 @@ export class LayoutManager {
} }
/** Calculate insert position for a new {@link IOperation} */ /** Calculate insert position for a new {@link IOperation} */
newOperationPosition(data: ICreateOperationDTO): Position2D { newOperationPosition(data: ICreateOperationDTO): Rectangle2D {
let result = { x: data.position_x, y: data.position_y }; const result = { x: data.position_x, y: data.position_y, width: data.width, height: data.height };
const operations = this.layout.operations; const parentNode = this.layout.find(pos => pos.nodeID === `b${data.item_data.parent}`) ?? null;
const parentNode = this.layout.blocks.find(pos => pos.id === data.item_data.parent); const operations = this.layout.filter(pos => pos.nodeID.startsWith('o'));
if (operations.length === 0) {
return result;
}
if (data.arguments.length !== 0) { if (data.arguments.length !== 0) {
result = calculatePositionFromArgs(data.arguments, operations); const pos = calculatePositionFromArgs(
operations.filter(node => data.arguments.includes(Number(node.nodeID.slice(1))))
);
result.x = pos.x;
result.y = pos.y;
} else if (parentNode) { } else if (parentNode) {
result.x = parentNode.x + MIN_DISTANCE; result.x = parentNode.x + MIN_DISTANCE;
result.y = parentNode.y + MIN_DISTANCE; result.y = parentNode.y + MIN_DISTANCE;
} else { } else {
result = this.calculatePositionForFreeOperation(result); const pos = this.calculatePositionForFreeOperation(result);
result.x = pos.x;
result.y = pos.y;
} }
result = preventOverlap( preventOverlap(result, operations);
{ ...result, width: OPERATION_NODE_WIDTH, height: OPERATION_NODE_HEIGHT }, this.extendParentBounds(parentNode, result);
operations.map(node => ({ ...node, width: OPERATION_NODE_WIDTH, height: OPERATION_NODE_HEIGHT }))
);
if (parentNode) { return result;
const borderX = result.x + OPERATION_NODE_WIDTH + MIN_DISTANCE;
const borderY = result.y + OPERATION_NODE_HEIGHT + MIN_DISTANCE;
if (borderX > parentNode.x + parentNode.width) {
parentNode.width = borderX - parentNode.x;
}
if (borderY > parentNode.y + parentNode.height) {
parentNode.height = borderY - parentNode.y;
}
// TODO: trigger cascading updates
}
return { x: result.x, y: result.y };
} }
/** Calculate insert position for a new {@link IBlock} */ /** Calculate insert position for a new {@link IBlock} */
newBlockPosition(data: ICreateBlockDTO): Rectangle2D { newBlockPosition(data: ICreateBlockDTO): Rectangle2D {
const block_nodes = data.children_blocks const block_nodes = data.children_blocks
.map(id => this.layout.blocks.find(block => block.id === id)) .map(id => this.layout.find(block => block.nodeID === `b${id}`))
.filter(node => !!node); .filter(node => !!node);
const operation_nodes = data.children_operations const operation_nodes = data.children_operations
.map(id => this.layout.operations.find(operation => operation.id === id)) .map(id => this.layout.find(operation => operation.nodeID === `o${id}`))
.filter(node => !!node); .filter(node => !!node);
const parentNode = this.layout.blocks.find(pos => pos.id === data.item_data.parent); const parentNode = this.layout.find(pos => pos.nodeID === `b${data.item_data.parent}`) ?? null;
let result: Rectangle2D = { x: data.position_x, y: data.position_y, width: data.width, height: data.height }; let result: Rectangle2D = { x: data.position_x, y: data.position_y, width: data.width, height: data.height };
@ -98,61 +80,78 @@ export class LayoutManager {
if (block_nodes.length === 0 && operation_nodes.length === 0) { if (block_nodes.length === 0 && operation_nodes.length === 0) {
if (parentNode) { if (parentNode) {
const siblings = this.oss.blocks.filter(block => block.parent === parentNode.id).map(block => block.id); const siblings = this.oss.blocks
.filter(block => block.parent === data.item_data.parent)
.map(block => block.nodeID);
if (siblings.length > 0) { if (siblings.length > 0) {
result = preventOverlap( preventOverlap(
result, result,
this.layout.blocks.filter(block => siblings.includes(block.id)) this.layout.filter(node => siblings.includes(node.nodeID))
); );
} }
} else { } else {
const rootBlocks = this.oss.blocks.filter(block => block.parent === null).map(block => block.id); const rootBlocks = this.oss.blocks.filter(block => block.parent === null).map(block => block.nodeID);
if (rootBlocks.length > 0) { if (rootBlocks.length > 0) {
result = preventOverlap( preventOverlap(
result, result,
this.layout.blocks.filter(block => rootBlocks.includes(block.id)) this.layout.filter(node => rootBlocks.includes(node.nodeID))
); );
} }
} }
} }
if (parentNode) { this.extendParentBounds(parentNode, result);
const borderX = result.x + result.width + MIN_DISTANCE;
const borderY = result.y + result.height + MIN_DISTANCE;
if (borderX > parentNode.x + parentNode.width) {
parentNode.width = borderX - parentNode.x;
}
if (borderY > parentNode.y + parentNode.height) {
parentNode.height = borderY - parentNode.y;
}
// TODO: trigger cascading updates
}
return result; return result;
} }
/** Update layout when parent changes */ /** Update layout when parent changes */
onOperationChangeParent(targetID: number, newParent: number | null) { onChangeParent(targetID: string, newParent: string | null) {
console.error('not implemented', targetID, newParent); const targetNode = this.layout.find(pos => pos.nodeID === targetID);
if (!targetNode) {
return;
} }
/** Update layout when parent changes */ const parentNode = this.layout.find(pos => pos.nodeID === newParent) ?? null;
onBlockChangeParent(targetID: number, newParent: number | null) { const offset = this.calculateOffsetForParentChange(targetNode, parentNode);
console.error('not implemented', targetID, newParent); if (offset.x === 0 && offset.y === 0) {
return;
}
targetNode.x += offset.x;
targetNode.y += offset.y;
const children = this.oss.hierarchy.expandAllOutputs([targetID]);
const childrenPositions = this.layout.filter(pos => children.includes(pos.nodeID));
for (const child of childrenPositions) {
child.x += offset.x;
child.y += offset.y;
}
this.extendParentBounds(parentNode, targetNode);
}
private extendParentBounds(parent: INodePosition | null, child: Rectangle2D) {
if (!parent) {
return;
}
const borderX = child.x + child.width + MIN_DISTANCE;
const borderY = child.y + child.height + MIN_DISTANCE;
parent.width = Math.max(parent.width, borderX - parent.x);
parent.height = Math.max(parent.height, borderY - parent.y);
// TODO: cascade update
} }
private calculatePositionForFreeOperation(initial: Position2D): Position2D { private calculatePositionForFreeOperation(initial: Position2D): Position2D {
const operations = this.layout.operations; if (this.oss.operations.length === 0) {
if (operations.length === 0) {
return initial; return initial;
} }
const freeInputs = this.oss.operations const freeInputs = this.oss.operations
.filter(operation => operation.arguments.length === 0 && operation.parent === null) .filter(operation => operation.arguments.length === 0 && operation.parent === null)
.map(operation => operation.id); .map(operation => operation.nodeID);
let inputsPositions = operations.filter(pos => freeInputs.includes(pos.id)); let inputsPositions = this.layout.filter(pos => freeInputs.includes(pos.nodeID));
if (inputsPositions.length === 0) { if (inputsPositions.length === 0) {
inputsPositions = operations; inputsPositions = this.layout.filter(pos => pos.nodeID.startsWith('o'));
} }
const maxX = Math.max(...inputsPositions.map(node => node.x)); const maxX = Math.max(...inputsPositions.map(node => node.x));
const minY = Math.min(...inputsPositions.map(node => node.y)); const minY = Math.min(...inputsPositions.map(node => node.y));
@ -163,8 +162,8 @@ export class LayoutManager {
} }
private calculatePositionForFreeBlock(initial: Rectangle2D): Rectangle2D { private calculatePositionForFreeBlock(initial: Rectangle2D): Rectangle2D {
const rootBlocks = this.oss.blocks.filter(block => block.parent === null).map(block => block.id); const rootBlocks = this.oss.blocks.filter(block => block.parent === null).map(block => block.nodeID);
const blocksPositions = this.layout.blocks.filter(pos => rootBlocks.includes(pos.id)); const blocksPositions = this.layout.filter(pos => rootBlocks.includes(pos.nodeID));
if (blocksPositions.length === 0) { if (blocksPositions.length === 0) {
return initial; return initial;
} }
@ -172,6 +171,23 @@ export class LayoutManager {
const minY = Math.min(...blocksPositions.map(node => node.y)); const minY = Math.min(...blocksPositions.map(node => node.y));
return { ...initial, x: maxX + MIN_DISTANCE, y: minY }; return { ...initial, x: maxX + MIN_DISTANCE, y: minY };
} }
private calculateOffsetForParentChange(target: INodePosition, parent: INodePosition | null): Position2D {
const newPosition = { ...target };
if (parent === null) {
const rootElements = this.oss.hierarchy.rootNodes();
const positions = this.layout.filter(pos => rootElements.includes(pos.nodeID));
preventOverlap(newPosition, positions);
} else if (!rectanglesOverlap(target, parent)) {
newPosition.x = parent.x + MIN_DISTANCE;
newPosition.y = parent.y + MIN_DISTANCE;
const siblings = this.oss.hierarchy.at(parent.nodeID)?.outputs ?? [];
const siblingsPositions = this.layout.filter(pos => siblings.includes(pos.nodeID));
preventOverlap(newPosition, siblingsPositions);
}
return { x: newPosition.x - target.x, y: newPosition.y - target.y };
}
} }
// ======= Internals ======= // ======= Internals =======
@ -184,38 +200,25 @@ function rectanglesOverlap(a: Rectangle2D, b: Rectangle2D): boolean {
); );
} }
function getOverlapAmount(a: Rectangle2D, b: Rectangle2D): Position2D { function preventOverlap(target: Rectangle2D, fixedRectangles: Rectangle2D[]) {
const xOverlap = Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x));
const yOverlap = Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y));
return { x: xOverlap, y: yOverlap };
}
function preventOverlap(target: Rectangle2D, fixedRectangles: Rectangle2D[]): Rectangle2D {
let hasOverlap: boolean; let hasOverlap: boolean;
do { do {
hasOverlap = false; hasOverlap = false;
for (const fixed of fixedRectangles) { for (const fixed of fixedRectangles) {
if (rectanglesOverlap(target, fixed)) { if (rectanglesOverlap(target, fixed)) {
hasOverlap = true; hasOverlap = true;
const overlap = getOverlapAmount(target, fixed); target.x += MIN_DISTANCE;
if (overlap.x >= overlap.y) { target.y += MIN_DISTANCE;
target.x += overlap.x + MIN_DISTANCE;
} else {
target.y += overlap.y + MIN_DISTANCE;
}
break; break;
} }
} }
} while (hasOverlap); } while (hasOverlap);
return target;
} }
function calculatePositionFromArgs(args: number[], operations: IOperationPosition[]): Position2D { function calculatePositionFromArgs(args: INodePosition[]): Position2D {
const argNodes = operations.filter(pos => args.includes(pos.id)); const maxY = Math.max(...args.map(node => node.y));
const maxY = Math.max(...argNodes.map(node => node.y)); const minX = Math.min(...args.map(node => node.x));
const minX = Math.min(...argNodes.map(node => node.x)); const maxX = Math.max(...args.map(node => node.x));
const maxX = Math.max(...argNodes.map(node => node.x));
return { return {
x: Math.ceil((maxX + minX) / 2 / GRID_SIZE) * GRID_SIZE, x: Math.ceil((maxX + minX) / 2 / GRID_SIZE) * GRID_SIZE,
y: maxY + 2 * OPERATION_NODE_HEIGHT + MIN_DISTANCE y: maxY + 2 * OPERATION_NODE_HEIGHT + MIN_DISTANCE
@ -224,42 +227,23 @@ function calculatePositionFromArgs(args: number[], operations: IOperationPositio
function calculatePositionFromChildren( function calculatePositionFromChildren(
initial: Rectangle2D, initial: Rectangle2D,
operations: IOperationPosition[], operations: INodePosition[],
blocks: IBlockPosition[] blocks: INodePosition[]
): Rectangle2D { ): Rectangle2D {
let left = undefined; const allNodes = [...blocks, ...operations];
let top = undefined; if (allNodes.length === 0) {
let right = undefined; return initial;
let bottom = undefined;
for (const block of blocks) {
left = left === undefined ? block.x - MIN_DISTANCE : Math.min(left, block.x - MIN_DISTANCE);
top = top === undefined ? block.y - MIN_DISTANCE : Math.min(top, block.y - MIN_DISTANCE);
right =
right === undefined
? Math.max(left + initial.width, block.x + block.width + MIN_DISTANCE)
: Math.max(right, block.x + block.width + MIN_DISTANCE);
bottom = !bottom
? Math.max(top + initial.height, block.y + block.height + MIN_DISTANCE)
: Math.max(bottom, block.y + block.height + MIN_DISTANCE);
} }
for (const operation of operations) { const left = Math.min(...allNodes.map(n => n.x)) - MIN_DISTANCE;
left = left === undefined ? operation.x - MIN_DISTANCE : Math.min(left, operation.x - MIN_DISTANCE); const top = Math.min(...allNodes.map(n => n.y)) - MIN_DISTANCE;
top = top === undefined ? operation.y - MIN_DISTANCE : Math.min(top, operation.y - MIN_DISTANCE); const right = Math.max(...allNodes.map(n => n.x + n.width)) + MIN_DISTANCE;
right = const bottom = Math.max(...allNodes.map(n => n.y + n.height)) + MIN_DISTANCE;
right === undefined
? Math.max(left + initial.width, operation.x + OPERATION_NODE_WIDTH + MIN_DISTANCE)
: Math.max(right, operation.x + OPERATION_NODE_WIDTH + MIN_DISTANCE);
bottom = !bottom
? Math.max(top + initial.height, operation.y + OPERATION_NODE_HEIGHT + MIN_DISTANCE)
: Math.max(bottom, operation.y + OPERATION_NODE_HEIGHT + MIN_DISTANCE);
}
return { return {
x: left ?? initial.x, x: left,
y: top ?? initial.y, y: top,
width: right !== undefined && left !== undefined ? right - left : initial.width, width: right - left,
height: bottom !== undefined && top !== undefined ? bottom - top : initial.height height: bottom - top
}; };
} }

View File

@ -2,19 +2,27 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { EditorLibraryItem, ToolbarItemCard } from '@/features/library/components'; import { EditorLibraryItem } from '@/features/library/components/editor-library-item';
import { ToolbarItemCard } from '@/features/library/components/toolbar-item-card';
import { useWindowSize } from '@/hooks/use-window-size';
import { useModificationStore } from '@/stores/modification'; import { useModificationStore } from '@/stores/modification';
import { usePreferencesStore } from '@/stores/preferences';
import { globalIDs } from '@/utils/constants'; import { globalIDs } from '@/utils/constants';
import { OssStats } from '../../../components/oss-stats';
import { useOssEdit } from '../oss-edit-context'; import { useOssEdit } from '../oss-edit-context';
import { FormOSS } from './form-oss'; import { FormOSS } from './form-oss';
import { OssStats } from './oss-stats';
const SIDELIST_LAYOUT_THRESHOLD = 768; // px
export function EditorOssCard() { export function EditorOssCard() {
const { schema, isMutable, deleteSchema } = useOssEdit(); const { schema, isMutable, deleteSchema } = useOssEdit();
const { isModified } = useModificationStore(); const { isModified } = useModificationStore();
const showOSSStats = usePreferencesStore(state => state.showOSSStats);
const windowSize = useWindowSize();
const isNarrow = !!windowSize.width && windowSize.width <= SIDELIST_LAYOUT_THRESHOLD;
function initiateSubmit() { function initiateSubmit() {
const element = document.getElementById(globalIDs.library_item_editor) as HTMLFormElement; const element = document.getElementById(globalIDs.library_item_editor) as HTMLFormElement;
@ -33,25 +41,36 @@ export function EditorOssCard() {
} }
return ( return (
<> <div
onKeyDown={handleInput}
className={clsx(
'relative md:w-fit md:max-w-fit max-w-128',
'flex px-6 pt-8',
isNarrow && 'flex-col md:items-center'
)}
>
<ToolbarItemCard <ToolbarItemCard
className='cc-tab-tools' className='cc-tab-tools'
onSubmit={initiateSubmit} onSubmit={initiateSubmit}
schema={schema} schema={schema}
isMutable={isMutable} isMutable={isMutable}
deleteSchema={deleteSchema} deleteSchema={deleteSchema}
isNarrow={isNarrow}
/> />
<div
onKeyDown={handleInput} <div className='cc-column px-3 mx-0 md:mx-auto'>
className={clsx('md:max-w-fit max-w-128 min-w-fit', 'flex flex-row flex-wrap pt-8 px-6 justify-center')}
>
<div className='cc-column px-3'>
<FormOSS key={schema.id} /> <FormOSS key={schema.id} />
<EditorLibraryItem schema={schema} isAttachedToOSS={false} /> <EditorLibraryItem schema={schema} isAttachedToOSS={false} />
</div> </div>
<OssStats className='mt-3 md:mt-8 md:ml-5 w-80 md:w-56 mx-auto h-min' stats={schema.stats} /> <OssStats
className={clsx(
'w-80 md:w-56 mt-3 md:mt-8 mx-auto md:ml-5 md:mr-0',
'cc-animate-sidebar',
showOSSStats ? 'max-w-full' : 'opacity-0 max-w-0'
)}
stats={schema.stats}
/>
</div> </div>
</>
); );
} }

View File

@ -7,7 +7,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { type IUpdateLibraryItemDTO, LibraryItemType, schemaUpdateLibraryItem } from '@/features/library'; import { type IUpdateLibraryItemDTO, LibraryItemType, schemaUpdateLibraryItem } from '@/features/library';
import { useUpdateItem } from '@/features/library/backend/use-update-item'; import { useUpdateItem } from '@/features/library/backend/use-update-item';
import { ToolbarItemAccess } from '@/features/library/components'; import { ToolbarItemAccess } from '@/features/library/components/toolbar-item-access';
import { SubmitButton } from '@/components/control'; import { SubmitButton } from '@/components/control';
import { IconSave } from '@/components/icons'; import { IconSave } from '@/components/icons';

View File

@ -19,18 +19,13 @@ export function useContextMenu() {
const setHoverOperation = useOperationTooltipStore(state => state.setHoverItem); const setHoverOperation = useOperationTooltipStore(state => state.setHoverItem);
const { addSelectedNodes } = useStoreApi().getState(); const { addSelectedNodes } = useStoreApi().getState();
function handleContextMenu(event: React.MouseEvent<Element>, node: OssNode) { function openContextMenu(node: OssNode, clientX: number, clientY: number) {
event.preventDefault();
event.stopPropagation();
addSelectedNodes([node.id]); addSelectedNodes([node.id]);
setMenuProps({ setMenuProps({
item: node.type === 'block' ? node.data.block ?? null : node.data.operation ?? null, item: node.type === 'block' ? node.data.block ?? null : node.data.operation ?? null,
cursorX: event.clientX, cursorX: clientX,
cursorY: event.clientY cursorY: clientY
}); });
setIsOpen(true); setIsOpen(true);
setHoverOperation(null); setHoverOperation(null);
} }
@ -42,7 +37,7 @@ export function useContextMenu() {
return { return {
isOpen, isOpen,
menuProps, menuProps,
handleContextMenu, openContextMenu,
hideContextMenu hideContextMenu
}; };
} }

View File

@ -38,9 +38,6 @@ export function NodeCore({ node }: NodeCoreProps) {
'relative flex items-center justify-center p-[2px]', 'relative flex items-center justify-center p-[2px]',
isChild && 'border-accent-orange' isChild && 'border-accent-orange'
)} )}
data-tooltip-id={globalIDs.operation_tooltip}
data-tooltip-hidden={node.dragging}
onMouseEnter={() => setHover(node.data.operation)}
> >
<div className='absolute z-pop top-0 right-0 flex flex-col gap-[4px] p-[2px]'> <div className='absolute z-pop top-0 right-0 flex flex-col gap-[4px] p-[2px]'>
<Indicator <Indicator
@ -79,9 +76,12 @@ export function NodeCore({ node }: NodeCoreProps) {
<div <div
className={clsx( className={clsx(
'text-center line-clamp-2 pl-[4px]', 'text-center line-clamp-2 px-[4px] mr-[12px]',
longLabel ? 'text-[12px]/[16px] pr-[10px]' : 'text-[14px]/[20px] pr-[4px]' longLabel ? 'text-[12px]/[16px]' : 'text-[14px]/[20px]'
)} )}
data-tooltip-id={globalIDs.operation_tooltip}
data-tooltip-hidden={node.dragging}
onMouseEnter={() => setHover(node.data.operation)}
> >
{node.data.label} {node.data.label}
</div> </div>

View File

@ -6,6 +6,7 @@ import clsx from 'clsx';
import { DiagramFlow, useReactFlow, useStoreApi } from '@/components/flow/diagram-flow'; import { DiagramFlow, useReactFlow, useStoreApi } from '@/components/flow/diagram-flow';
import { useMainHeight } from '@/stores/app-layout'; import { useMainHeight } from '@/stores/app-layout';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
import { usePreferencesStore } from '@/stores/preferences';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
import { promptText } from '@/utils/labels'; import { promptText } from '@/utils/labels';
@ -23,6 +24,7 @@ import { useContextMenu } from './context-menu/use-context-menu';
import { OssNodeTypes } from './graph/oss-node-types'; import { OssNodeTypes } from './graph/oss-node-types';
import { CoordinateDisplay } from './coordinate-display'; import { CoordinateDisplay } from './coordinate-display';
import { useOssFlow } from './oss-flow-context'; import { useOssFlow } from './oss-flow-context';
import { SidePanel } from './side-panel';
import { ToolbarOssGraph } from './toolbar-oss-graph'; import { ToolbarOssGraph } from './toolbar-oss-graph';
import { useDragging } from './use-dragging'; import { useDragging } from './use-dragging';
import { useGetLayout } from './use-get-layout'; import { useGetLayout } from './use-get-layout';
@ -52,6 +54,7 @@ export function OssFlow() {
const showGrid = useOSSGraphStore(state => state.showGrid); const showGrid = useOSSGraphStore(state => state.showGrid);
const showCoordinates = useOSSGraphStore(state => state.showCoordinates); const showCoordinates = useOSSGraphStore(state => state.showCoordinates);
const showPanel = usePreferencesStore(state => state.showOssSidePanel);
const getLayout = useGetLayout(); const getLayout = useGetLayout();
const { updateLayout } = useUpdateLayout(); const { updateLayout } = useUpdateLayout();
@ -64,7 +67,7 @@ export function OssFlow() {
const showDeleteOperation = useDialogsStore(state => state.showDeleteOperation); const showDeleteOperation = useDialogsStore(state => state.showDeleteOperation);
const showEditBlock = useDialogsStore(state => state.showEditBlock); const showEditBlock = useDialogsStore(state => state.showEditBlock);
const { isOpen: isContextMenuOpen, menuProps, handleContextMenu, hideContextMenu } = useContextMenu(); const { isOpen: isContextMenuOpen, menuProps, openContextMenu, hideContextMenu } = useContextMenu();
const { handleDragStart, handleDrag, handleDragStop } = useDragging({ hideContextMenu }); const { handleDragStart, handleDrag, handleDragStop } = useDragging({ hideContextMenu });
function handleSavePositions() { function handleSavePositions() {
@ -135,8 +138,8 @@ export function OssFlow() {
}); });
} }
} else { } else {
if (node.data.operation?.result) { if (node.data.operation) {
navigateOperationSchema(Number(node.id)); navigateOperationSchema(node.data.operation.id);
} }
} }
} }
@ -183,19 +186,29 @@ export function OssFlow() {
setMouseCoords(targetPosition); setMouseCoords(targetPosition);
} }
function handleNodeContextMenu(event: React.MouseEvent<Element>, node: OssNode) {
event.preventDefault();
event.stopPropagation();
openContextMenu(node, event.clientX, event.clientY);
}
return ( return (
<div tabIndex={-1} className='relative' onMouseMove={showCoordinates ? handleMouseMove : undefined}> <div tabIndex={-1} className='relative' onMouseMove={showCoordinates ? handleMouseMove : undefined}>
{showCoordinates ? <CoordinateDisplay mouseCoords={mouseCoords} className='absolute top-1 right-2' /> : null} {showCoordinates ? <CoordinateDisplay mouseCoords={mouseCoords} className='absolute top-1 right-2' /> : null}
<ContextMenu isOpen={isContextMenuOpen} onHide={hideContextMenu} {...menuProps} />
<ToolbarOssGraph <ToolbarOssGraph
className='absolute z-pop top-8 right-1/2 translate-x-1/2' className='absolute z-pop top-8 right-1/2 translate-x-1/2'
onCreateOperation={handleCreateOperation} onCreateOperation={handleCreateOperation}
onCreateBlock={handleCreateBlock} onCreateBlock={handleCreateBlock}
onDelete={handleDeleteSelected} onDelete={handleDeleteSelected}
onResetPositions={resetGraph} onResetPositions={resetGraph}
openContextMenu={openContextMenu}
isContextMenuOpen={isContextMenuOpen}
hideContextMenu={hideContextMenu}
/> />
<ContextMenu isOpen={isContextMenuOpen} onHide={hideContextMenu} {...menuProps} />
<DiagramFlow <DiagramFlow
{...flowOptions} {...flowOptions}
className={clsx(!containMovement && 'cursor-relocate')} className={clsx(!containMovement && 'cursor-relocate')}
@ -209,12 +222,22 @@ export function OssFlow() {
showGrid={showGrid} showGrid={showGrid}
onClick={hideContextMenu} onClick={hideContextMenu}
onNodeDoubleClick={handleNodeDoubleClick} onNodeDoubleClick={handleNodeDoubleClick}
onNodeContextMenu={handleContextMenu} onNodeContextMenu={handleNodeContextMenu}
onContextMenu={hideContextMenu} onContextMenu={hideContextMenu}
onNodeDragStart={handleDragStart} onNodeDragStart={handleDragStart}
onNodeDrag={handleDrag} onNodeDrag={handleDrag}
onNodeDragStop={handleDragStop} onNodeDragStop={handleDragStop}
/> />
<SidePanel
className={clsx(
'absolute right-0 top-0 z-sticky w-84 min-h-80',
'cc-animate-panel cc-shadow-border',
showPanel ? 'translate-x-0' : 'opacity-0 translate-x-full pointer-events-none'
)}
isMounted={showPanel}
selectedItems={selectedItems}
/>
</div> </div>
); );
} }

View File

@ -0,0 +1 @@
export { SidePanel } from './side-panel';

View File

@ -0,0 +1,72 @@
import { Suspense } from 'react';
import clsx from 'clsx';
import { useDebounce } from 'use-debounce';
import { MiniButton } from '@/components/control';
import { IconClose } from '@/components/icons';
import { Loader } from '@/components/loader';
import { cn } from '@/components/utils';
import { useMainHeight } from '@/stores/app-layout';
import { usePreferencesStore } from '@/stores/preferences';
import { PARAMETER } from '@/utils/constants';
import { type IOssItem, NodeType } from '../../../../models/oss';
import { ViewSchema } from './view-schema';
interface SidePanelProps {
selectedItems: IOssItem[];
className?: string;
isMounted: boolean;
}
export function SidePanel({ selectedItems, isMounted, className }: SidePanelProps) {
const selectedOperation =
selectedItems.length === 1 && selectedItems[0].nodeType === NodeType.OPERATION ? selectedItems[0] : null;
const selectedSchema = selectedOperation?.result ?? null;
const debouncedMounted = useDebounce(isMounted, PARAMETER.moveDuration);
const closePanel = usePreferencesStore(state => state.toggleShowOssSidePanel);
const sidePanelHeight = useMainHeight();
return (
<div
className={cn(
'relative flex flex-col py-2 h-full overflow-hidden',
'border-l rounded-none rounded-l-sm bg-background',
className
)}
style={{ height: sidePanelHeight }}
>
<MiniButton
titleHtml='Закрыть панель'
aria-label='Закрыть'
noPadding
icon={<IconClose size='1.25rem' />}
className='absolute z-pop top-2 right-1'
onClick={closePanel}
/>
<div
className={clsx(
'mt-0 mb-1',
'font-medium text-sm select-none self-center',
'transition-transform',
selectedSchema && 'translate-x-16'
)}
>
Содержание КС
</div>
{!selectedOperation ? (
<div className='text-center text-sm cc-fade-in'>Выделите операцию для просмотра</div>
) : !selectedSchema ? (
<div className='text-center text-sm cc-fade-in'>Отсутствует концептуальная схема для выбранной операции</div>
) : debouncedMounted ? (
<Suspense fallback={<Loader />}>
<ViewSchema schemaID={selectedSchema} />
</Suspense>
) : null}
</div>
);
}

View File

@ -0,0 +1,195 @@
import { urls, useConceptNavigation } from '@/app';
import { type IConstituenta, type IRSForm } from '@/features/rsform';
import { CstType, type IConstituentaBasicsDTO, type ICreateConstituentaDTO } from '@/features/rsform/backend/types';
import { useCreateConstituenta } from '@/features/rsform/backend/use-create-constituenta';
import { useMoveConstituents } from '@/features/rsform/backend/use-move-constituents';
import { useMutatingRSForm } from '@/features/rsform/backend/use-mutating-rsform';
import { generateAlias } from '@/features/rsform/models/rsform-api';
import { useCstSearchStore } from '@/features/rsform/stores/cst-search';
import { MiniButton } from '@/components/control';
import { IconClone, IconDestroy, IconMoveDown, IconMoveUp, IconNewItem, IconRSForm } from '@/components/icons';
import { cn } from '@/components/utils';
import { useDialogsStore } from '@/stores/dialogs';
import { PARAMETER, prefixes } from '@/utils/constants';
import { type RO } from '@/utils/meta';
interface ToolbarConstituentsProps {
schema: IRSForm;
activeCst: IConstituenta | null;
setActive: (cstID: number) => void;
resetActive: () => void;
className?: string;
}
export function ToolbarConstituents({
schema,
activeCst,
setActive,
resetActive,
className
}: ToolbarConstituentsProps) {
const router = useConceptNavigation();
const isProcessing = useMutatingRSForm();
const searchText = useCstSearchStore(state => state.query);
const hasSearch = searchText.length > 0;
const showCreateCst = useDialogsStore(state => state.showCreateCst);
const showDeleteCst = useDialogsStore(state => state.showDeleteCst);
const { moveConstituents } = useMoveConstituents();
const { createConstituenta } = useCreateConstituenta();
function navigateRSForm() {
router.push({ path: urls.schema(schema.id) });
}
function onCreateCst(newCst: RO<IConstituentaBasicsDTO>) {
setActive(newCst.id);
setTimeout(() => {
const element = document.getElementById(`${prefixes.cst_list}${newCst.id}`);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'end'
});
}
}, PARAMETER.refreshTimeout);
}
function createCst() {
const targetType = activeCst?.cst_type ?? CstType.BASE;
const data: ICreateConstituentaDTO = {
insert_after: activeCst?.id ?? null,
cst_type: targetType,
alias: generateAlias(targetType, schema),
term_raw: '',
definition_formal: '',
definition_raw: '',
convention: '',
term_forms: []
};
showCreateCst({ schema: schema, onCreate: onCreateCst, initial: data });
}
function cloneCst() {
if (!activeCst) {
return;
}
void createConstituenta({
itemID: schema.id,
data: {
insert_after: activeCst.id,
cst_type: activeCst.cst_type,
alias: generateAlias(activeCst.cst_type, schema),
term_raw: activeCst.term_raw,
definition_formal: activeCst.definition_formal,
definition_raw: activeCst.definition_raw,
convention: activeCst.convention,
term_forms: activeCst.term_forms
}
}).then(onCreateCst);
}
function promptDeleteCst() {
if (!activeCst) {
return;
}
showDeleteCst({
schema: schema,
selected: [activeCst.id],
afterDelete: resetActive
});
}
function moveUp() {
if (!activeCst) {
return;
}
const currentIndex = schema.items.reduce((prev, cst, index) => {
if (activeCst.id !== cst.id) {
return prev;
} else if (prev === -1) {
return index;
}
return Math.min(prev, index);
}, -1);
const target = Math.max(0, currentIndex - 1);
void moveConstituents({
itemID: schema.id,
data: {
items: [activeCst.id],
move_to: target
}
});
}
function moveDown() {
if (!activeCst) {
return;
}
let count = 0;
const currentIndex = schema.items.reduce((prev, cst, index) => {
if (activeCst.id !== cst.id) {
return prev;
} else {
count += 1;
if (prev === -1) {
return index;
}
return Math.max(prev, index);
}
}, -1);
const target = Math.min(schema.items.length - 1, currentIndex - count + 2);
void moveConstituents({
itemID: schema.id,
data: {
items: [activeCst.id],
move_to: target
}
});
}
return (
<div className={cn('flex gap-0.5', className)}>
<MiniButton
title='Перейти к концептуальной схеме'
icon={<IconRSForm size='1rem' className='icon-primary' />}
onClick={navigateRSForm}
/>
<MiniButton
title='Создать конституенту'
icon={<IconNewItem size='1rem' className='icon-green' />}
onClick={createCst}
disabled={isProcessing}
/>
<MiniButton
title='Клонировать конституенту'
icon={<IconClone size='1rem' className='icon-green' />}
onClick={cloneCst}
disabled={!activeCst || isProcessing}
/>
<MiniButton
title='Удалить выделенную конституенту'
onClick={promptDeleteCst}
icon={<IconDestroy size='1rem' className='icon-red' />}
disabled={!activeCst || isProcessing || activeCst?.is_inherited}
/>
<MiniButton
title='Переместить вверх'
icon={<IconMoveUp size='1rem' className='icon-primary' />}
onClick={moveUp}
disabled={!activeCst || isProcessing || schema.items.length < 2 || hasSearch}
/>
<MiniButton
title='Переместить вниз'
icon={<IconMoveDown size='1rem' className='icon-primary' />}
onClick={moveDown}
disabled={!activeCst || isProcessing || schema.items.length < 2 || hasSearch}
/>
</div>
);
}

View File

@ -0,0 +1,45 @@
import { useState } from 'react';
import { useRSFormSuspense } from '@/features/rsform/backend/use-rsform';
import { RSFormStats } from '@/features/rsform/components/rsform-stats';
import { ViewConstituents } from '@/features/rsform/components/view-constituents';
import { useFitHeight } from '@/stores/app-layout';
import { ToolbarConstituents } from './toolbar-constituents';
interface ViewSchemaProps {
schemaID: number;
}
export function ViewSchema({ schemaID }: ViewSchemaProps) {
const { schema } = useRSFormSuspense({ itemID: schemaID });
const [activeID, setActiveID] = useState<number | null>(null);
const activeCst = activeID ? schema.cstByID.get(activeID) ?? null : null;
const listHeight = useFitHeight('19rem', '10rem');
return (
<div className='grid h-full relative cc-fade-in' style={{ gridTemplateRows: '1fr auto' }}>
<ToolbarConstituents
className='absolute -top-7 left-1'
schema={schema}
activeCst={activeCst}
setActive={setActiveID}
resetActive={() => setActiveID(null)}
/>
<ViewConstituents
dense
noBorder
className='border-y rounded-none'
schema={schema}
activeCst={activeCst}
onActivate={cst => setActiveID(cst.id)}
maxListHeight={listHeight}
/>
<RSFormStats className='pr-4 py-2 ml-[-1rem]' stats={schema.stats} />
</div>
);
}

View File

@ -1,18 +1,18 @@
'use client'; 'use client';
import { toast } from 'react-toastify'; import React from 'react';
import { HelpTopic } from '@/features/help'; import { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components'; import { BadgeHelp } from '@/features/help/components/badge-help';
import { IconShowSidebar } from '@/features/library/components/icon-show-sidebar';
import { type OssNode } from '@/features/oss/models/oss-layout';
import { MiniButton } from '@/components/control'; import { MiniButton } from '@/components/control';
import { import {
IconConceptBlock, IconConceptBlock,
IconDestroy, IconDestroy,
IconEdit2, IconEdit2,
IconExecute,
IconFitImage, IconFitImage,
IconFixLayout,
IconNewItem, IconNewItem,
IconReset, IconReset,
IconSave, IconSave,
@ -21,14 +21,12 @@ import {
import { type Styling } from '@/components/props'; import { type Styling } from '@/components/props';
import { cn } from '@/components/utils'; import { cn } from '@/components/utils';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
import { prepareTooltip } from '@/utils/utils'; import { usePreferencesStore } from '@/stores/preferences';
import { isIOS, prepareTooltip } from '@/utils/utils';
import { OperationType } from '../../../backend/types';
import { useExecuteOperation } from '../../../backend/use-execute-operation';
import { useMutatingOss } from '../../../backend/use-mutating-oss'; import { useMutatingOss } from '../../../backend/use-mutating-oss';
import { useUpdateLayout } from '../../../backend/use-update-layout'; import { useUpdateLayout } from '../../../backend/use-update-layout';
import { NodeType } from '../../../models/oss'; import { NodeType } from '../../../models/oss';
import { LayoutManager } from '../../../models/oss-layout-api';
import { useOssEdit } from '../oss-edit-context'; import { useOssEdit } from '../oss-edit-context';
import { useOssFlow } from './oss-flow-context'; import { useOssFlow } from './oss-flow-context';
@ -39,6 +37,10 @@ interface ToolbarOssGraphProps extends Styling {
onCreateBlock: () => void; onCreateBlock: () => void;
onDelete: () => void; onDelete: () => void;
onResetPositions: () => void; onResetPositions: () => void;
isContextMenuOpen: boolean;
openContextMenu: (node: OssNode, clientX: number, clientY: number) => void;
hideContextMenu: () => void;
} }
export function ToolbarOssGraph({ export function ToolbarOssGraph({
@ -46,12 +48,16 @@ export function ToolbarOssGraph({
onCreateBlock, onCreateBlock,
onDelete, onDelete,
onResetPositions, onResetPositions,
isContextMenuOpen,
openContextMenu,
hideContextMenu,
className, className,
...restProps ...restProps
}: ToolbarOssGraphProps) { }: ToolbarOssGraphProps) {
const { schema, selectedItems, isMutable, canDeleteOperation: canDelete } = useOssEdit(); const { schema, selectedItems, isMutable, canDeleteOperation: canDelete } = useOssEdit();
const isProcessing = useMutatingOss(); const isProcessing = useMutatingOss();
const { resetView } = useOssFlow(); const { resetView, nodes } = useOssFlow();
const selectedOperation = const selectedOperation =
selectedItems.length === 1 && selectedItems[0].nodeType === NodeType.OPERATION ? selectedItems[0] : null; selectedItems.length === 1 && selectedItems[0].nodeType === NodeType.OPERATION ? selectedItems[0] : null;
const selectedBlock = const selectedBlock =
@ -59,67 +65,31 @@ export function ToolbarOssGraph({
const getLayout = useGetLayout(); const getLayout = useGetLayout();
const { updateLayout } = useUpdateLayout(); const { updateLayout } = useUpdateLayout();
const { executeOperation } = useExecuteOperation();
const showEditOperation = useDialogsStore(state => state.showEditOperation); const showOptions = useDialogsStore(state => state.showOssOptions);
const showEditBlock = useDialogsStore(state => state.showEditBlock); const showSidePanel = usePreferencesStore(state => state.showOssSidePanel);
const showOssOptions = useDialogsStore(state => state.showOssOptions); const toggleShowSidePanel = usePreferencesStore(state => state.toggleShowOssSidePanel);
const readyForSynthesis = (() => {
if (!selectedOperation || selectedOperation.operation_type !== OperationType.SYNTHESIS) {
return false;
}
if (selectedOperation.result) {
return false;
}
const argumentIDs = schema.graph.expandInputs([selectedOperation.id]);
if (!argumentIDs || argumentIDs.length < 1) {
return false;
}
const argumentOperations = argumentIDs.map(id => schema.operationByID.get(id)!);
if (argumentOperations.some(item => item.result === null)) {
return false;
}
return true;
})();
function handleFixLayout() {
// TODO: implement layout algorithm
toast.info('Еще не реализовано');
}
function handleShowOptions() { function handleShowOptions() {
showOssOptions(); showOptions();
} }
function handleSavePositions() { function handleSavePositions() {
void updateLayout({ itemID: schema.id, data: getLayout() }); void updateLayout({ itemID: schema.id, data: getLayout() });
} }
function handleOperationExecute() { function handleEditItem(event: React.MouseEvent<HTMLButtonElement>) {
if (!readyForSynthesis || !selectedOperation) { if (isContextMenuOpen) {
hideContextMenu();
return; return;
} }
void executeOperation({ const nodeID = selectedOperation?.nodeID ?? selectedBlock?.nodeID;
itemID: schema.id, // if (!nodeID) {
data: { target: selectedOperation.id, layout: getLayout() } return;
});
} }
const node = nodes.find(node => node.id === nodeID);
function handleEditItem() { if (node) {
if (selectedOperation) { openContextMenu(node, event.clientX, event.clientY);
showEditOperation({
manager: new LayoutManager(schema, getLayout()),
target: selectedOperation
});
} else if (selectedBlock) {
showEditBlock({
manager: new LayoutManager(schema, getLayout()),
target: selectedBlock
});
} }
} }
@ -145,12 +115,9 @@ export function ToolbarOssGraph({
onClick={resetView} onClick={resetView}
/> />
<MiniButton <MiniButton
title='Исправить позиции узлов' title='Панель содержания КС'
icon={<IconFixLayout size='1.25rem' className='icon-primary' />} icon={<IconShowSidebar value={showSidePanel} isBottom={false} size='1.25rem' />}
onClick={handleFixLayout} onClick={toggleShowSidePanel}
disabled={
selectedItems.length > 1 || (selectedItems.length > 0 && selectedItems[0].nodeType === NodeType.OPERATION)
}
/> />
<MiniButton <MiniButton
title='Настройки отображения' title='Настройки отображения'
@ -169,11 +136,12 @@ export function ToolbarOssGraph({
disabled={isProcessing} disabled={isProcessing}
/> />
<MiniButton <MiniButton
titleHtml={prepareTooltip('Новый блок', 'Ctrl + Shift + Q')} titleHtml={prepareTooltip('Редактировать выбранную', isIOS() ? '' : 'Правый клик')}
aria-label='Новый блок' hideTitle={isContextMenuOpen}
icon={<IconConceptBlock size='1.25rem' className='icon-green' />} aria-label='Редактировать выбранную'
onClick={onCreateBlock} icon={<IconEdit2 size='1.25rem' className='icon-primary' />}
disabled={isProcessing} onClick={handleEditItem}
disabled={selectedItems.length !== 1 || isProcessing}
/> />
<MiniButton <MiniButton
titleHtml={prepareTooltip('Новая операция', 'Ctrl + Q')} titleHtml={prepareTooltip('Новая операция', 'Ctrl + Q')}
@ -183,18 +151,13 @@ export function ToolbarOssGraph({
disabled={isProcessing} disabled={isProcessing}
/> />
<MiniButton <MiniButton
title='Активировать операцию' titleHtml={prepareTooltip('Новый блок', 'Ctrl + Shift + Q')}
icon={<IconExecute size='1.25rem' className='icon-green' />} aria-label='Новый блок'
onClick={handleOperationExecute} icon={<IconConceptBlock size='1.25rem' className='icon-green' />}
disabled={isProcessing || selectedItems.length !== 1 || !readyForSynthesis} onClick={onCreateBlock}
/> disabled={isProcessing}
<MiniButton
titleHtml={prepareTooltip('Редактировать выбранную', 'Двойной клик')}
aria-label='Редактировать выбранную'
icon={<IconEdit2 size='1.25rem' className='icon-primary' />}
onClick={handleEditItem}
disabled={selectedItems.length !== 1 || isProcessing}
/> />
<MiniButton <MiniButton
titleHtml={prepareTooltip('Удалить выбранную', 'Delete')} titleHtml={prepareTooltip('Удалить выбранную', 'Delete')}
aria-label='Удалить выбранную' aria-label='Удалить выбранную'

View File

@ -3,6 +3,7 @@ import { type Node, useReactFlow } from 'reactflow';
import { type IOssLayout } from '../../../backend/types'; import { type IOssLayout } from '../../../backend/types';
import { type IOperationSchema } from '../../../models/oss'; import { type IOperationSchema } from '../../../models/oss';
import { type Position2D } from '../../../models/oss-layout'; import { type Position2D } from '../../../models/oss-layout';
import { OPERATION_NODE_HEIGHT, OPERATION_NODE_WIDTH } from '../../../models/oss-layout-api';
import { useOssEdit } from '../oss-edit-context'; import { useOssEdit } from '../oss-edit-context';
import { BLOCK_NODE_MIN_HEIGHT, BLOCK_NODE_MIN_WIDTH } from './graph/block-node'; import { BLOCK_NODE_MIN_HEIGHT, BLOCK_NODE_MIN_WIDTH } from './graph/block-node';
@ -14,22 +15,24 @@ export function useGetLayout() {
return function getLayout(): IOssLayout { return function getLayout(): IOssLayout {
const nodes = getNodes(); const nodes = getNodes();
const nodeById = new Map(nodes.map(node => [node.id, node])); const nodeById = new Map(nodes.map(node => [node.id, node]));
return { return [
operations: nodes ...nodes
.filter(node => node.type !== 'block') .filter(node => node.type !== 'block')
.map(node => ({ .map(node => ({
id: schema.itemByNodeID.get(node.id)!.id, nodeID: node.id,
...computeAbsolutePosition(node, schema, nodeById) ...computeAbsolutePosition(node, schema, nodeById),
width: OPERATION_NODE_WIDTH,
height: OPERATION_NODE_HEIGHT
})), })),
blocks: nodes ...nodes
.filter(node => node.type === 'block') .filter(node => node.type === 'block')
.map(node => ({ .map(node => ({
id: schema.itemByNodeID.get(node.id)!.id, nodeID: node.id,
...computeAbsolutePosition(node, schema, nodeById), ...computeAbsolutePosition(node, schema, nodeById),
width: node.width ?? BLOCK_NODE_MIN_WIDTH, width: node.width ?? BLOCK_NODE_MIN_WIDTH,
height: node.height ?? BLOCK_NODE_MIN_HEIGHT height: node.height ?? BLOCK_NODE_MIN_HEIGHT
})) }))
}; ];
}; };
} }

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