Compare commits

...
15 Commits
Author SHA1 Message Date
Lucas.Xu 2c513be305 chore: bump version 0.2.5 (#2924) 2023-07-02 15:15:57 +08:00
Lucas.Xu 11d05b303d feat: support inserting local image (#2913) 2023-07-02 11:46:45 +08:00
Richard Shiue c870dbeac4 test: add more cover integration tests (#2922) 2023-07-01 23:13:09 +08:00
Richard ShiueandLucas.Xu 3acd36e580 test: fix calendar integration test #2919 (#2920)
* test: fix calendar integration test #2919

* chore: update frontend/appflowy_flutter/integration_test/database_calendar_test.dart

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>

* test: fix date cell test

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2023-07-01 20:29:49 +08:00
Aman Negi 95d4fb6865 feat: add outline block (#2750) 2023-06-29 20:28:30 +08:00
Muhammad Rizwan 30b52a29fd feat: support Inline page reference #2196 (#2898) 2023-06-29 10:04:24 +08:00
Richard Shiue a17d6b7eec test: fix integration test (#2906)
* test: fix calendar integration test

* test: add comment for workaround
2023-06-28 22:11:50 +08:00
Kilu.He 18ed553296 feat: support equation block (#2903) 2023-06-28 12:39:04 +08:00
Richard Shiue 7f74fd6149 refactor: cover node widget code (#2899)
* chore: change initial cover type's name to none

* chore: refactor cover node widget

* chore: use a constant instead of magic value

* fix: make the size of icon hover effect smaller

* chore: improve appearance of selected color

* test: add cover integration tests

* fix: inner ring of selected color in dark mode

* refactor: cover node to document header node

* test: simplify tests

* chore: rename files
2023-06-27 15:17:51 +08:00
LainHE a3e09f6021 chore: update language zh-CN (#2900) 2023-06-27 11:11:09 +08:00
Kilu.He 5e0a0f92ff feat: support text equation (#2876) 2023-06-27 10:44:06 +08:00
Alex Wallen 2a2b5fe246 [feat] Add actions to error widget. (#2865)
* chore: rebase error?

* fix: revert changes in Cargo.toml

* feat: add actions row
2023-06-27 09:55:10 +08:00
rmri f914e7aa33 chore: Update hu-HU.json (#2837)
Fixed typos and added missing items in Hungarian translation.
2023-06-27 09:54:46 +08:00
Alex Wallen 386d3f2ba7 feat github redirect (#2867)
* feat: add GitHub to question bubble

* feat: translate to feedback
2023-06-27 09:53:38 +08:00
Nathan.fooo 88faa0f02a feat: using cached view (#2877) 2023-06-23 23:19:34 +08:00
140 changed files with 4982 additions and 1477 deletions

No files matched your search

+7
View File
@@ -7,6 +7,13 @@
- Unable to copy and paste a word
- Some UI issues
## Version 0.2.4 - 06/23/2023
### Bug fixes:
- Unable to copy and paste a word
- Some UI issues
## Version 0.2.3 - 06/21/2023
### New Features
+1 -1
View File
@@ -23,7 +23,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
CARGO_MAKE_CRATE_NAME = "dart-ffi"
LIB_NAME = "dart_ffi"
CURRENT_APP_VERSION = "0.2.4"
CURRENT_APP_VERSION = "0.2.5"
FLUTTER_DESKTOP_FEATURES = "dart,rev-sqlite"
PRODUCT_NAME = "AppFlowy"
# CRATE_TYPE: https://doc.rust-lang.org/reference/linkage.html
@@ -90,7 +90,8 @@
"name": "Debug Info",
"success": "Copied debug info to clipboard!",
"fail": "Unable to copy debug info to clipboard"
}
},
"feedback": "Feedback"
},
"menuAppHeader": {
"addPageTooltip": "Quickly add a page inside",
@@ -384,6 +385,9 @@
"createANewCalendar": "Create a new Calendar"
}
},
"selectionMenu": {
"outline": "Outline"
},
"plugins": {
"referencedBoard": "Referenced Board",
"referencedGrid": "Referenced Grid",
@@ -448,6 +452,12 @@
"center": "Center",
"right": "Right",
"defaultColor": "Default"
},
"image": {
"copiedToPasteBoard": "The image link has been copied to the clipboard"
},
"outline": {
"addHeadingToCreateOutline": "Add headings to create a table of contents."
}
}
},
@@ -120,14 +120,14 @@
"oAuth": {
"err": {
"failedTitle": "Sikertelen bejelentkezés.",
"failedMsg": "Kérjük győződj meg róla, hogy elvégezted a bejelentkezési folyamatot a böngésződben"
"failedMsg": "Kérjük győződj meg róla, hogy elvégezted a bejelentkezési folyamatot a böngésződben!"
},
"google": {
"title": "Bejelentkezés Google-al",
"title": "Bejelentkezés Google-lal",
"instruction1": "Ahhoz, hogy hozzáférj a Google Kontaktjaidhoz, kérjük hatalmazd fel ezt az alkalmazást a böngésződben.",
"instruction2": "Másold ezt a kódot a vágólapra az ikonra kattintással vagy a szöveg kijelölésével:",
"instruction3": "Nyisd meg ezt a linket a böngésződben, és írjd be a fenti kódot:",
"instruction4": "Nyomd meg az alábbi gombot, ha elvégezted a registrációt:"
"instruction3": "Nyisd meg ezt a linket a böngésződben, és írd be a fenti kódot:",
"instruction4": "Nyomd meg az alábbi gombot, ha elvégezted a regisztrációt:"
}
},
"settings": {
@@ -139,15 +139,15 @@
},
"appearance": {
"themeMode": {
"label": "Theme Mode",
"label": "Téma",
"light": "Világos mód",
"dark": "Éjjeli mód",
"system": "Adapt to System"
"system": "Rendszerbeállítás követése"
}
}
},
"sideBar": {
"openSidebar": "Open sidebar",
"closeSidebar": "Close sidebar"
"openSidebar": "Oldalsáv megnyitása",
"closeSidebar": "Oldalsáv bezárása"
}
}
}
@@ -6,6 +6,8 @@
"subscribeNewsletterText": "消息订阅",
"letsGoButtonText": "开始",
"title": "标题",
"youCanAlso": "您还可以",
"and": "以及",
"signUp": {
"buttonText": "注册",
"title": "注册 @:appName 账户",
@@ -21,6 +23,7 @@
"signIn": {
"loginTitle": "登录 @:appName",
"loginButtonText": "登录",
"loginAsGuestButtonText": "开始使用",
"buttonText": "登录",
"forgotPassword": "忘记密码?",
"emailHint": "邮箱",
@@ -40,6 +43,21 @@
"markdown": "Markdown",
"copyLink": "复制链接"
},
"moreAction": {
"small": "小",
"medium": "中",
"large": "大",
"fontSize": "字号",
"import": "导入",
"moreOptions": "更多选项"
},
"importPanel": {
"textAndMarkdown": "文本 & Markdown",
"documentFromV010": "来自 v0.1.0 的文档",
"databaseFromV010": "来自 v0.1.0 的数据库",
"csv": "CSV",
"database": "数据库"
},
"disclosureAction": {
"rename": "重命名",
"delete": "删除",
@@ -54,7 +72,7 @@
"pageHeader": {
"fileName": "文件名",
"lastModified": "最近修改",
"created": "创建"
"created": "创建时间"
}
},
"deletePagePrompt": {
@@ -64,8 +82,10 @@
},
"dialogCreatePageNameHint": "页面名称",
"questionBubble": {
"shortcuts": "快捷键",
"whatsNew": "新功能?",
"help": "帮助 & 支持",
"markdown": "Markdown",
"debug": {
"name": "调试信息",
"success": "将调试信息复制到剪贴板!",
@@ -90,14 +110,18 @@
"inlineCode": "内联代码",
"quote": "块引用",
"header": "标题",
"highlight": "高亮"
"highlight": "高亮",
"color": "颜色"
},
"tooltip": {
"lightMode": "切换到亮色模式",
"darkMode": "切换到暗色模式",
"openAsPage": "作为页面打开",
"addNewRow": "增加一行",
"openMenu": "点击打开菜单"
"addNewRow": "添加一行",
"openMenu": "点击打开菜单",
"dragRow": "拖动来调整行",
"viewDataBase": "查看数据库",
"referencePage": "这个 {name} 正在被引用"
},
"sideBar": {
"openSidebar": "打开侧边栏",
@@ -117,11 +141,19 @@
},
"button": {
"OK": "确认",
"Done": "完成",
"Cancel": "取消",
"signIn": "登录",
"signOut": "登出",
"complete": "完成",
"save": "保存"
"save": "保存",
"generate": "生成",
"esc": "ESC",
"keep": "保留",
"tryAgain": "重试",
"discard": "放弃",
"replace": "替换",
"insertBelow": "下方插入"
},
"label": {
"welcome": "欢迎!",
@@ -149,37 +181,133 @@
"appearance": "外观",
"language": "语言",
"user": "用户",
"files": "文件",
"open": "打开设置"
},
"appearance": {
"themeMode": {
"label": "Theme Mode",
"label": "主题模式",
"light": "日间模式",
"dark": "夜间模式",
"system": "Adapt to System"
}
"system": "系统自适应"
},
"theme": "主题"
},
"files": {
"copy": "复制",
"defaultLocation": "读取文件和数据存储位置",
"exportData": "导出您的数据",
"doubleTapToCopy": "双击复制路径",
"restoreLocation": "恢复为 AppFlowy 默认路径",
"customizeLocation": "打开另一个文件夹",
"restartApp": "请重启 app 使设置生效",
"exportDatabase": "导出数据库",
"selectFiles": "选择需要导出的文件",
"selectAll": "全选",
"deselectAll": "取消全选",
"createNewFolder": "新建文件夹",
"createNewFolderDesc": "告诉我们您要将数据存储在何处",
"defineWhereYourDataIsStored": "定义数据存储位置",
"open": "打开",
"openFolder": "打开现有文件夹",
"openFolderDesc": "读取并将其写入您现有的 AppFlowy 文件夹",
"folderHintText": "文件夹名",
"location": "正在新建文件夹",
"locationDesc": "为您的 AppFlowy 数据文件夹选择一个名称",
"browser": "浏览",
"create": "新建",
"set": "设置",
"folderPath": "保存文件夹的路径",
"locationCannotBeEmpty": "路径不能为空",
"pathCopiedSnackbar": "文件存储路径已被复制到剪贴板!",
"changeLocationTooltips": "更改数据目录",
"change": "更改",
"openLocationTooltips": "打开另一个数据目录",
"openCurrentDataFolder": "打开当前数据目录",
"recoverLocationTooltips": "恢复为 AppFlowy 默认数据目录",
"exportFileSuccess": "导出文件成功!",
"exportFileFail": "导出文件失败!",
"export": "导出"
},
"user": {
"name": "名字",
"icon": "图标",
"selectAnIcon": "选择一个图标",
"pleaseInputYourOpenAIKey": "请输入您的 OpenAI 密钥"
}
},
"grid": {
"deleteView": "您确定要删除这个视窗吗?",
"createView": "新建",
"settings": {
"filter": "过滤器",
"filter": "筛选",
"sort": "以……排序",
"sortBy": "排序",
"Properties": "属性",
"group": "组"
"group": "组",
"addFilter": "添加筛选",
"deleteFilter": "删除筛选",
"filterBy": "以……筛选",
"typeAValue": "请输入一个值",
"layout": "布局",
"databaseLayout": "布局"
},
"textFilter": {
"contains": "包含",
"doesNotContain": "不包含",
"endsWith": "以……结束",
"startWith": "以……开始",
"is": "等于",
"isNot": "不等于",
"isEmpty": "为空",
"isNotEmpty": "不为空",
"choicechipPrefix": {
"isNot": "不等于",
"startWith": "以……开始",
"endWith": "以……结束",
"isEmpty": "为空",
"isNotEmpty": "不为空"
}
},
"checkboxFilter": {
"isChecked": "已勾选",
"isUnchecked": "未勾选",
"choicechipPrefix": {
"is": "is"
}
},
"checklistFilter": {
"isComplete": "已完成",
"isIncomplted": "未完成"
},
"singleSelectOptionFilter": {
"is": "等于",
"isNot": "不等于",
"isEmpty": "为空",
"isNotEmpty": "不为空"
},
"multiSelectOptionFilter": {
"contains": "包含",
"doesNotContain": "不包含",
"isEmpty": "为空",
"isNotEmpty": "不为空"
},
"field": {
"hide": "隐藏",
"insertLeft": "左侧插入",
"insertRight": "右侧插入",
"duplicate": "拷贝",
"duplicate": "复制",
"delete": "删除",
"textFieldName": "文本",
"checkboxFieldName": "勾选框",
"dateFieldName": "日期",
"updatedAtFieldName": "修改时间",
"createdAtFieldName": "创建时间",
"numberFieldName": "数字",
"singleSelectFieldName": "单项选择器",
"multiSelectFieldName": "多项选择器",
"urlFieldName": "链接",
"checklistFieldName": "清单",
"numberFormat": "数字格式",
"dateFormat": "日期格式",
"includeTime": "包含时间",
@@ -187,6 +315,7 @@
"dateFormatISO": "年-月-日",
"dateFormatLocal": "月/日/年",
"dateFormatUS": "年/月/日",
"dateFormatDayMonthYear": "日/月/年",
"timeFormat": "时间格式",
"invalidTimeFormat": "时间格式错误",
"timeFormatTwelveHour": "12小时制",
@@ -195,16 +324,23 @@
"optionTitle": "标签",
"addOption": "添加标签",
"editProperty": "编辑列属性",
"newProperty": "增加一列",
"newProperty": "添加一列",
"deleteFieldPromptMessage": "确定要删除这个属性吗? "
},
"sort": {
"ascending": "升序",
"descending": "降序",
"deleteSort": "删除排序",
"addSort": "添加排序"
},
"row": {
"duplicate": "复制",
"delete": "删除",
"textPlaceholder": "空",
"copyProperty": "复制列",
"count": "数量",
"newRow": "添加一行"
"newRow": "添加一行",
"action": "执行"
},
"selectOption": {
"create": "新建",
@@ -222,19 +358,130 @@
"panelTitle": "选择或新建一个标签",
"searchOption": "搜索标签"
},
"menuName": "网格"
"checklist": {
"panelTitle": "添加项"
},
"menuName": "网格",
"referencedGridPrefix": "视图"
},
"document": {
"menuName": "文档",
"date": {
"timeHintTextInTwelveHour": "01:00 PM",
"timeHintTextInTwentyFourHour": "13:00"
},
"slashMenu": {
"board": {
"selectABoardToLinkTo": "选择要链接到的看板",
"createANewBoard": "新建看板"
},
"grid": {
"selectAGridToLinkTo": "选择要链接到的网格",
"createANewGrid": "新建网格"
},
"calendar": {
"selectACalendarToLinkTo": "选择要链接到的日历",
"createANewCalendar": "新建日历"
}
},
"plugins": {
"referencedBoard": "引用的看板",
"referencedGrid": "引用的网格",
"referencedCalendar": "引用的日历",
"autoGeneratorMenuItemName": "OpenAI 创作",
"autoGeneratorTitleName": "OpenAI: 让 AI 写些什么...",
"autoGeneratorLearnMore": "学习更多",
"autoGeneratorGenerate": "生成",
"autoGeneratorHintText": "让 OpenAI ...",
"autoGeneratorCantGetOpenAIKey": "无法获得 OpenAI 密钥",
"autoGeneratorRewrite": "重写",
"smartEdit": "AI 助手",
"openAI": "OpenAI",
"smartEditFixSpelling": "修正拼写",
"warning": "⚠️ AI 可能不准确或具有误导性.",
"smartEditSummarize": "总结",
"smartEditImproveWriting": "提高写作水平",
"smartEditMakeLonger": "丰富内容",
"smartEditCouldNotFetchResult": "无法从 OpenAI 获取到结果",
"smartEditCouldNotFetchKey": "无法获取到 OpenAI 密钥",
"smartEditDisabled": "在设置中连接 OpenAI",
"discardResponse": "您是否要放弃 AI 继续写作?",
"cover": {
"changeCover": "修改封面",
"colors": "颜色",
"images": "图像",
"clearAll": "清除所有",
"abstract": "摘要",
"addCover": "添加封面",
"addLocalImage": "添加本地图像",
"invalidImageUrl": "无效的图像网址",
"failedToAddImageToGallery": "无法将图片添加到库",
"enterImageUrl": "输入图像网址",
"add": "添加",
"back": "返回",
"saveToGallery": "保存至库",
"removeIcon": "移除图标",
"pasteImageUrl": "粘贴图片网址",
"or": "或",
"pickFromFiles": "从文件中选取",
"couldNotFetchImage": "无法获取到图像",
"imageSavingFailed": "图像保存失败",
"addIcon": "添加图标",
"coverRemoveAlert": "删除后将从封面中移除。",
"alertDialogConfirmation": "您确定您要继续吗?"
},
"mathEquation": {
"addMathEquation": "添加数学公式",
"editMathEquation": "编辑数学公式"
},
"optionAction": {
"click": "点击",
"toOpenMenu": " 来打开菜单",
"delete": "删除",
"duplicate": "复制",
"turnInto": "变成",
"moveUp": "上移",
"moveDown": "下移",
"color": "颜色",
"align": "对齐",
"left": "左",
"center": "中心",
"right": "又",
"defaultColor": "默认"
}
}
},
"board": {
"column": {
"create_new_card": "新建"
},
"menuName": "看板"
"menuName": "看板",
"referencedBoardPrefix": "视图"
},
"calendar": {
"menuName": "日历",
"defaultNewCalendarTitle": "未命名",
"navigation": {
"today": "今天",
"jumpToday": "跳转到今天",
"previousMonth": "上一月",
"nextMonth": "下一月"
},
"settings": {
"showWeekNumbers": "显示周数",
"showWeekends": "显示周末",
"firstDayOfWeek": "一周开始于",
"layoutDateField": "以……为日历布局",
"noDateTitle": "没有日期",
"noDateHint": "计划外事件将显示在此处",
"clickToAdd": "单击以添加到日历",
"name": "日历布局"
},
"referencedCalendarPrefix": "视图"
},
"errorDialog": {
"title": "AppFlowy 错误",
"howToFixFallback": "对于给您带来的不便, 我们深表歉意! 请在我们的 GitHub 页面上提交 issue 并描述您遇到的错误。",
"github": "在 GitHub 查看"
}
}
}
@@ -206,12 +206,13 @@ void main() {
await tester.tapCreateCalendarButton();
// Create a new event in today's calendar cell
final today = DateTime.now();
await tester.scrollToToday();
await tester.doubleClickCalendarCell(DateTime.now());
await tester.doubleClickCalendarCell(today);
await tester.dismissRowDetailPage();
// Make sure that the event is today
tester.assertNumberofEventsOnSpecificDay(1, DateTime.now());
tester.assertNumberofEventsOnSpecificDay(1, today);
// Click on the event
await tester.openCalendarEvent(index: 0);
@@ -220,9 +221,11 @@ void main() {
await tester.tapDateCellInRowDetailPage();
await tester.findDateEditor(findsOneWidget);
// Edit the event's date
final tomorrow = DateTime.now().add(const Duration(days: 1));
await tester.selectDay(content: tomorrow.day);
// Edit the event's date. To avoid selecting a day outside of the current month, the new date will be one day closer to the middle of the month.
final newDate = today.day < 15
? today.add(const Duration(days: 1))
: today.subtract(const Duration(days: 1));
await tester.selectDay(content: newDate.day);
await tester.dismissCellEditor();
// Dismiss the row details page
@@ -230,7 +233,7 @@ void main() {
// Make sure that the event is edited
tester.assertNumberOfEventsInCalendar(1);
tester.assertNumberofEventsOnSpecificDay(1, tomorrow);
tester.assertNumberofEventsOnSpecificDay(1, newDate);
});
testWidgets('reschedule an event by drag-and-drop', (tester) async {
@@ -237,7 +237,7 @@ void main() {
await tester.assertDateCellInGrid(
rowIndex: 0,
fieldType: fieldType,
content: DateFormat('MMM d, y').format(today),
content: DateFormat('MMM dd, y').format(today),
);
await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType);
@@ -252,7 +252,7 @@ void main() {
await tester.assertDateCellInGrid(
rowIndex: 0,
fieldType: fieldType,
content: DateFormat('MMM d, y HH:mm').format(now),
content: DateFormat('MMM dd, y HH:mm').format(now),
);
await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType);
@@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'util/database_test_op.dart';
import 'util/emoji.dart';
import 'util/ime.dart';
import 'util/util.dart';
@@ -1,6 +1,8 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../util/emoji.dart';
import '../util/util.dart';
void main() {
@@ -22,15 +24,112 @@ void main() {
await TestFolder.cleanTestLocation(null);
});
testWidgets(
'hovering on cover image will display change and delete cover image buttons',
(tester) async {
testWidgets('document cover tests', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.editor.hoverOnCoverPluginAddButton();
tester.expectToSeeNoDocumentCover();
// Hover over cover toolbar to show 'Add Cover' and 'Add Icon' buttons
await tester.editor.hoverOnCoverToolbar();
tester.expectToSeePluginAddCoverAndIconButton();
// Insert a document cover
await tester.editor.tapOnAddCover();
tester.expectToSeeDocumentCover(CoverType.asset);
// Hover over the cover to show the 'Change Cover' and delete buttons
await tester.editor.hoverOnCover();
tester.expectChangeCoverAndDeleteButton();
// Change cover to a solid color background
await tester.editor.tapOnChangeCover();
await tester.editor.switchSolidColorBackground();
await tester.editor.dismissCoverPicker();
tester.expectToSeeDocumentCover(CoverType.color);
// Change cover to a network image
const imageUrl =
"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy/main/frontend/appflowy_flutter/assets/images/appflowy_launch_splash.jpg";
await tester.editor.hoverOnCover();
await tester.editor.tapOnChangeCover();
await tester.editor.addNetworkImageCover(imageUrl);
await tester.editor.switchNetworkImageCover(imageUrl);
await tester.editor.dismissCoverPicker();
tester.expectToSeeDocumentCover(CoverType.file);
// Remove the cover
await tester.editor.hoverOnCover();
await tester.editor.tapOnRemoveCover();
tester.expectToSeeNoDocumentCover();
});
testWidgets('document icon tests', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
tester.expectToSeeDocumentIcon(null);
// Hover over cover toolbar to show the 'Add Cover' and 'Add Icon' buttons
await tester.editor.hoverOnCoverToolbar();
tester.expectToSeePluginAddCoverAndIconButton();
// Insert a document icon
await tester.editor.tapAddIconButton();
await tester.switchToEmojiList();
await tester.tapEmoji('😀');
tester.expectToSeeDocumentIcon('😀');
// Remove the document icon from the cover toolbar
await tester.editor.hoverOnCoverToolbar();
await tester.editor.tapRemoveIconButton();
tester.expectToSeeDocumentIcon(null);
// Add the icon back for further testing
await tester.editor.hoverOnCoverToolbar();
await tester.editor.tapAddIconButton();
await tester.switchToEmojiList();
await tester.tapEmoji('😀');
tester.expectToSeeDocumentIcon('😀');
// Change the document icon
await tester.editor.tapOnIconWidget();
await tester.switchToEmojiList();
await tester.tapEmoji('😅');
tester.expectToSeeDocumentIcon('😅');
// Remove the document icon from the icon picker
await tester.editor.tapOnIconWidget();
await tester.editor.tapRemoveIconButton(isInPicker: true);
tester.expectToSeeDocumentIcon(null);
});
testWidgets('icon and cover at the same time', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
tester.expectToSeeDocumentIcon(null);
tester.expectToSeeNoDocumentCover();
// Hover over cover toolbar to show the 'Add Cover' and 'Add Icon' buttons
await tester.editor.hoverOnCoverToolbar();
tester.expectToSeePluginAddCoverAndIconButton();
// Insert a document icon
await tester.editor.tapAddIconButton();
await tester.switchToEmojiList();
await tester.tapEmoji('😀');
// Insert a document cover
await tester.editor.tapOnAddCover();
// Expect to see the icon and cover at the same time
tester.expectToSeeDocumentIcon('😀');
tester.expectToSeeDocumentCover(CoverType.asset);
// Hover over the cover toolbar and see that neither icons are shown
await tester.editor.hoverOnCoverToolbar();
tester.expectToSeeEmptyDocumentHeaderToolbar();
});
});
}
@@ -1,4 +1,5 @@
import 'package:appflowy/plugins/database_view/board/presentation/board_page.dart';
import 'package:appflowy/plugins/database_view/calendar/presentation/calendar_page.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/grid_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
@@ -56,21 +57,21 @@ void main() {
);
});
// testWidgets('insert a referenced calendar', (tester) async {
// await tester.initializeAppFlowy();
// await tester.tapGoButton();
testWidgets('insert a referenced calendar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// await insertReferenceDatabase(tester, ViewLayoutPB.Calendar);
await insertReferenceDatabase(tester, ViewLayoutPB.Calendar);
// // validate the referenced grid is inserted
// expect(
// find.descendant(
// of: find.byType(AppFlowyEditor),
// matching: find.byType(CalendarPage),
// ),
// findsOneWidget,
// );
// });
// validate the referenced grid is inserted
expect(
find.descendant(
of: find.byType(AppFlowyEditor),
matching: find.byType(CalendarPage),
),
findsOneWidget,
);
});
});
}
@@ -93,7 +94,7 @@ Future<void> insertReferenceDatabase(
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
// insert a referenced grid
// insert a referenced view
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
layout.referencedMenuName,
@@ -0,0 +1,129 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('inline page view in document', () {
const location = 'inline_page';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('insert a inline page - grid', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await insertingInlinePage(tester, ViewLayoutPB.Grid);
final mentionBlock = find.byType(MentionPageBlock);
expect(mentionBlock, findsOneWidget);
await tester.tapButton(mentionBlock);
});
testWidgets('insert a inline page - board', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await insertingInlinePage(tester, ViewLayoutPB.Board);
final mentionBlock = find.byType(MentionPageBlock);
expect(mentionBlock, findsOneWidget);
await tester.tapButton(mentionBlock);
});
testWidgets('insert a inline page - calendar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await insertingInlinePage(tester, ViewLayoutPB.Calendar);
final mentionBlock = find.byType(MentionPageBlock);
expect(mentionBlock, findsOneWidget);
await tester.tapButton(mentionBlock);
});
testWidgets('insert a inline page - document', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await insertingInlinePage(tester, ViewLayoutPB.Document);
final mentionBlock = find.byType(MentionPageBlock);
expect(mentionBlock, findsOneWidget);
await tester.tapButton(mentionBlock);
});
testWidgets('insert a inline page and rename it', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
final pageName = await insertingInlinePage(tester, ViewLayoutPB.Document);
// rename
await tester.hoverOnPageName(pageName);
const newName = 'RenameToNewPageName';
await tester.renamePage(newName);
final finder = find.descendant(
of: find.byType(MentionPageBlock),
matching: find.findTextInFlowyText(newName),
);
expect(finder, findsOneWidget);
});
testWidgets('insert a inline page and delete it', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
final pageName = await insertingInlinePage(tester, ViewLayoutPB.Grid);
// rename
await tester.hoverOnPageName(pageName);
await tester.tapDeletePageButton();
final finder = find.descendant(
of: find.byType(MentionPageBlock),
matching: find.findTextInFlowyText(pageName),
);
expect(finder, findsOneWidget);
await tester.tapButton(finder);
expect(find.byType(FlowyErrorPage), findsOneWidget);
});
});
}
/// Insert a referenced database of [layout] into the document
Future<String> insertingInlinePage(
WidgetTester tester,
ViewLayoutPB layout,
) async {
// create a new grid
final id = uuid();
final name = '${layout.name}_$id';
await tester.createNewPageWithName(
layout,
name,
);
// create a new document
await tester.createNewPageWithName(
ViewLayoutPB.Document,
'insert_a_inline_page_${layout.name}',
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
// insert a inline page
await tester.editor.showAtMenu();
await tester.editor.tapAtMenuItemWithName(name);
return name;
}
@@ -118,7 +118,7 @@ const _sample = r'''
---
[] Highlight any text, and use the editing menu to _style_ **your** writing `however` you ~~like.~~
[] Type / followed by /bullet or /num to create a list.
[] Type followed by bullet or num to create a list.
[x] Click `+ New Page` button at the bottom of your sidebar to add a new page.
@@ -0,0 +1,114 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../util/ime.dart';
import '../util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('outline block test', () {
const location = 'outline_test';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('insert an outline block', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.createNewPageWithName(
ViewLayoutPB.Document,
'outline_test',
);
await tester.editor.tapLineOfEditorAt(0);
await insertOutlineInDocument(tester);
// validate the outline is inserted
expect(find.byType(OutlineBlockWidget), findsOneWidget);
});
testWidgets('insert an outline block and check if headings are visible',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.createNewPageWithName(
ViewLayoutPB.Document,
'outline_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText('# Heading 1\n');
await tester.ime.insertText('## Heading 2\n');
await tester.ime.insertText('### Heading 3\n');
/* Results in:
* # Heading 1
* ## Heading 2
* ### Heading 3
*/
await tester.editor.tapLineOfEditorAt(3);
await insertOutlineInDocument(tester);
expect(
find.descendant(
of: find.byType(OutlineBlockWidget),
matching: find.text('Heading 1'),
),
findsOneWidget,
);
// Heading 2 is prefixed with a bullet
expect(
find.descendant(
of: find.byType(OutlineBlockWidget),
matching: find.text('Heading 2'),
),
findsOneWidget,
);
// Heading 3 is prefixed with a dash
expect(
find.descendant(
of: find.byType(OutlineBlockWidget),
matching: find.text('Heading 3'),
),
findsOneWidget,
);
// update the Heading 1 to Heading 1Hello world
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText('Hello world');
expect(
find.descendant(
of: find.byType(OutlineBlockWidget),
matching: find.text('Heading 1Hello world'),
),
findsOneWidget,
);
});
});
}
/// Inserts an outline block in the document
Future<void> insertOutlineInDocument(WidgetTester tester) async {
// open the actions menu and insert the outline block
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_selectionMenu_outline.tr(),
);
await tester.pumpAndSettle();
}
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/board/presentation/board_page.dart';
import 'package:appflowy/plugins/database_view/calendar/application/calendar_bloc.dart';
import 'package:appflowy/plugins/database_view/calendar/presentation/calendar_day.dart';
import 'package:appflowy/plugins/database_view/calendar/presentation/calendar_page.dart';
import 'package:appflowy/plugins/database_view/calendar/presentation/toolbar/calendar_layout_setting.dart';
@@ -40,6 +41,7 @@ import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:calendar_view/calendar_view.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/text_input.dart';
@@ -294,7 +296,17 @@ extension AppFlowyDatabaseTest on WidgetTester {
matching: findDay,
);
await tapButton(finder);
// if the day is very near the beginning or the end of the month,
// it may overlap with the same day in the next or previous month,
// respectively because it was spilling over. This will lead to 2
// widgets being found and thus cannot be tapped correctly.
if (content < 15) {
// e.g., Jan 2 instead of Feb 2
await tapButton(finder.first);
} else {
// e.g. Jun 28 instead of May 28
await tapButton(finder.last);
}
}
Future<void> toggleIncludeTime() async {
@@ -461,17 +473,6 @@ extension AppFlowyDatabaseTest on WidgetTester {
await tapButton(find.byType(EmojiSelectionMenu));
}
/// Must call [openEmojiPicker] first
Future<void> switchToEmojiList() async {
final icon = find.byIcon(Icons.tag_faces);
await tapButton(icon);
}
Future<void> tapEmoji(String emoji) async {
final emojiWidget = find.text(emoji);
await tapButton(emojiWidget);
}
Future<void> tapDateCellInRowDetailPage() async {
final findDateCell = find.byType(GridDateCell);
await tapButton(findDateCell);
@@ -978,9 +979,20 @@ extension AppFlowyDatabaseTest on WidgetTester {
Future<void> scrollToToday() async {
final todayCell = find.byWidgetPredicate(
(widget) => widget is CalendarDayCard && widget.isToday,
skipOffstage: false,
);
await ensureVisible(todayCell);
final scrollable = find
.descendant(
of: find.byType(MonthView<CalendarDayEvent>),
matching: find.byWidgetPredicate(
(widget) => widget is Scrollable && widget.axis == Axis.vertical,
),
)
.first;
await scrollUntilVisible(
todayCell,
300,
scrollable: scrollable,
);
await pumpAndSettle(const Duration(milliseconds: 300));
}
@@ -1,4 +1,15 @@
import 'dart:ui';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/custom_cover_picker.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_popover.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'ime.dart';
@@ -21,19 +32,109 @@ class EditorOperations {
/// Tap the line of editor at [index]
Future<void> tapLineOfEditorAt(int index) async {
final textBlocks = find.byType(TextBlockComponentWidget);
final textBlocks = find.byType(FlowyRichText);
index = index.clamp(0, textBlocks.evaluate().length - 1);
await tester.tapAt(tester.getTopRight(textBlocks.at(index)));
await tester.pumpAndSettle();
}
/// Hover on cover plugin button above the document
Future<void> hoverOnCoverPluginAddButton() async {
final editor = find.byWidgetPredicate(
(widget) => widget is AppFlowyEditor,
Future<void> hoverOnCoverToolbar() async {
final coverToolbar = find.byType(DocumentHeaderToolbar);
await tester.startGesture(
tester.getBottomLeft(coverToolbar).translate(5, -5),
kind: PointerDeviceKind.mouse,
);
await tester.hoverOnWidget(
editor,
offset: tester.getTopLeft(editor).translate(20, 20),
await tester.pumpAndSettle();
}
/// Taps on the 'Add Icon' button in the cover toolbar
Future<void> tapAddIconButton() async {
await tester.tapButtonWithName(
LocaleKeys.document_plugins_cover_addIcon.tr(),
);
expect(find.byType(EmojiPopover), findsOneWidget);
}
/// Taps the 'Remove Icon' button in the cover toolbar and the icon popover
Future<void> tapRemoveIconButton({bool isInPicker = false}) async {
Finder button =
find.text(LocaleKeys.document_plugins_cover_removeIcon.tr());
if (isInPicker) {
button = find.descendant(of: find.byType(EmojiPopover), matching: button);
}
await tester.tapButton(button);
}
/// Requires that the document must already have an icon. This opens the icon
/// picker
Future<void> tapOnIconWidget() async {
final iconWidget = find.byType(EmojiIconWidget);
await tester.tapButton(iconWidget);
}
Future<void> tapOnAddCover() async {
await tester.tapButtonWithName(
LocaleKeys.document_plugins_cover_addCover.tr(),
);
}
Future<void> tapOnChangeCover() async {
await tester.tapButtonWithName(
LocaleKeys.document_plugins_cover_changeCover.tr(),
);
}
Future<void> switchSolidColorBackground() async {
final findPurpleButton = find.byWidgetPredicate(
(widget) => widget is ColorItem && widget.option.colorHex == "ffe8e0ff",
);
await tester.tapButton(findPurpleButton);
}
Future<void> addNetworkImageCover(String imageUrl) async {
final findNewImageButton = find.byType(NewCustomCoverButton);
await tester.tapButton(findNewImageButton);
final imageUrlTextField = find.descendant(
of: find.byType(NetworkImageUrlInput),
matching: find.byType(TextField),
);
await tester.enterText(imageUrlTextField, imageUrl);
await tester.tapButtonWithName(
LocaleKeys.document_plugins_cover_add.tr(),
);
await tester.tapButtonWithName(
LocaleKeys.document_plugins_cover_saveToGallery.tr(),
);
}
Future<void> switchNetworkImageCover(String imageUrl) async {
final image = find.byWidgetPredicate(
(widget) => widget is ImageGridItem,
);
await tester.tapButton(image);
}
Future<void> tapOnRemoveCover() async {
await tester.tapButton(find.byType(DeleteCoverButton));
}
/// A cover must be present in the document to function properly since this
/// catches all cover types collectively
Future<void> hoverOnCover() async {
final cover = find.byType(DocumentCover);
await tester.startGesture(
tester.getCenter(cover),
kind: PointerDeviceKind.mouse,
);
await tester.pumpAndSettle();
}
Future<void> dismissCoverPicker() async {
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
}
/// trigger the slash command (selection menu)
@@ -41,6 +142,11 @@ class EditorOperations {
await tester.ime.insertCharacter('/');
}
/// trigger the slash command (selection menu)
Future<void> showAtMenu() async {
await tester.ime.insertCharacter('@');
}
/// Tap the slash menu item with [name]
///
/// Must call [showSlashMenu] first.
@@ -48,4 +154,15 @@ class EditorOperations {
final slashMenuItem = find.text(name, findRichText: true);
await tester.tapButton(slashMenuItem);
}
/// Tap the at menu item with [name]
///
/// Must call [showAtMenu] first.
Future<void> tapAtMenuItemWithName(String name) async {
final atMenuItem = find.descendant(
of: find.byType(SelectionMenuWidget),
matching: find.text(name, findRichText: true),
);
await tester.tapButton(atMenuItem);
}
}
@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'base.dart';
extension EmojiTestExtension on WidgetTester {
/// Must call [openEmojiPicker] first
Future<void> switchToEmojiList() async {
final icon = find.byIcon(Icons.tag_faces);
await tapButton(icon);
}
Future<void> tapEmoji(String emoji) async {
final emojiWidget = find.text(emoji);
await tapButton(emojiWidget);
}
}
@@ -1,5 +1,7 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/banner.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/home/menu/app/section/item.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -47,7 +49,7 @@ extension Expectation on WidgetTester {
expect(exportSuccess, findsOneWidget);
}
/// Expect to see the add button and icon button inside the document.
/// Expect to see the add button and icon button in the cover toolbar
void expectToSeePluginAddCoverAndIconButton() {
final addCover = find.textContaining(
LocaleKeys.document_plugins_cover_addCover.tr(),
@@ -59,6 +61,51 @@ extension Expectation on WidgetTester {
expect(addIcon, findsOneWidget);
}
/// Expect to see the document header toolbar empty
void expectToSeeEmptyDocumentHeaderToolbar() {
final addCover = find.textContaining(
LocaleKeys.document_plugins_cover_addCover.tr(),
);
final addIcon = find.textContaining(
LocaleKeys.document_plugins_cover_addIcon.tr(),
);
expect(addCover, findsNothing);
expect(addIcon, findsNothing);
}
void expectToSeeDocumentIcon(String? emoji) {
if (emoji == null) {
final iconWidget = find.byType(EmojiIconWidget);
expect(iconWidget, findsNothing);
return;
}
final iconWidget = find.byWidgetPredicate(
(widget) => widget is EmojiIconWidget && widget.emoji == emoji,
);
expect(iconWidget, findsOneWidget);
}
void expectToSeeDocumentCover(CoverType type) {
final findCover = find.byWidgetPredicate(
(widget) => widget is DocumentCover && widget.coverType == type,
);
expect(findCover, findsOneWidget);
}
void expectToSeeNoDocumentCover() {
final findCover = find.byType(DocumentCover);
expect(findCover, findsNothing);
}
void expectChangeCoverAndDeleteButton() {
final findChangeCover = find.text(
LocaleKeys.document_plugins_cover_changeCover.tr(),
);
final findRemoveIcon = find.byType(DeleteCoverButton);
expect(findChangeCover, findsOneWidget);
expect(findRemoveIcon, findsOneWidget);
}
/// Expect to see the user name on the home page
void expectToSeeUserName(String name) {
final userName = find.byWidgetPredicate(
@@ -9,11 +9,11 @@ extension IME on WidgetTester {
class IMESimulator {
IMESimulator(this.tester) {
client = findDeltaTextInputClient();
client = findTextInputClient();
}
final WidgetTester tester;
late final DeltaTextInputClient client;
late final TextInputClient client;
Future<void> insertText(String text) async {
for (final c in text.characters) {
@@ -27,28 +27,22 @@ class IMESimulator {
assert(false);
return;
}
final deltas = [
TextEditingDeltaInsertion(
textInserted: character,
oldText: value.text.replaceRange(
value.selection.start,
value.selection.end,
'',
),
insertionOffset: value.selection.baseOffset,
selection: TextSelection.collapsed(
offset: value.selection.baseOffset + 1,
),
composing: TextRange.empty,
final text = value.text
.replaceRange(value.selection.start, value.selection.end, character);
final textEditingValue = TextEditingValue(
text: text,
selection: TextSelection.collapsed(
offset: value.selection.baseOffset + 1,
),
];
client.updateEditingValueWithDeltas(deltas);
composing: TextRange.empty,
);
client.updateEditingValue(textEditingValue);
await tester.pumpAndSettle();
}
DeltaTextInputClient findDeltaTextInputClient() {
TextInputClient findTextInputClient() {
final finder = find.byType(KeyboardServiceWidget);
final KeyboardServiceWidgetState state = tester.state(finder);
return state.textInputService as DeltaTextInputClient;
return state.textInputService as TextInputClient;
}
}
@@ -9,7 +9,7 @@ import 'package:appflowy/plugins/document/application/doc_service.dart';
import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pbserver.dart';
import 'package:appflowy_editor/appflowy_editor.dart'
show EditorState, LogLevel, TransactionTime;
show EditorState, LogLevel, TransactionTime, Selection, paragraphNode;
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:flutter/foundation.dart';
@@ -155,6 +155,9 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
return;
}
await _transactionAdapter.apply(event.$2, editorState);
// check if the document is empty.
applyRules();
});
// output the log from the editor when debug mode
@@ -166,6 +169,39 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
};
}
}
Future<void> applyRules() async {
ensureAtLeastOneParagraphExists();
ensureLastNodeIsEditable();
}
Future<void> ensureLastNodeIsEditable() async {
final editorState = this.editorState;
if (editorState == null) {
return;
}
final document = editorState.document;
final lastNode = document.root.children.lastOrNull;
if (lastNode == null || lastNode.delta == null) {
final transaction = editorState.transaction;
transaction.insertNode([document.root.children.length], paragraphNode());
await editorState.apply(transaction);
}
}
Future<void> ensureAtLeastOneParagraphExists() async {
final editorState = this.editorState;
if (editorState == null) {
return;
}
final document = editorState.document;
if (document.root.children.isEmpty) {
final transaction = editorState.transaction;
transaction.insertNode([0], paragraphNode());
transaction.afterSelection = Selection.collapse([0], 0);
await editorState.apply(transaction);
}
}
}
@freezed
@@ -123,7 +123,7 @@ class _DocumentPageState extends State<DocumentPage> {
return const Placeholder();
}
final page = editorState!.document.root;
return CoverImageNodeWidget(
return DocumentHeaderNodeWidget(
node: page,
editorState: editorState!,
);
@@ -1,10 +1,9 @@
import 'package:appflowy/plugins/document/application/doc_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option_action.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_list.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/database/referenced_database_menu_tem.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -35,6 +34,8 @@ class AppFlowyEditorPage extends StatefulWidget {
class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
late final ScrollController effectiveScrollController;
final inlinePageReferenceService = InlinePageReferenceService();
final List<CommandShortcutEvent> commandShortcutEvents = [
...codeBlockCommands,
...standardCommandShortcutEvents,
@@ -53,23 +54,15 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
highlightColorItem,
];
late final slashMenuItems = [
inlineGridMenuItem(documentBloc),
referencedGridMenuItem,
inlineBoardMenuItem(documentBloc),
referencedBoardMenuItem,
inlineCalendarMenuItem(documentBloc),
referencedCalendarMenuItem,
calloutItem,
mathEquationItem,
codeBlockItem,
emojiMenuItem,
autoGeneratorMenuItem,
];
late final List<SelectionMenuItem> slashMenuItems;
late final Map<String, BlockComponentBuilder> blockComponentBuilders =
_customAppFlowyBlockComponentBuilders();
List<CharacterShortcutEvent> get characterShortcutEvents => [
// inline page reference list
...inlinePageReferenceShortcuts,
// code block
...codeBlockCharacterEvents,
@@ -88,6 +81,18 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
), // remove the default slash command.
];
late final inlinePageReferenceShortcuts = [
inlinePageReferenceService.customPageLinkMenu(
character: '@',
style: styleCustomizer.selectionMenuStyleBuilder(),
),
// uncomment this to enable the inline page reference list
// inlinePageReferenceService.customPageLinkMenu(
// character: '+',
// style: styleCustomizer.selectionMenuStyleBuilder(),
// ),
];
late final showSlashMenu = customSlashCommand(
slashMenuItems,
shouldInsertSlash: false,
@@ -100,6 +105,9 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
@override
void initState() {
super.initState();
slashMenuItems = _customSlashMenuItems();
effectiveScrollController = widget.scrollController ?? ScrollController();
}
@@ -200,6 +208,15 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
),
ImageBlockKeys.type: ImageBlockComponentBuilder(
configuration: configuration,
showMenu: true,
menuBuilder: (node, state) => Positioned(
top: 0,
right: 10,
child: ImageMenu(
node: node,
state: state,
),
),
),
DatabaseBlockKeys.gridType: DatabaseViewBlockComponentBuilder(
configuration: configuration,
@@ -235,7 +252,15 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
),
AutoCompletionBlockKeys.type: AutoCompletionBlockComponentBuilder(),
SmartEditBlockKeys.type: SmartEditBlockComponentBuilder(),
ToggleListBlockKeys.type: ToggleListBlockComponentBuilder(),
ToggleListBlockKeys.type: ToggleListBlockComponentBuilder(
configuration: configuration,
),
OutlineBlockKeys.type: OutlineBlockComponentBuilder(
configuration: configuration.copyWith(
placeholderTextStyle: (_) =>
styleCustomizer.outlineBlockPlaceholderStyleBuilder(),
),
),
};
final builders = {
@@ -258,7 +283,8 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
NumberedListBlockKeys.type,
QuoteBlockKeys.type,
TodoListBlockKeys.type,
CalloutBlockKeys.type
CalloutBlockKeys.type,
OutlineBlockKeys.type,
];
final supportAlignBuilderType = [
@@ -304,6 +330,34 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
return builders;
}
List<SelectionMenuItem> _customSlashMenuItems() {
final items = [...standardSelectionMenuItems];
final imageItem = items.firstWhereOrNull(
(element) => element.name == AppFlowyEditorLocalizations.current.image,
);
if (imageItem != null) {
final imageItemIndex = items.indexOf(imageItem);
if (imageItemIndex != -1) {
items[imageItemIndex] = customImageMenuItem;
}
}
return [
...items,
inlineGridMenuItem(documentBloc),
referencedGridMenuItem,
inlineBoardMenuItem(documentBloc),
referencedBoardMenuItem,
inlineCalendarMenuItem(documentBloc),
referencedCalendarMenuItem,
calloutItem,
outlineItem,
mathEquationItem,
codeBlockItem,
emojiMenuItem,
autoGeneratorMenuItem,
];
}
(bool, Selection?) _computeAutoFocusParameters() {
if (widget.editorState.document.isEmpty) {
return (true, Selection.collapse([0], 0));
@@ -87,7 +87,8 @@ class _LinkToPageMenuState extends State<LinkToPageMenu> {
final Map<int, (ViewPB, ViewPB)> _items = {};
Future<List<(ViewPB, List<ViewPB>)>> fetchItems() async {
final items = await ViewBackendService().fetchViews(widget.layoutType);
final items =
await ViewBackendService().fetchViewsWithLayoutType(widget.layoutType);
int index = 0;
for (final (app, children) in items) {
@@ -1,555 +0,0 @@
import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/change_cover_popover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/emoji_popover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/emoji_icon_widget.dart';
import 'package:appflowy/workspace/presentation/widgets/emoji_picker/emoji_picker.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide FlowySvg;
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/rounded_button.dart';
import 'package:flutter/material.dart';
class CoverBlockKeys {
const CoverBlockKeys._();
static const String selectionType = 'cover_selection_type';
static const String selection = 'cover_selection';
static const String iconSelection = 'selected_icon';
}
enum CoverSelectionType {
initial,
color,
file,
asset;
static CoverSelectionType fromString(String? value) {
if (value == null) {
return CoverSelectionType.initial;
}
return CoverSelectionType.values.firstWhere(
(e) => e.toString() == value,
orElse: () => CoverSelectionType.initial,
);
}
}
class CoverNodeWidgetBuilder implements NodeWidgetBuilder {
@override
Widget build(NodeWidgetContext<Node> context) {
return CoverImageNodeWidget(
key: context.node.key,
node: context.node,
editorState: context.editorState,
);
}
@override
NodeValidator<Node> get nodeValidator => (node) {
return true;
};
}
class CoverImageNodeWidget extends StatefulWidget {
const CoverImageNodeWidget({
Key? key,
required this.node,
required this.editorState,
}) : super(key: key);
final Node node;
final EditorState editorState;
@override
State<CoverImageNodeWidget> createState() => _CoverImageNodeWidgetState();
}
class _CoverImageNodeWidgetState extends State<CoverImageNodeWidget> {
CoverSelectionType get selectionType => CoverSelectionType.fromString(
widget.node.attributes[CoverBlockKeys.selectionType],
);
@override
void initState() {
super.initState();
widget.node.addListener(_reload);
}
@override
void dispose() {
widget.node.removeListener(_reload);
super.dispose();
}
void _reload() {
setState(() {});
}
PopoverController iconPopoverController = PopoverController();
@override
Widget build(BuildContext context) {
return _CoverImage(
editorState: widget.editorState,
node: widget.node,
onCoverChanged: (type, value) {
_insertCover(type, value);
},
);
}
Future<void> _insertCover(CoverSelectionType type, dynamic cover) async {
final transaction = widget.editorState.transaction;
transaction.updateNode(widget.node, {
CoverBlockKeys.selectionType: type.toString(),
CoverBlockKeys.selection: cover,
CoverBlockKeys.iconSelection:
widget.node.attributes[CoverBlockKeys.iconSelection]
});
return widget.editorState.apply(transaction);
}
}
class _AddCoverButton extends StatefulWidget {
final Node node;
final EditorState editorState;
final bool hasIcon;
final CoverSelectionType selectionType;
final PopoverController iconPopoverController;
const _AddCoverButton({
required this.onTap,
required this.node,
required this.editorState,
required this.hasIcon,
required this.selectionType,
required this.iconPopoverController,
});
final VoidCallback onTap;
@override
State<_AddCoverButton> createState() => _AddCoverButtonState();
}
bool isPopoverOpen = false;
class _AddCoverButtonState extends State<_AddCoverButton> {
bool isHidden = true;
PopoverMutex mutex = PopoverMutex();
bool isPopoverOpen = false;
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (event) {
setHidden(false);
},
onExit: (event) {
setHidden(isPopoverOpen ? false : true);
},
opaque: false,
child: Container(
height: widget.hasIcon ? 180 : 50.0,
alignment: Alignment.bottomLeft,
width: double.infinity,
padding: const EdgeInsets.only(
left: 80,
top: 20,
bottom: 5,
),
child: isHidden
? Container()
: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
children: [
// Add Cover Button.
widget.selectionType != CoverSelectionType.initial
? Container()
: FlowyButton(
key: UniqueKey(),
leftIconSize: const Size.square(18),
onTap: widget.onTap,
useIntrinsicWidth: true,
leftIcon: const FlowySvg(name: 'editor/image'),
text: FlowyText.regular(
LocaleKeys.document_plugins_cover_addCover.tr(),
),
),
// Add Icon Button.
widget.hasIcon
? FlowyButton(
leftIconSize: const Size.square(18),
onTap: () {
_removeIcon();
},
useIntrinsicWidth: true,
leftIcon: const Icon(
Icons.emoji_emotions_outlined,
size: 18,
),
text: FlowyText.regular(
LocaleKeys.document_plugins_cover_removeIcon.tr(),
),
)
: AppFlowyPopover(
mutex: mutex,
asBarrier: true,
onClose: () {
isPopoverOpen = false;
setHidden(true);
},
offset: const Offset(120, 10),
controller: widget.iconPopoverController,
direction: PopoverDirection.bottomWithCenterAligned,
constraints:
BoxConstraints.loose(const Size(320, 380)),
margin: EdgeInsets.zero,
child: FlowyButton(
leftIconSize: const Size.square(18),
useIntrinsicWidth: true,
leftIcon: const Icon(
Icons.emoji_emotions_outlined,
size: 18,
),
text: FlowyText.regular(
LocaleKeys.document_plugins_cover_addIcon.tr(),
),
),
popupBuilder: (BuildContext popoverContext) {
isPopoverOpen = true;
return EmojiPopover(
showRemoveButton: widget.hasIcon,
removeIcon: _removeIcon,
node: widget.node,
editorState: widget.editorState,
onEmojiChanged: (Emoji emoji) {
_insertIcon(emoji);
widget.iconPopoverController.close();
},
);
},
)
],
),
),
);
}
Future<void> _insertIcon(Emoji emoji) async {
final transaction = widget.editorState.transaction;
transaction.updateNode(widget.node, {
CoverBlockKeys.selectionType:
widget.node.attributes[CoverBlockKeys.selectionType],
CoverBlockKeys.selection:
widget.node.attributes[CoverBlockKeys.selection],
CoverBlockKeys.iconSelection: emoji.emoji,
});
return widget.editorState.apply(transaction);
}
Future<void> _removeIcon() async {
final transaction = widget.editorState.transaction;
transaction.updateNode(widget.node, {
CoverBlockKeys.iconSelection: "",
CoverBlockKeys.selectionType:
widget.node.attributes[CoverBlockKeys.selectionType],
CoverBlockKeys.selection:
widget.node.attributes[CoverBlockKeys.selection],
});
return widget.editorState.apply(transaction);
}
void setHidden(bool value) {
if (isHidden == value) return;
setState(() {
isHidden = value;
});
}
}
class _CoverImage extends StatefulWidget {
const _CoverImage({
required this.editorState,
required this.node,
required this.onCoverChanged,
});
final Node node;
final EditorState editorState;
final Function(
CoverSelectionType selectionType,
dynamic selection,
) onCoverChanged;
@override
State<_CoverImage> createState() => _CoverImageState();
}
class _CoverImageState extends State<_CoverImage> {
final popoverController = PopoverController();
CoverSelectionType get selectionType => CoverSelectionType.fromString(
widget.node.attributes[CoverBlockKeys.selectionType],
);
Color get color {
final hex = widget.node.attributes[CoverBlockKeys.selection] as String?;
return hex?.toColor() ?? Colors.white;
}
bool get hasIcon =>
widget.node.attributes[CoverBlockKeys.iconSelection] == null
? false
: widget.node.attributes[CoverBlockKeys.iconSelection].isNotEmpty;
bool isOverlayButtonsHidden = true;
PopoverController iconPopoverController = PopoverController();
bool get hasCover =>
selectionType == CoverSelectionType.initial ? false : true;
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.bottomLeft,
children: [
Container(
alignment: Alignment.topCenter,
height: !hasCover
? 0
: hasIcon
? 320
: 280,
child: _buildCoverImage(context, widget.editorState),
),
hasIcon
? Positioned(
left: 80,
bottom: !hasCover ? 30 : 40,
child: AppFlowyPopover(
offset: const Offset(100, 0),
controller: iconPopoverController,
direction: PopoverDirection.bottomWithCenterAligned,
constraints: BoxConstraints.loose(const Size(320, 380)),
margin: EdgeInsets.zero,
child: EmojiIconWidget(
emoji: widget.node.attributes[CoverBlockKeys.iconSelection],
),
popupBuilder: (BuildContext popoverContext) {
return EmojiPopover(
node: widget.node,
showRemoveButton: hasIcon,
removeIcon: _removeIcon,
editorState: widget.editorState,
onEmojiChanged: (Emoji emoji) {
_insertIcon(emoji);
iconPopoverController.close();
},
);
},
),
)
: Container(),
hasIcon && selectionType != CoverSelectionType.initial
? Container()
: _AddCoverButton(
onTap: () {
_insertCover(
CoverSelectionType.asset,
builtInAssetImages.first,
);
},
node: widget.node,
editorState: widget.editorState,
hasIcon: hasIcon,
selectionType: selectionType,
iconPopoverController: iconPopoverController,
),
],
);
}
Future<void> _insertCover(CoverSelectionType type, dynamic cover) async {
final transaction = widget.editorState.transaction;
transaction.updateNode(widget.node, {
CoverBlockKeys.selectionType: type.toString(),
CoverBlockKeys.selection: cover,
CoverBlockKeys.iconSelection:
widget.node.attributes[CoverBlockKeys.iconSelection]
});
return widget.editorState.apply(transaction);
}
Future<void> _insertIcon(Emoji emoji) async {
final transaction = widget.editorState.transaction;
transaction.updateNode(widget.node, {
CoverBlockKeys.selectionType:
widget.node.attributes[CoverBlockKeys.selectionType],
CoverBlockKeys.selection:
widget.node.attributes[CoverBlockKeys.selection],
CoverBlockKeys.iconSelection: emoji.emoji,
});
return widget.editorState.apply(transaction);
}
Future<void> _removeIcon() async {
final transaction = widget.editorState.transaction;
transaction.updateNode(widget.node, {
CoverBlockKeys.iconSelection: "",
CoverBlockKeys.selectionType:
widget.node.attributes[CoverBlockKeys.selectionType],
CoverBlockKeys.selection:
widget.node.attributes[CoverBlockKeys.selection],
});
return widget.editorState.apply(transaction);
}
Widget _buildCoverOverlayButtons(BuildContext context) {
return Positioned(
bottom: 20,
right: 50,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AppFlowyPopover(
onClose: () {
setOverlayButtonsHidden(true);
},
offset: const Offset(-125, 10),
controller: popoverController,
direction: PopoverDirection.bottomWithCenterAligned,
constraints: BoxConstraints.loose(const Size(380, 450)),
margin: EdgeInsets.zero,
child: Visibility(
maintainState: true,
maintainAnimation: true,
maintainSize: true,
visible: !isOverlayButtonsHidden,
child: RoundedTextButton(
onPressed: () {
popoverController.show();
setOverlayButtonsHidden(true);
},
hoverColor: Theme.of(context).colorScheme.surface,
textColor: Theme.of(context).colorScheme.tertiary,
fillColor:
Theme.of(context).colorScheme.surface.withOpacity(0.5),
width: 120,
height: 28,
title: LocaleKeys.document_plugins_cover_changeCover.tr(),
),
),
popupBuilder: (BuildContext popoverContext) {
return ChangeCoverPopover(
node: widget.node,
editorState: widget.editorState,
onCoverChanged: widget.onCoverChanged,
);
},
),
const SizedBox(width: 10),
Visibility(
maintainAnimation: true,
maintainSize: true,
maintainState: true,
visible: !isOverlayButtonsHidden,
child: FlowyIconButton(
hoverColor: Theme.of(context).colorScheme.surface,
fillColor: Theme.of(context).colorScheme.surface.withOpacity(0.5),
iconPadding: const EdgeInsets.all(5),
width: 28,
icon: svgWidget(
'editor/delete',
color: Theme.of(context).colorScheme.tertiary,
),
onPressed: () {
widget.onCoverChanged(CoverSelectionType.initial, null);
},
),
),
],
),
);
}
Widget _buildCoverImage(BuildContext context, EditorState editorState) {
const height = 250.0;
final Widget coverImage;
switch (selectionType) {
case CoverSelectionType.file:
final imageFile =
File(widget.node.attributes[CoverBlockKeys.selection]);
if (!imageFile.existsSync()) {
// reset cover state
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onCoverChanged(CoverSelectionType.initial, null);
});
coverImage = const SizedBox();
break;
}
coverImage = Image.file(
imageFile,
fit: BoxFit.cover,
);
break;
case CoverSelectionType.asset:
coverImage = Image.asset(
widget.node.attributes[CoverBlockKeys.selection],
fit: BoxFit.cover,
);
break;
case CoverSelectionType.color:
coverImage = Container(
decoration: BoxDecoration(
color: color,
borderRadius: Corners.s6Border,
),
alignment: Alignment.center,
);
break;
case CoverSelectionType.initial:
coverImage = const SizedBox();
break;
}
// OverflowBox needs to be wraped by a widget with constraints(or from its parent) first,otherwise it will occur an error
return MouseRegion(
onEnter: (event) {
setOverlayButtonsHidden(false);
},
onExit: (event) {
setOverlayButtonsHidden(true);
},
child: SizedBox(
height: height,
child: Stack(
children: [
Container(
padding: const EdgeInsets.only(bottom: 10),
height: double.infinity,
width: double.infinity,
child: coverImage,
),
hasCover
? _buildCoverOverlayButtons(context)
: const SizedBox.shrink()
],
),
),
);
}
void setOverlayButtonsHidden(bool value) {
if (isOverlayButtonsHidden == value) return;
setState(() {
isOverlayButtonsHidden = value;
});
}
}
@@ -11,6 +11,7 @@ import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -25,7 +26,7 @@ class ChangeCoverPopover extends StatefulWidget {
final EditorState editorState;
final Node node;
final Function(
CoverSelectionType selectionType,
CoverType selectionType,
String selection,
) onCoverChanged;
@@ -83,7 +84,7 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
child: BlocBuilder<ChangeCoverPopoverBloc, ChangeCoverPopoverState>(
builder: (context, state) {
return Padding(
padding: const EdgeInsets.all(15),
padding: const EdgeInsets.all(12),
child: SingleChildScrollView(
child: isAddingImage
? CoverImagePicker(
@@ -116,18 +117,18 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
LocaleKeys.document_plugins_cover_colors.tr(),
color: Theme.of(context).colorScheme.tertiary,
),
const SizedBox(height: 10),
const VSpace(10),
_buildColorPickerList(),
const SizedBox(height: 10),
const VSpace(10),
_buildImageHeader(),
const SizedBox(height: 10),
const VSpace(10),
_buildFileImagePicker(),
const SizedBox(height: 10),
const VSpace(10),
FlowyText.semibold(
LocaleKeys.document_plugins_cover_abstract.tr(),
color: Theme.of(context).colorScheme.tertiary,
),
const SizedBox(height: 10),
const VSpace(10),
_buildAbstractImagePicker(),
],
);
@@ -149,10 +150,10 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
LocaleKeys.document_plugins_cover_clearAll.tr(),
fontColor: Theme.of(context).colorScheme.tertiary,
onPressed: () async {
final hasFileImageCover = CoverSelectionType.fromString(
widget.node.attributes[CoverBlockKeys.selectionType],
final hasFileImageCover = CoverType.fromString(
widget.node.attributes[DocumentHeaderBlockKeys.coverType],
) ==
CoverSelectionType.file;
CoverType.file;
final changeCoverBloc = context.read<ChangeCoverPopoverBloc>();
if (hasFileImageCover) {
await showDialog(
@@ -196,7 +197,7 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
return InkWell(
onTap: () {
widget.onCoverChanged(
CoverSelectionType.asset,
CoverType.asset,
builtInAssetImages[index],
);
},
@@ -220,14 +221,14 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
pickerBackgroundColor: theme.cardColor,
pickerItemHoverColor: theme.hoverColor,
selectedBackgroundColorHex:
widget.node.attributes[CoverBlockKeys.selectionType] ==
CoverSelectionType.color.toString()
? widget.node.attributes[CoverBlockKeys.selection]
widget.node.attributes[DocumentHeaderBlockKeys.coverType] ==
CoverType.color.toString()
? widget.node.attributes[DocumentHeaderBlockKeys.coverDetails]
: 'ffffff',
backgroundColorOptions:
_generateBackgroundColorOptions(widget.editorState),
onSubmittedBackgroundColorHex: (color) {
widget.onCoverChanged(CoverSelectionType.color, color);
widget.onCoverChanged(CoverType.color, color);
setState(() {});
},
);
@@ -249,43 +250,25 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
itemCount: images.length + 1,
itemBuilder: (BuildContext ctx, index) {
if (index == 0) {
return Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.primary,
),
borderRadius: Corners.s8Border,
),
child: FlowyIconButton(
iconPadding: EdgeInsets.zero,
icon: Icon(
Icons.add,
color: Theme.of(context).colorScheme.primary,
),
hoverColor:
Theme.of(context).colorScheme.primary.withOpacity(0.15),
width: 20,
onPressed: () {
setState(() {
isAddingImage = true;
});
},
return NewCustomCoverButton(
onPressed: () => setState(
() => isAddingImage = true,
),
);
}
return ImageGridItem(
onImageSelect: () {
widget.onCoverChanged(
CoverSelectionType.file,
CoverType.file,
images[index - 1],
);
},
onImageDelete: () async {
final changeCoverBloc =
context.read<ChangeCoverPopoverBloc>();
final deletingCurrentCover =
widget.node.attributes[CoverBlockKeys.selection] ==
images[index - 1];
final deletingCurrentCover = widget.node
.attributes[DocumentHeaderBlockKeys.coverDetails] ==
images[index - 1];
if (deletingCurrentCover) {
await showDialog(
context: context,
@@ -328,6 +311,32 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
}
}
@visibleForTesting
class NewCustomCoverButton extends StatelessWidget {
final VoidCallback onPressed;
const NewCustomCoverButton({super.key, required this.onPressed});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.primary,
),
borderRadius: Corners.s8Border,
),
child: FlowyIconButton(
icon: Icon(
Icons.add,
color: Theme.of(context).colorScheme.primary,
),
hoverColor: Theme.of(context).colorScheme.primary.withOpacity(0.15),
onPressed: onPressed,
),
);
}
}
class DeleteImageAlertDialog extends StatelessWidget {
const DeleteImageAlertDialog({
Key? key,
@@ -481,36 +490,63 @@ class _CoverColorPickerState extends State<CoverColorPicker> {
scrollController.dispose();
}
Widget _buildColorItem(ColorOption option, bool isChecked) {
Widget _buildColorItems(List<ColorOption> options, String? selectedColor) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: options
.map(
(e) => ColorItem(
option: e,
isChecked: e.colorHex == selectedColor,
hoverColor: widget.pickerItemHoverColor,
onTap: widget.onSubmittedBackgroundColorHex,
),
)
.toList(),
);
}
}
@visibleForTesting
class ColorItem extends StatelessWidget {
final ColorOption option;
final bool isChecked;
final Color hoverColor;
final void Function(String) onTap;
const ColorItem({
required this.option,
required this.isChecked,
required this.hoverColor,
required this.onTap,
super.key,
});
@override
Widget build(BuildContext context) {
return InkWell(
customBorder: const RoundedRectangleBorder(
borderRadius: Corners.s6Border,
),
hoverColor: widget.pickerItemHoverColor,
onTap: () {
widget.onSubmittedBackgroundColorHex(option.colorHex);
},
hoverColor: hoverColor,
onTap: () => onTap(option.colorHex),
child: Padding(
padding: const EdgeInsets.only(right: 10.0),
child: SizedBox.square(
dimension: isChecked ? 24 : 25,
dimension: 25,
child: Container(
decoration: BoxDecoration(
color: option.colorHex.toColor(),
border: isChecked
? Border.all(
color: const Color(0xFFFFFFFF),
width: 2.0,
)
: null,
shape: BoxShape.circle,
),
child: isChecked
? SizedBox.square(
dimension: 24,
child: Container(
margin: const EdgeInsets.all(4),
margin: const EdgeInsets.all(1),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).cardColor,
width: 3.0,
),
color: option.colorHex.toColor(),
shape: BoxShape.circle,
),
@@ -522,14 +558,4 @@ class _CoverColorPickerState extends State<CoverColorPicker> {
),
);
}
Widget _buildColorItems(List<ColorOption> options, String? selectedColor) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: options
.map((e) => _buildColorItem(e, e.colorHex == selectedColor))
.toList(),
);
}
}
@@ -1,14 +1,14 @@
import 'dart:async';
import 'dart:io';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/change_cover_popover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/cover_node_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
part 'change_cover_popover_bloc.freezed.dart';
part 'cover_editor_bloc.freezed.dart';
class ChangeCoverPopoverBloc
extends Bloc<ChangeCoverPopoverEvent, ChangeCoverPopoverState> {
@@ -32,7 +32,7 @@ class ChangeCoverPopoverBloc
deleteImage: (DeleteImage deleteImage) async {
final currentState = state;
final currentlySelectedImage =
node.attributes[CoverBlockKeys.selection];
node.attributes[DocumentHeaderBlockKeys.coverDetails];
if (currentState is Loaded) {
await _deleteImageInStorage(deleteImage.path);
if (currentlySelectedImage == deleteImage.path) {
@@ -48,7 +48,7 @@ class ChangeCoverPopoverBloc
clearAllImages: (ClearAllImages clearAllImages) async {
final currentState = state;
final currentlySelectedImage =
node.attributes[CoverBlockKeys.selection];
node.attributes[DocumentHeaderBlockKeys.coverDetails];
if (currentState is Loaded) {
for (final image in currentState.imageNames) {
@@ -90,9 +90,9 @@ class ChangeCoverPopoverBloc
Future<void> _removeCoverImageFromNode() async {
final transaction = editorState.transaction;
transaction.updateNode(node, {
CoverBlockKeys.selectionType: CoverSelectionType.initial.toString(),
CoverBlockKeys.iconSelection:
node.attributes[CoverBlockKeys.iconSelection]
DocumentHeaderBlockKeys.coverType: CoverType.none.toString(),
DocumentHeaderBlockKeys.icon:
node.attributes[DocumentHeaderBlockKeys.icon]
});
return editorState.apply(transaction);
}
@@ -1,8 +1,9 @@
import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/cover_image_picker_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/custom_cover_picker_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/snap_bar.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flowy_infra/image.dart';
@@ -67,17 +68,13 @@ class _CoverImagePickerState extends State<CoverImagePicker> {
),
)
: CoverImagePreviewWidget(state: state),
const SizedBox(
height: 10,
),
const VSpace(10),
NetworkImageUrlInput(
onAdd: (url) {
context.read<CoverImagePickerBloc>().add(UrlSubmit(url));
},
),
const SizedBox(
height: 10,
),
const VSpace(10),
ImagePickerActionButtons(
onBackPressed: () {
widget.onBackPressed();
@@ -233,16 +230,12 @@ class _CoverImagePreviewWidgetState extends State<CoverImagePreviewWidget> {
),
],
),
const SizedBox(
height: 10,
),
const VSpace(10),
FlowyText(
LocaleKeys.document_plugins_cover_or.tr(),
fontWeight: FontWeight.w300,
),
const SizedBox(
height: 10,
),
const VSpace(10),
FlowyButton(
hoverColor: Theme.of(context).hoverColor,
onTap: () {
@@ -13,9 +13,10 @@ import 'package:dartz/dartz.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:path/path.dart' as p;
import 'change_cover_popover.dart';
part 'cover_image_picker_bloc.freezed.dart';
import 'cover_editor.dart';
part 'custom_cover_picker_bloc.freezed.dart';
class CoverImagePickerBloc
extends Bloc<CoverImagePickerEvent, CoverImagePickerState> {
@@ -0,0 +1,507 @@
import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/widgets/emoji_picker/emoji_picker.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide FlowySvg;
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/rounded_button.dart';
import 'package:flutter/material.dart';
import 'cover_editor.dart';
import 'emoji_icon_widget.dart';
import 'emoji_popover.dart';
const double kCoverHeight = 250.0;
const double kIconHeight = 60.0;
const double kToolbarHeight = 40.0; // with padding to the top
class DocumentHeaderBlockKeys {
const DocumentHeaderBlockKeys._();
static const String coverType = 'cover_selection_type';
static const String coverDetails = 'cover_selection';
static const String icon = 'selected_icon';
}
enum CoverType {
none,
color,
file,
asset;
static CoverType fromString(String? value) {
if (value == null) {
return CoverType.none;
}
return CoverType.values.firstWhere(
(e) => e.toString() == value,
orElse: () => CoverType.none,
);
}
}
class DocumentHeaderNodeWidgetBuilder implements NodeWidgetBuilder {
@override
Widget build(NodeWidgetContext<Node> context) {
return DocumentHeaderNodeWidget(
key: context.node.key,
node: context.node,
editorState: context.editorState,
);
}
@override
NodeValidator<Node> get nodeValidator => (_) => true;
}
class DocumentHeaderNodeWidget extends StatefulWidget {
const DocumentHeaderNodeWidget({
required this.node,
required this.editorState,
super.key,
});
final Node node;
final EditorState editorState;
@override
State<DocumentHeaderNodeWidget> createState() =>
_DocumentHeaderNodeWidgetState();
}
class _DocumentHeaderNodeWidgetState extends State<DocumentHeaderNodeWidget> {
CoverType get coverType => CoverType.fromString(
widget.node.attributes[DocumentHeaderBlockKeys.coverType],
);
String? get coverDetails =>
widget.node.attributes[DocumentHeaderBlockKeys.coverDetails];
String get icon => widget.node.attributes[DocumentHeaderBlockKeys.icon];
bool get hasIcon =>
widget.node.attributes[DocumentHeaderBlockKeys.icon]?.isNotEmpty ?? false;
bool get hasCover => coverType != CoverType.none;
@override
void initState() {
super.initState();
widget.node.addListener(_reload);
}
@override
void dispose() {
widget.node.removeListener(_reload);
super.dispose();
}
void _reload() => setState(() {});
@override
Widget build(BuildContext context) {
return Stack(
children: [
SizedBox(
height: _calculateOverallHeight(),
child: DocumentHeaderToolbar(
onCoverChanged: _saveCover,
node: widget.node,
editorState: widget.editorState,
hasCover: hasCover,
hasIcon: hasIcon,
),
),
if (hasCover)
DocumentCover(
editorState: widget.editorState,
node: widget.node,
coverType: coverType,
coverDetails: coverDetails,
onCoverChanged: (type, details) =>
_saveCover(cover: (type, details)),
),
if (hasIcon)
Positioned(
left: 80,
// if hasCover, there shouldn't be icons present so the icon can
// be closer to the bottom.
bottom:
hasCover ? kToolbarHeight - kIconHeight / 2 : kToolbarHeight,
child: DocumentIcon(
editorState: widget.editorState,
node: widget.node,
icon: icon,
onIconChanged: (icon) => _saveCover(icon: icon),
),
),
],
);
}
double _calculateOverallHeight() {
switch ((hasIcon, hasCover)) {
case (true, true):
return kCoverHeight + kToolbarHeight;
case (true, false):
return 50 + kIconHeight + kToolbarHeight;
case (false, true):
return kCoverHeight + kToolbarHeight;
case (false, false):
return kToolbarHeight;
}
}
Future<void> _saveCover({(CoverType, String?)? cover, String? icon}) {
final transaction = widget.editorState.transaction;
final Map<String, dynamic> attributes = {
DocumentHeaderBlockKeys.coverType:
widget.node.attributes[DocumentHeaderBlockKeys.coverType],
DocumentHeaderBlockKeys.coverDetails:
widget.node.attributes[DocumentHeaderBlockKeys.coverDetails],
DocumentHeaderBlockKeys.icon:
widget.node.attributes[DocumentHeaderBlockKeys.icon]
};
if (cover != null) {
attributes[DocumentHeaderBlockKeys.coverType] = cover.$1.toString();
attributes[DocumentHeaderBlockKeys.coverDetails] = cover.$2;
}
if (icon != null) {
attributes[DocumentHeaderBlockKeys.icon] = icon;
}
transaction.updateNode(widget.node, attributes);
return widget.editorState.apply(transaction);
}
}
@visibleForTesting
class DocumentHeaderToolbar extends StatefulWidget {
final Node node;
final EditorState editorState;
final bool hasCover;
final bool hasIcon;
final Future<void> Function({(CoverType, String?)? cover, String? icon})
onCoverChanged;
const DocumentHeaderToolbar({
required this.node,
required this.editorState,
required this.hasCover,
required this.hasIcon,
required this.onCoverChanged,
super.key,
});
@override
State<DocumentHeaderToolbar> createState() => _DocumentHeaderToolbarState();
}
class _DocumentHeaderToolbarState extends State<DocumentHeaderToolbar> {
bool isHidden = true;
bool isPopoverOpen = false;
final PopoverController _popoverController = PopoverController();
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (event) => setHidden(false),
onExit: (event) {
if (!isPopoverOpen) {
setHidden(true);
}
},
opaque: false,
child: Container(
alignment: Alignment.bottomLeft,
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 80),
child: SizedBox(
height: 28,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: buildRowChildren(),
),
),
),
);
}
List<Widget> buildRowChildren() {
if (isHidden || widget.hasCover && widget.hasIcon) {
return [];
}
final List<Widget> children = [];
if (!widget.hasCover) {
children.add(
FlowyButton(
leftIconSize: const Size.square(18),
onTap: () => widget.onCoverChanged(
cover: (CoverType.asset, builtInAssetImages.first),
),
useIntrinsicWidth: true,
leftIcon: const FlowySvg(name: 'editor/image'),
text: FlowyText.regular(
LocaleKeys.document_plugins_cover_addCover.tr(),
),
),
);
}
if (widget.hasIcon) {
children.add(
FlowyButton(
leftIconSize: const Size.square(18),
onTap: () => widget.onCoverChanged(icon: ""),
useIntrinsicWidth: true,
leftIcon: const Icon(
Icons.emoji_emotions_outlined,
size: 18,
),
text: FlowyText.regular(
LocaleKeys.document_plugins_cover_removeIcon.tr(),
),
),
);
} else {
children.add(
AppFlowyPopover(
onClose: () => isPopoverOpen = false,
controller: _popoverController,
offset: const Offset(0, 8),
direction: PopoverDirection.bottomWithCenterAligned,
constraints: BoxConstraints.loose(const Size(320, 380)),
child: FlowyButton(
leftIconSize: const Size.square(18),
useIntrinsicWidth: true,
leftIcon: const Icon(
Icons.emoji_emotions_outlined,
size: 18,
),
text: FlowyText.regular(
LocaleKeys.document_plugins_cover_addIcon.tr(),
),
),
popupBuilder: (BuildContext popoverContext) {
isPopoverOpen = true;
return EmojiPopover(
showRemoveButton: widget.hasIcon,
removeIcon: () {
widget.onCoverChanged(icon: "");
_popoverController.close();
},
node: widget.node,
editorState: widget.editorState,
onEmojiChanged: (Emoji emoji) {
widget.onCoverChanged(icon: emoji.emoji);
_popoverController.close();
},
);
},
),
);
}
return children;
}
void setHidden(bool value) {
if (isHidden == value) return;
setState(() {
isHidden = value;
});
}
}
@visibleForTesting
class DocumentCover extends StatefulWidget {
final Node node;
final EditorState editorState;
final CoverType coverType;
final String? coverDetails;
final Future<void> Function(CoverType type, String? details) onCoverChanged;
const DocumentCover({
required this.editorState,
required this.node,
required this.coverType,
required this.onCoverChanged,
this.coverDetails,
super.key,
});
@override
State<DocumentCover> createState() => DocumentCoverState();
}
class DocumentCoverState extends State<DocumentCover> {
bool isOverlayButtonsHidden = true;
bool isPopoverOpen = false;
@override
Widget build(BuildContext context) {
return SizedBox(
height: kCoverHeight,
child: MouseRegion(
onEnter: (event) => setOverlayButtonsHidden(false),
onExit: (event) =>
setOverlayButtonsHidden(isPopoverOpen ? false : true),
child: Stack(
children: [
SizedBox(
height: double.infinity,
width: double.infinity,
child: _buildCoverImage(),
),
if (!isOverlayButtonsHidden) _buildCoverOverlayButtons(context)
],
),
),
);
}
Widget _buildCoverImage() {
switch (widget.coverType) {
case CoverType.file:
final imageFile = File(widget.coverDetails ?? "");
if (!imageFile.existsSync()) {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onCoverChanged(CoverType.none, null);
});
return const SizedBox.shrink();
}
return Image.file(
imageFile,
fit: BoxFit.cover,
);
case CoverType.asset:
return Image.asset(
widget.coverDetails!,
fit: BoxFit.cover,
);
case CoverType.color:
final color = widget.coverDetails?.toColor() ?? Colors.white;
return Container(color: color);
case CoverType.none:
return const SizedBox.shrink();
}
}
Widget _buildCoverOverlayButtons(BuildContext context) {
return Positioned(
bottom: 20,
right: 50,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AppFlowyPopover(
offset: const Offset(0, 8),
direction: PopoverDirection.bottomWithCenterAligned,
constraints: BoxConstraints.loose(const Size(380, 450)),
margin: EdgeInsets.zero,
onClose: () => isPopoverOpen = false,
child: RoundedTextButton(
hoverColor: Theme.of(context).colorScheme.surface,
textColor: Theme.of(context).colorScheme.tertiary,
fillColor: Theme.of(context).colorScheme.surface.withOpacity(0.5),
width: 120,
height: 28,
title: LocaleKeys.document_plugins_cover_changeCover.tr(),
),
popupBuilder: (BuildContext popoverContext) {
isPopoverOpen = true;
return ChangeCoverPopover(
node: widget.node,
editorState: widget.editorState,
onCoverChanged: (cover, selection) =>
widget.onCoverChanged(cover, selection),
);
},
),
const HSpace(10),
DeleteCoverButton(
onTap: () => widget.onCoverChanged(CoverType.none, null),
),
],
),
);
}
void setOverlayButtonsHidden(bool value) {
if (isOverlayButtonsHidden == value) return;
setState(() {
isOverlayButtonsHidden = value;
});
}
}
@visibleForTesting
class DeleteCoverButton extends StatelessWidget {
final VoidCallback onTap;
const DeleteCoverButton({required this.onTap, super.key});
@override
Widget build(BuildContext context) {
return FlowyIconButton(
hoverColor: Theme.of(context).colorScheme.surface,
fillColor: Theme.of(context).colorScheme.surface.withOpacity(0.5),
iconPadding: const EdgeInsets.all(5),
width: 28,
icon: svgWidget(
'editor/delete',
color: Theme.of(context).colorScheme.tertiary,
),
onPressed: onTap,
);
}
}
@visibleForTesting
class DocumentIcon extends StatefulWidget {
final Node node;
final EditorState editorState;
final String icon;
final Future<void> Function(String icon) onIconChanged;
const DocumentIcon({
required this.node,
required this.editorState,
required this.icon,
required this.onIconChanged,
super.key,
});
@override
State<DocumentIcon> createState() => _DocumentIconState();
}
class _DocumentIconState extends State<DocumentIcon> {
final PopoverController _popoverController = PopoverController();
@override
Widget build(BuildContext context) {
return AppFlowyPopover(
direction: PopoverDirection.bottomWithCenterAligned,
controller: _popoverController,
offset: const Offset(0, 8),
constraints: BoxConstraints.loose(const Size(320, 380)),
child: EmojiIconWidget(emoji: widget.icon),
popupBuilder: (BuildContext popoverContext) {
return EmojiPopover(
node: widget.node,
showRemoveButton: true,
removeIcon: () {
widget.onIconChanged("");
_popoverController.close();
},
editorState: widget.editorState,
onEmojiChanged: (Emoji emoji) {
widget.onIconChanged(emoji.emoji);
_popoverController.close();
},
);
},
);
}
}
@@ -5,12 +5,10 @@ class EmojiIconWidget extends StatefulWidget {
const EmojiIconWidget({
super.key,
required this.emoji,
this.size = 80,
this.emojiSize = 60,
});
final String emoji;
final double size;
final double emojiSize;
@override
@@ -25,12 +23,11 @@ class _EmojiIconWidgetState extends State<EmojiIconWidget> {
return MouseRegion(
onEnter: (_) => setHidden(false),
onExit: (_) => setHidden(true),
cursor: SystemMouseCursors.click,
child: Container(
height: widget.size,
width: widget.size,
decoration: BoxDecoration(
color: !hover
? Theme.of(context).colorScheme.inverseSurface
? Theme.of(context).colorScheme.inverseSurface.withOpacity(0.5)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
@@ -30,38 +30,35 @@ class EmojiPopover extends StatefulWidget {
class _EmojiPopoverState extends State<EmojiPopover> {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(15),
child: Column(
children: [
if (widget.showRemoveButton)
Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Align(
alignment: Alignment.centerRight,
child: DeleteButton(onTap: widget.removeIcon),
),
),
Expanded(
child: EmojiPicker(
onEmojiSelected: (category, emoji) {
widget.onEmojiChanged(emoji);
},
config: Config(
columns: 8,
emojiSizeMax: 28,
bgColor: Colors.transparent,
iconColor: Theme.of(context).iconTheme.color!,
iconColorSelected: Theme.of(context).colorScheme.onSurface,
selectedHoverColor: Theme.of(context).colorScheme.secondary,
progressIndicatorColor: Theme.of(context).iconTheme.color!,
buttonMode: ButtonMode.CUPERTINO,
initCategory: Category.RECENT,
),
return Column(
children: [
if (widget.showRemoveButton)
Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Align(
alignment: Alignment.centerRight,
child: DeleteButton(onTap: widget.removeIcon),
),
),
],
),
Expanded(
child: EmojiPicker(
onEmojiSelected: (category, emoji) {
widget.onEmojiChanged(emoji);
},
config: Config(
columns: 8,
emojiSizeMax: 28,
bgColor: Colors.transparent,
iconColor: Theme.of(context).iconTheme.color!,
iconColorSelected: Theme.of(context).colorScheme.onSurface,
selectedHoverColor: Theme.of(context).colorScheme.secondary,
progressIndicatorColor: Theme.of(context).iconTheme.color!,
buttonMode: ButtonMode.CUPERTINO,
initCategory: Category.RECENT,
),
),
),
],
);
}
}
@@ -72,13 +69,16 @@ class DeleteButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FlowyButton(
onTap: onTap,
useIntrinsicWidth: true,
text: FlowyText(
LocaleKeys.document_plugins_cover_removeIcon.tr(),
return SizedBox(
height: 28,
child: FlowyButton(
onTap: onTap,
useIntrinsicWidth: true,
text: FlowyText(
LocaleKeys.document_plugins_cover_removeIcon.tr(),
),
leftIcon: const FlowySvg(name: 'editor/delete'),
),
leftIcon: const FlowySvg(name: 'editor/delete'),
);
}
}
@@ -0,0 +1,290 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide FlowySvg;
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/ignore_parent_gesture.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
class ImageMenu extends StatefulWidget {
const ImageMenu({
super.key,
required this.node,
required this.state,
});
final Node node;
final ImageBlockComponentWidgetState state;
@override
State<ImageMenu> createState() => _ImageMenuState();
}
class _ImageMenuState extends State<ImageMenu> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
height: 32,
decoration: BoxDecoration(
color: theme.cardColor,
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withOpacity(0.1),
),
],
borderRadius: BorderRadius.circular(4.0),
),
child: Row(
children: [
const HSpace(4),
_ImageCopyLinkButton(
onTap: copyImageLink,
),
const HSpace(4),
_ImageAlignButton(
node: widget.node,
state: widget.state,
),
const _Divider(),
_ImageDeleteButton(
onTap: () => deleteImage(),
),
const HSpace(4),
],
),
);
}
void copyImageLink() {
final url = widget.node.attributes[ImageBlockKeys.url];
if (url != null) {
Clipboard.setData(ClipboardData(text: url));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: FlowyText(
LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(),
),
),
);
}
}
Future<void> deleteImage() async {
final node = widget.node;
final editorState = context.read<EditorState>();
final transaction = editorState.transaction;
transaction.deleteNode(node);
transaction.afterSelection = null;
await editorState.apply(transaction);
}
}
class _ImageCopyLinkButton extends StatelessWidget {
const _ImageCopyLinkButton({
required this.onTap,
});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: const FlowySvg(
name: 'editor/copy',
size: Size.square(16),
),
);
}
}
class _ImageAlignButton extends StatefulWidget {
const _ImageAlignButton({
required this.node,
required this.state,
});
final Node node;
final ImageBlockComponentWidgetState state;
@override
State<_ImageAlignButton> createState() => _ImageAlignButtonState();
}
const interceptorKey = 'image-align';
class _ImageAlignButtonState extends State<_ImageAlignButton> {
final gestureInterceptor = SelectionGestureInterceptor(
key: interceptorKey,
canTap: (details) => false,
);
String get align => widget.node.attributes['align'] ?? 'center';
final popoverController = PopoverController();
late final EditorState editorState;
@override
void initState() {
super.initState();
editorState = context.read<EditorState>();
}
@override
void dispose() {
allowMenuClose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return IgnoreParentGestureWidget(
child: AppFlowyPopover(
onClose: allowMenuClose,
controller: popoverController,
windowPadding: const EdgeInsets.all(0),
margin: const EdgeInsets.all(0),
direction: PopoverDirection.bottomWithCenterAligned,
offset: const Offset(0, 10),
child: buildAlignIcon(),
popupBuilder: (_) {
preventMenuClose();
return _AlignButtons(
onAlignChanged: onAlignChanged,
);
},
),
);
}
void onAlignChanged(String align) {
popoverController.close();
final transaction = editorState.transaction;
transaction.updateNode(widget.node, {
ImageBlockKeys.align: align,
});
editorState.apply(transaction);
allowMenuClose();
}
void preventMenuClose() {
widget.state.alwaysShowMenu = true;
editorState.service.selectionService.registerGestureInterceptor(
gestureInterceptor,
);
}
void allowMenuClose() {
widget.state.alwaysShowMenu = false;
editorState.service.selectionService.unregisterGestureInterceptor(
interceptorKey,
);
}
Widget buildAlignIcon() {
return FlowySvg(
name: 'editor/align/$align',
size: const Size.square(16),
);
}
}
class _AlignButtons extends StatelessWidget {
const _AlignButtons({
required this.onAlignChanged,
});
final Function(String align) onAlignChanged;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 32,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const HSpace(4),
_AlignButton(
align: 'left',
onTap: () => onAlignChanged('left'),
),
const _Divider(),
_AlignButton(
align: 'left',
onTap: () => onAlignChanged('center'),
),
const _Divider(),
_AlignButton(
align: 'left',
onTap: () => onAlignChanged('right'),
),
const HSpace(4),
],
),
);
}
}
class _AlignButton extends StatelessWidget {
const _AlignButton({
required this.align,
required this.onTap,
});
final String align;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: FlowySvg(
name: 'editor/align/$align',
size: const Size.square(16),
),
);
}
}
class _ImageDeleteButton extends StatelessWidget {
const _ImageDeleteButton({
required this.onTap,
});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: const FlowySvg(
name: 'editor/delete',
size: Size.square(16),
),
);
}
}
class _Divider extends StatelessWidget {
const _Divider();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: Container(
width: 1,
color: Colors.grey,
),
);
}
}
@@ -0,0 +1,59 @@
import 'dart:io';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/settings/settings_location_cubit.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:flowy_infra/uuid.dart';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
final customImageMenuItem = SelectionMenuItem(
name: AppFlowyEditorLocalizations.current.image,
icon: (editorState, isSelected, style) => SelectionMenuIconWidget(
name: 'image',
isSelected: isSelected,
style: style,
),
keywords: ['image', 'picture', 'img', 'photo'],
handler: (editorState, menuService, context) {
final container = Overlay.of(context);
showImageMenu(
container,
editorState,
menuService,
onInsertImage: (url) async {
// if the url is http, we can insert it directly
// otherwise, if it's a file url, we need to copy the file to the app's document directory
final regex = RegExp('^(http|https)://');
if (regex.hasMatch(url)) {
await editorState.insertImageNode(url);
} else {
final path = await getIt<ApplicationDataStorage>().getPath();
final imagePath = p.join(
path,
'images',
);
try {
// create the directory if not exists
final directory = Directory(imagePath);
if (!directory.existsSync()) {
await directory.create(recursive: true);
}
final copyToPath = p.join(
imagePath,
'${uuid()}${p.extension(url)}',
);
await File(url).copy(
copyToPath,
);
await editorState.insertImageNode(copyToPath);
} catch (e) {
Log.error('cannot copy image file', e);
}
}
},
);
},
);
@@ -0,0 +1,164 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
enum MentionType {
page;
static MentionType fromString(String value) {
switch (value) {
case 'page':
return page;
default:
throw UnimplementedError();
}
}
}
class MentionBlockKeys {
const MentionBlockKeys._();
static const mention = 'mention';
static const type = 'type'; // MentionType, String
static const pageId = 'page_id';
static const pageType = 'page_type';
static const pageName = 'page_name';
}
class InlinePageReferenceService {
customPageLinkMenu({
bool shouldInsertKeyword = false,
SelectionMenuStyle style = SelectionMenuStyle.light,
String character = '@',
}) {
return CharacterShortcutEvent(
key: 'show page link menu',
character: character,
handler: (editorState) async {
final items = await generatePageItems(character);
return _showPageSelectionMenu(
editorState,
items,
shouldInsertKeyword: shouldInsertKeyword,
style: style,
character: character,
);
},
);
}
SelectionMenuService? _selectionMenuService;
Future<bool> _showPageSelectionMenu(
EditorState editorState,
List<SelectionMenuItem> items, {
bool shouldInsertKeyword = true,
SelectionMenuStyle style = SelectionMenuStyle.light,
String character = '@',
}) async {
if (PlatformExtension.isMobile) {
return false;
}
final selection = editorState.selection;
if (selection == null) {
return false;
}
// delete the selection
await editorState.deleteSelection(selection);
final afterSelection = editorState.selection;
if (afterSelection == null || !afterSelection.isCollapsed) {
assert(false, 'the selection should be collapsed');
return true;
}
await editorState.insertTextAtPosition(
character,
position: selection.start,
);
() {
final context = editorState.getNodeAtPath(selection.start.path)?.context;
if (context != null) {
_selectionMenuService = SelectionMenu(
context: context,
editorState: editorState,
selectionMenuItems: items,
deleteSlashByDefault: false,
style: style,
itemCountFilter: 5,
);
_selectionMenuService?.show();
}
}();
return true;
}
Future<List<SelectionMenuItem>> generatePageItems(String character) async {
final service = ViewBackendService();
final List<(ViewPB, List<ViewPB>)> pbViews = await service.fetchViews(
(_, __) => true,
);
if (pbViews.isEmpty) {
return [];
}
final List<SelectionMenuItem> pages = [];
final List<ViewPB> views = [];
for (final element in pbViews) {
views.addAll(element.$2);
}
views.sort(((a, b) => b.createTime.compareTo(a.createTime)));
for (final view in views) {
final SelectionMenuItem pageSelectionMenuItem = SelectionMenuItem(
icon: (editorState, isSelected, style) => SelectableSvgWidget(
name: view.iconName,
isSelected: isSelected,
style: style,
),
keywords: [
view.name.toLowerCase(),
],
name: view.name,
handler: (editorState, menuService, context) async {
final selection = editorState.selection;
if (selection == null || !selection.isCollapsed) {
return;
}
final node = editorState.getNodeAtPath(selection.end.path);
final delta = node?.delta;
if (node == null || delta == null) {
return;
}
final index = selection.endIndex;
final lastKeywordIndex =
delta.toPlainText().substring(0, index).lastIndexOf(character);
// @page name -> $
// preload the page infos
pageMemorizer[view.id] = view;
final transaction = editorState.transaction
..replaceText(
node,
lastKeywordIndex,
index - lastKeywordIndex,
'\$',
attributes: {
MentionBlockKeys.mention: {
MentionBlockKeys.type: MentionType.page.name,
MentionBlockKeys.pageId: view.id,
}
},
);
await editorState.apply(transaction);
},
);
pages.add(pageSelectionMenuItem);
}
return pages;
}
}
@@ -0,0 +1,22 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:flutter/material.dart';
class MentionBlock extends StatelessWidget {
const MentionBlock({
super.key,
required this.mention,
});
final Map mention;
@override
Widget build(BuildContext context) {
final type = MentionType.fromString(mention[MentionBlockKeys.type]);
if (type == MentionType.page) {
final pageId = mention[MentionBlockKeys.pageId];
return MentionPageBlock(key: ValueKey(pageId), pageId: pageId);
}
throw UnimplementedError();
}
}
@@ -0,0 +1,143 @@
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
import 'package:appflowy/plugins/trash/application/trash_service.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/menu.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart'
show EditorState, SelectionUpdateReason;
import 'package:collection/collection.dart';
import 'package:flowy_infra/image.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
final pageMemorizer = <String, ViewPB?>{};
class MentionPageBlock extends StatefulWidget {
const MentionPageBlock({
super.key,
required this.pageId,
});
final String pageId;
@override
State<MentionPageBlock> createState() => _MentionPageBlockState();
}
class _MentionPageBlockState extends State<MentionPageBlock> {
late final EditorState editorState;
late final Future<ViewPB?> viewPBFuture;
ViewListener? viewListener;
@override
void initState() {
super.initState();
editorState = context.read<EditorState>();
viewPBFuture = fetchView(widget.pageId);
viewListener = ViewListener(viewId: widget.pageId)
..start(
onViewUpdated: (p0) {
pageMemorizer[p0.id] = p0;
editorState.reload();
},
);
}
@override
void dispose() {
viewListener?.stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
final fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
return FutureBuilder<ViewPB?>(
initialData: pageMemorizer[widget.pageId],
future: viewPBFuture,
builder: (context, state) {
final view = state.data;
// memorize the result
pageMemorizer[widget.pageId] = view;
if (view == null) {
return const SizedBox.shrink();
}
updateSelection();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: FlowyHover(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => openPage(widget.pageId),
behavior: HitTestBehavior.translucent,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const HSpace(4),
FlowySvg(
name: view.layout.iconName,
size: const Size.square(18.0),
),
const HSpace(2),
FlowyText(
view.name,
decoration: TextDecoration.underline,
fontSize: fontSize,
),
const HSpace(2),
],
),
),
),
);
},
);
}
void openPage(String pageId) async {
final view = await fetchView(pageId);
if (view == null) {
Log.error('Page($pageId) not found');
return;
}
getIt<MenuSharedState>().latestOpenView = view;
}
Future<ViewPB?> fetchView(String pageId) async {
final views = await ViewBackendService().fetchViews((_, __) => true);
final flattenViews = views.expand((e) => [e.$1, ...e.$2]).toList();
final view = flattenViews.firstWhereOrNull(
(element) => element.id == pageId,
);
if (view == null) {
// try to fetch from trash
final trashViews = await TrashService().readTrash();
final trash = trashViews.fold(
(l) => l.items.firstWhereOrNull((element) => element.id == pageId),
(r) => null,
);
if (trash != null) {
return ViewPB()
..id = trash.id
..name = trash.name;
}
}
return view;
}
void updateSelection() {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
editorState.updateSelectionWithReason(
editorState.selection,
reason: SelectionUpdateReason.transaction,
);
});
}
}
@@ -0,0 +1,212 @@
import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class OutlineBlockKeys {
const OutlineBlockKeys._();
static const String type = 'outline';
static const String backgroundColor = blockComponentBackgroundColor;
}
// defining the callout block menu item for selection
SelectionMenuItem outlineItem = SelectionMenuItem.node(
name: LocaleKeys.document_selectionMenu_outline.tr(),
iconData: Icons.list_alt,
keywords: ['outline', 'table of contents'],
nodeBuilder: (editorState) => outlineBlockNode(),
replace: (_, node) => node.delta?.isEmpty ?? false,
);
Node outlineBlockNode() {
return Node(
type: OutlineBlockKeys.type,
);
}
class OutlineBlockComponentBuilder extends BlockComponentBuilder {
OutlineBlockComponentBuilder({
this.configuration = const BlockComponentConfiguration(),
});
@override
final BlockComponentConfiguration configuration;
@override
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return OutlineBlockWidget(
key: node.key,
node: node,
configuration: configuration,
showActions: showActions(node),
actionBuilder: (context, state) => actionBuilder(
blockComponentContext,
state,
),
);
}
@override
bool validate(Node node) => node.children.isEmpty;
}
class OutlineBlockWidget extends BlockComponentStatefulWidget {
const OutlineBlockWidget({
super.key,
required super.node,
super.showActions,
super.actionBuilder,
super.configuration = const BlockComponentConfiguration(),
});
@override
State<OutlineBlockWidget> createState() => _OutlineBlockWidgetState();
}
class _OutlineBlockWidgetState extends State<OutlineBlockWidget>
with BlockComponentConfigurable {
@override
BlockComponentConfiguration get configuration => widget.configuration;
@override
Node get node => widget.node;
// get the background color of the note block from the node's attributes
Color get backgroundColor {
final colorString =
node.attributes[OutlineBlockKeys.backgroundColor] as String?;
if (colorString == null) {
return Colors.transparent;
}
return colorString.toColor();
}
late EditorState editorState = context.read<EditorState>();
late Stream<(TransactionTime, Transaction)> stream =
editorState.transactionStream;
@override
Widget build(BuildContext context) {
return StreamBuilder(
stream: stream,
builder: (context, snapshot) {
if (widget.showActions && widget.actionBuilder != null) {
return BlockComponentActionWrapper(
node: widget.node,
actionBuilder: widget.actionBuilder!,
child: _buildOutlineBlock(),
);
}
return _buildOutlineBlock();
},
);
}
Widget _buildOutlineBlock() {
final children = getHeadingNodes()
.map(
(e) => Container(
padding: const EdgeInsets.only(
bottom: 4.0,
),
width: double.infinity,
child: OutlineItemWidget(node: e),
),
)
.toList();
if (children.isEmpty) {
return Align(
alignment: Alignment.centerLeft,
child: Text(
LocaleKeys.document_plugins_outline_addHeadingToCreateOutline.tr(),
style: configuration.placeholderTextStyle(node),
),
);
}
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
color: backgroundColor,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
children: children,
),
);
}
Iterable<Node> getHeadingNodes() {
final children = editorState.document.root.children;
return children.where((element) => element.type == HeadingBlockKeys.type);
}
}
class OutlineItemWidget extends StatelessWidget {
OutlineItemWidget({
super.key,
required this.node,
}) {
assert(node.type == HeadingBlockKeys.type);
}
final Node node;
@override
Widget build(BuildContext context) {
final editorState = context.read<EditorState>();
final textStyle = editorState.editorStyle.textStyleConfiguration;
final style = textStyle.href.combine(textStyle.text);
return FlowyHover(
style: HoverStyle(
hoverColor: Colors.grey.withOpacity(0.2), // TODO: use theme color.
),
child: GestureDetector(
onTap: () => updateBlockSelection(context),
child: Container(
padding: EdgeInsets.only(left: node.leftIndent),
child: Text(
node.outlineItemText,
style: style,
),
),
),
);
}
void updateBlockSelection(BuildContext context) {
final editorState = context.read<EditorState>();
editorState.selectionType = SelectionType.block;
editorState.selection = Selection.collapse(
node.path,
node.delta?.length ?? 0,
);
editorState.selectionType = null;
}
}
extension on Node {
double get leftIndent {
assert(type == HeadingBlockKeys.type);
if (type != HeadingBlockKeys.type) {
return 0.0;
}
final level = attributes[HeadingBlockKeys.level];
if (level == 2) {
return 20;
} else if (level == 3) {
return 40;
}
return 0;
}
String get outlineItemText {
return delta?.toPlainText() ?? '';
}
}
@@ -1,12 +1,13 @@
export 'callout/callout_block_component.dart';
export 'code_block/code_block_component.dart';
export 'code_block/code_block_shortcut_event.dart';
export 'cover/change_cover_popover_bloc.dart';
export 'cover/cover_node_widget.dart';
export 'cover/cover_image_picker.dart';
export 'header/cover_editor_bloc.dart';
export 'header/document_header_node_widget.dart';
export 'header/custom_cover_picker.dart';
export 'emoji_picker/emoji_menu_item.dart';
export 'extensions/flowy_tint_extension.dart';
export 'database/inline_database_menu_item.dart';
export 'database/referenced_database_menu_item.dart';
export 'database/database_view_block_component.dart';
export 'math_equation/math_equation_block_component.dart';
export 'openai/widgets/auto_completion_node_widget.dart';
@@ -14,3 +15,8 @@ export 'openai/widgets/smart_edit_node_widget.dart';
export 'openai/widgets/smart_edit_toolbar_item.dart';
export 'toggle/toggle_block_component.dart';
export 'toggle/toggle_block_shortcut_event.dart';
export 'outline/outline_block_component.dart';
export 'image/image_menu.dart';
export 'image/image_selection_menu.dart';
export 'actions/option_action.dart';
export 'actions/block_action_list.dart';
@@ -1,5 +1,7 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide FlowySvg, Log;
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -57,6 +59,7 @@ class EditorStyleCustomizer {
),
),
),
textSpanDecorator: customizeAttributeDecorator,
);
}
@@ -124,6 +127,17 @@ class EditorStyleCustomizer {
);
}
TextStyle outlineBlockPlaceholderStyleBuilder() {
final theme = Theme.of(context);
final fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
return TextStyle(
fontFamily: 'poppins',
fontSize: fontSize,
height: 1.5,
color: theme.colorScheme.onBackground.withOpacity(0.6),
);
}
SelectionMenuStyle selectionMenuStyleBuilder() {
final theme = Theme.of(context);
return SelectionMenuStyle(
@@ -142,4 +156,28 @@ class EditorStyleCustomizer {
backgroundColor: theme.colorScheme.onTertiary,
);
}
InlineSpan customizeAttributeDecorator(
TextInsert textInsert,
TextSpan textSpan,
) {
final attributes = textInsert.attributes;
if (attributes == null) {
return textSpan;
}
final mention = attributes[MentionBlockKeys.mention] as Map?;
if (mention != null) {
final type = mention[MentionBlockKeys.type];
if (type == MentionType.page.name) {
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: MentionBlock(
key: ValueKey(mention[MentionBlockKeys.pageId]),
mention: mention,
),
);
}
}
return textSpan;
}
}
@@ -25,6 +25,11 @@ class DocumentAppearanceCubit extends Cubit<DocumentAppearance> {
void fetch() async {
final prefs = await SharedPreferences.getInstance();
final fontSize = prefs.getDouble(_kDocumentAppearanceFontSize) ?? 16.0;
if (isClosed) {
return;
}
emit(
state.copyWith(
fontSize: fontSize,
@@ -35,6 +40,11 @@ class DocumentAppearanceCubit extends Cubit<DocumentAppearance> {
void syncFontSize(double fontSize) async {
final prefs = await SharedPreferences.getInstance();
prefs.setDouble(_kDocumentAppearanceFontSize, fontSize);
if (isClosed) {
return;
}
emit(
state.copyWith(
fontSize: fontSize,
@@ -94,13 +94,21 @@ extension ViewExtension on ViewPB {
}
String get iconName {
switch (layout) {
return layout.iconName;
}
}
extension ViewLayoutExtension on ViewLayoutPB {
String get iconName {
switch (this) {
case ViewLayoutPB.Grid:
return 'editor/grid';
case ViewLayoutPB.Board:
return 'editor/board';
case ViewLayoutPB.Calendar:
return 'editor/calendar';
case ViewLayoutPB.Document:
return 'editor/documents';
default:
throw Exception('Unknown layout type');
}
@@ -154,8 +154,19 @@ class ViewBackendService {
return FolderEventMoveView(payload).send();
}
Future<List<(ViewPB, List<ViewPB>)>> fetchViewsWithLayoutType(
ViewLayoutPB? layoutType,
) async {
return fetchViews((workspace, view) {
if (layoutType != null) {
return view.layout == layoutType;
}
return true;
});
}
Future<List<(ViewPB, List<ViewPB>)>> fetchViews(
ViewLayoutPB layoutType,
bool Function(WorkspaceSettingPB workspace, ViewPB view) filter,
) async {
final result = <(ViewPB, List<ViewPB>)>[];
return FolderEventGetCurrentWorkspace().send().then((value) async {
@@ -166,7 +177,7 @@ class ViewBackendService {
final childViews = await getChildViews(viewId: view.id).then(
(value) => value
.getLeftOrNull<List<ViewPB>>()
?.where((e) => e.layout == layoutType)
?.where((e) => filter(workspaces, e))
.toList(),
);
if (childViews != null && childViews.isNotEmpty) {
@@ -3,6 +3,7 @@ import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
@@ -78,6 +79,11 @@ class BubbleActionList extends StatelessWidget {
"https://appflowy.gitbook.io/docs/essential-documentation/markdown",
);
break;
case BubbleAction.github:
_launchURL(
'https://github.com/AppFlowy-IO/AppFlowy/issues/new/choose',
);
break;
}
}
@@ -169,14 +175,14 @@ class FlowyVersionDescription extends CustomActionCell {
}
}
enum BubbleAction { whatsNews, help, debug, shortcuts, markdown }
enum BubbleAction { whatsNews, help, debug, shortcuts, markdown, github }
class BubbleActionWrapper extends ActionCell {
final BubbleAction inner;
BubbleActionWrapper(this.inner);
@override
Widget? leftIcon(Color iconColor) => FlowyText.regular(inner.emoji);
Widget? leftIcon(Color iconColor) => inner.emoji;
@override
String get name => inner.name;
@@ -195,21 +201,31 @@ extension QuestionBubbleExtension on BubbleAction {
return LocaleKeys.questionBubble_shortcuts.tr();
case BubbleAction.markdown:
return LocaleKeys.questionBubble_markdown.tr();
case BubbleAction.github:
return LocaleKeys.questionBubble_feedback.tr();
}
}
String get emoji {
Widget get emoji {
switch (this) {
case BubbleAction.whatsNews:
return '🆕';
return const FlowyText.regular('🆕');
case BubbleAction.help:
return '👥';
return const FlowyText.regular('👥');
case BubbleAction.debug:
return '🐛';
return const FlowyText.regular('🐛');
case BubbleAction.shortcuts:
return '📋';
return const FlowyText.regular('📋');
case BubbleAction.markdown:
return '✨';
return const FlowyText.regular('✨');
case BubbleAction.github:
return Padding(
padding: const EdgeInsets.all(3.0),
child: svgWidget(
'login/github-mark',
size: const Size.square(12),
),
);
}
}
}
@@ -78,10 +78,10 @@ class FlowyIconButton extends StatelessWidget {
onPressed: onPressed,
child: FlowyHover(
style: HoverStyle(
hoverColor: hoverColor,
hoverColor: Colors.transparent,
foregroundColorOnHover:
iconColorOnHover ?? Theme.of(context).iconTheme.color,
backgroundColor: fillColor ?? Colors.transparent,
backgroundColor: Colors.transparent,
),
child: Padding(
padding: iconPadding,
@@ -9,12 +9,14 @@ class FlowyErrorPage extends StatelessWidget {
Error e, {
required String howToFix,
Key? key,
List<Widget>? actions,
}) =>
FlowyErrorPage._(
e.toString(),
stackTrace: e.stackTrace?.toString(),
howToFix: howToFix,
key: key,
actions: actions,
);
factory FlowyErrorPage.message(
@@ -22,12 +24,14 @@ class FlowyErrorPage extends StatelessWidget {
required String howToFix,
String? stackTrace,
Key? key,
List<Widget>? actions,
}) =>
FlowyErrorPage._(
message,
key: key,
stackTrace: stackTrace,
howToFix: howToFix,
actions: actions,
);
factory FlowyErrorPage.exception(
@@ -35,12 +39,14 @@ class FlowyErrorPage extends StatelessWidget {
required String howToFix,
String? stackTrace,
Key? key,
List<Widget>? actions,
}) =>
FlowyErrorPage._(
e.toString(),
stackTrace: stackTrace,
key: key,
howToFix: howToFix,
actions: actions,
);
const FlowyErrorPage._(
@@ -48,14 +54,16 @@ class FlowyErrorPage extends StatelessWidget {
required this.howToFix,
this.stackTrace,
super.key,
this.actions,
});
static const _titleFontSize = 24.0;
static const _titleToMessagePadding = 8.0;
final List<Widget>? actions;
final String howToFix;
final String message;
final String? stackTrace;
final String howToFix;
@override
Widget build(BuildContext context) {
@@ -87,6 +95,11 @@ class FlowyErrorPage extends StatelessWidget {
height: _titleToMessagePadding,
),
if (stackTrace != null) StackTracePreview(stackTrace!),
if (actions != null)
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: actions!,
),
],
),
);
+2 -2
View File
@@ -53,8 +53,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: cd0f67a
resolved-ref: cd0f67a48e40188114800fae9a0f59cafe15b0f2
ref: "572a174"
resolved-ref: "572a174892267e2f78f9c3d7f1fe4ca71c9be0db"
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git
version: "1.0.4"
+2 -2
View File
@@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 0.2.4
version: 0.2.5
environment:
sdk: ">=3.0.0 <4.0.0"
@@ -46,7 +46,7 @@ dependencies:
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: cd0f67a
ref: 572a174
appflowy_popover:
path: packages/appflowy_popover
+5
View File
@@ -27,12 +27,14 @@
"@tauri-apps/api": "^1.2.0",
"dayjs": "^1.11.7",
"emoji-mart": "^5.5.2",
"emoji-regex": "^10.2.1",
"events": "^3.3.0",
"google-protobuf": "^3.21.2",
"i18next": "^22.4.10",
"i18next-browser-languagedetector": "^7.0.1",
"is-hotkey": "^0.2.0",
"jest": "^29.5.0",
"katex": "^0.16.7",
"nanoid": "^4.0.0",
"prismjs": "^1.29.0",
"protoc-gen-ts": "^0.8.5",
@@ -44,6 +46,7 @@
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4",
"react-i18next": "^12.2.0",
"react-katex": "^3.0.1",
"react-redux": "^8.0.5",
"react-router-dom": "^6.8.0",
"react18-input-otp": "^1.1.2",
@@ -60,12 +63,14 @@
"@tauri-apps/cli": "^1.2.2",
"@types/google-protobuf": "^3.15.6",
"@types/is-hotkey": "^0.1.7",
"@types/katex": "^0.16.0",
"@types/node": "^18.7.10",
"@types/prismjs": "^1.26.0",
"@types/quill": "^2.0.10",
"@types/react": "^18.0.15",
"@types/react-beautiful-dnd": "^13.1.3",
"@types/react-dom": "^18.0.6",
"@types/react-katex": "^3.0.0",
"@types/utf8": "^3.0.1",
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.51.0",
+52
View File
@@ -37,6 +37,9 @@ dependencies:
emoji-mart:
specifier: ^5.5.2
version: 5.5.2
emoji-regex:
specifier: ^10.2.1
version: 10.2.1
events:
specifier: ^3.3.0
version: 3.3.0
@@ -55,6 +58,9 @@ dependencies:
jest:
specifier: ^29.5.0
version: 29.5.0(@types/node@18.16.9)
katex:
specifier: ^0.16.7
version: 0.16.7
nanoid:
specifier: ^4.0.0
version: 4.0.2
@@ -88,6 +94,9 @@ dependencies:
react-i18next:
specifier: ^12.2.0
version: 12.2.2(i18next@22.4.15)(react-dom@18.2.0)(react@18.2.0)
react-katex:
specifier: ^3.0.1
version: 3.0.1(prop-types@15.8.1)(react@18.2.0)
react-redux:
specifier: ^8.0.5
version: 8.0.5(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1)
@@ -132,6 +141,9 @@ devDependencies:
'@types/is-hotkey':
specifier: ^0.1.7
version: 0.1.7
'@types/katex':
specifier: ^0.16.0
version: 0.16.0
'@types/node':
specifier: ^18.7.10
version: 18.16.9
@@ -150,6 +162,9 @@ devDependencies:
'@types/react-dom':
specifier: ^18.0.6
version: 18.2.4
'@types/react-katex':
specifier: ^3.0.0
version: 3.0.0
'@types/utf8':
specifier: ^3.0.1
version: 3.0.1
@@ -1632,6 +1647,10 @@ packages:
resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==}
dev: true
/@types/katex@0.16.0:
resolution: {integrity: sha512-hz+S3nV6Mym5xPbT9fnO8dDhBFQguMYpY0Ipxv06JMi1ORgnEM4M1ymWDUhUNer3ElLmT583opRo4RzxKmh9jw==}
dev: true
/@types/lodash.memoize@4.1.7:
resolution: {integrity: sha512-lGN7WeO4vO6sICVpf041Q7BX/9k1Y24Zo3FY0aUezr1QlKznpjzsDk3T3wvH8ofYzoK0QupN9TWcFAFZlyPwQQ==}
dependencies:
@@ -1684,6 +1703,12 @@ packages:
'@types/react': 17.0.59
dev: false
/@types/react-katex@3.0.0:
resolution: {integrity: sha512-AiHHXh71a2M7Z6z1wj6iA23SkiRF9r0neHUdu8zjU/cT3MyLxDefYHbcceKhV/gjDEZgF3YaiNHyPNtoGUjPvg==}
dependencies:
'@types/react': 18.2.6
dev: true
/@types/react-redux@7.1.25:
resolution: {integrity: sha512-bAGh4e+w5D8dajd6InASVIyCo4pZLJ66oLb80F9OBLO1gKESbZcRCJpTT6uLXX+HAB57zw1WTdwJdAsewuTweg==}
dependencies:
@@ -2280,6 +2305,11 @@ packages:
engines: {node: '>= 6'}
dev: true
/commander@8.3.0:
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
engines: {node: '>= 12'}
dev: false
/compute-scroll-into-view@1.0.20:
resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==}
dev: false
@@ -2437,6 +2467,10 @@ packages:
resolution: {integrity: sha512-Sqc/nso4cjxhOwWJsp9xkVm8OF5c+mJLZJFoFfzRuKO+yWiN7K8c96xmtughYb0d/fZ8UC6cLIQ/p4BR6Pv3/A==}
dev: false
/emoji-regex@10.2.1:
resolution: {integrity: sha512-97g6QgOk8zlDRdgq1WxwgTMgEWGVAQvB5Fdpgc1MkNy56la5SKP9GsMXKDOdqwn90/41a8yPwIGk1Y6WVbeMQA==}
dev: false
/emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
dev: false
@@ -3799,6 +3833,13 @@ packages:
object.assign: 4.1.4
dev: true
/katex@0.16.7:
resolution: {integrity: sha512-Xk9C6oGKRwJTfqfIbtr0Kes9OSv6IFsuhFGc7tW4urlpMJtuh+7YhzU6YEG9n8gmWKcMAFzkp7nr+r69kV0zrA==}
hasBin: true
dependencies:
commander: 8.3.0
dev: false
/kleur@3.0.3:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'}
@@ -4483,6 +4524,17 @@ packages:
resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
dev: false
/react-katex@3.0.1(prop-types@15.8.1)(react@18.2.0):
resolution: {integrity: sha512-wIUW1fU5dHlkKvq4POfDkHruQsYp3fM8xNb/jnc8dnQ+nNCnaj0sx5pw7E6UyuEdLRyFKK0HZjmXBo+AtXXy0A==}
peerDependencies:
prop-types: ^15.8.1
react: '>=15.3.2 <=18'
dependencies:
katex: 0.16.7
prop-types: 15.8.1
react: 18.2.0
dev: false
/react-redux@7.2.9(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==}
peerDependencies:
+160 -150
View File
@@ -105,7 +105,7 @@ checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8"
[[package]]
name = "appflowy-integrate"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=06e942#06e942cb6433c94b5ecfe1d431b64bba625fc09c"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
dependencies = [
"anyhow",
"collab",
@@ -145,9 +145,9 @@ checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
[[package]]
name = "arrayvec"
version = "0.7.3"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8868f09ff8cea88b079da74ae569d9b8c62a23c68c746240b704ee6f7525c89c"
checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711"
[[package]]
name = "async-stream"
@@ -168,7 +168,7 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.18",
"syn 2.0.22",
]
[[package]]
@@ -179,7 +179,7 @@ checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.18",
"syn 2.0.22",
]
[[package]]
@@ -203,7 +203,7 @@ dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps 6.1.0",
"system-deps 6.1.1",
]
[[package]]
@@ -600,7 +600,7 @@ dependencies = [
"regex",
"rustc-hash",
"shlex",
"syn 2.0.18",
"syn 2.0.22",
]
[[package]]
@@ -827,7 +827,7 @@ checksum = "3c55d429bef56ac9172d25fecb85dc8068307d17acd74b377866b7a1ef25d3c8"
dependencies = [
"glib-sys",
"libc",
"system-deps 6.1.0",
"system-deps 6.1.1",
]
[[package]]
@@ -837,7 +837,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "599aa35200ffff8f04c1925aa1acc92fa2e08874379ef42e210a80e527e60838"
dependencies = [
"serde",
"toml 0.7.4",
"toml 0.7.5",
]
[[package]]
@@ -886,9 +886,9 @@ dependencies = [
[[package]]
name = "cfg-expr"
version = "0.15.2"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e70d3ad08698a0568b0562f22710fe6bfc1f4a61a367c77d0398c562eadd453a"
checksum = "215c0072ecc28f92eeb0eea38ba63ddfcb65c2828c46311d646f1a3ff5f9841c"
dependencies = [
"smallvec",
"target-lexicon",
@@ -935,7 +935,7 @@ checksum = "cf9cc2b23599e6d7479755f3594285efb3f74a1bdca7a7374948bc831e23a552"
dependencies = [
"chrono",
"chrono-tz-build 0.1.0",
"phf 0.11.1",
"phf 0.11.2",
]
[[package]]
@@ -956,8 +956,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9998fb9f7e9b2111641485bf8beb32f92945f97f92a3d061f744cfef335f751"
dependencies = [
"parse-zoneinfo",
"phf 0.11.1",
"phf_codegen 0.11.1",
"phf 0.11.2",
"phf_codegen 0.11.2",
]
[[package]]
@@ -1030,7 +1030,7 @@ dependencies = [
[[package]]
name = "collab"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=06e942#06e942cb6433c94b5ecfe1d431b64bba625fc09c"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
dependencies = [
"anyhow",
"bytes",
@@ -1048,7 +1048,7 @@ dependencies = [
[[package]]
name = "collab-client-ws"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=06e942#06e942cb6433c94b5ecfe1d431b64bba625fc09c"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
dependencies = [
"bytes",
"collab-sync",
@@ -1066,7 +1066,7 @@ dependencies = [
[[package]]
name = "collab-database"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=06e942#06e942cb6433c94b5ecfe1d431b64bba625fc09c"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
dependencies = [
"anyhow",
"async-trait",
@@ -1092,7 +1092,7 @@ dependencies = [
[[package]]
name = "collab-derive"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=06e942#06e942cb6433c94b5ecfe1d431b64bba625fc09c"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
dependencies = [
"proc-macro2",
"quote",
@@ -1104,7 +1104,7 @@ dependencies = [
[[package]]
name = "collab-document"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=06e942#06e942cb6433c94b5ecfe1d431b64bba625fc09c"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
dependencies = [
"anyhow",
"collab",
@@ -1122,7 +1122,7 @@ dependencies = [
[[package]]
name = "collab-folder"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=06e942#06e942cb6433c94b5ecfe1d431b64bba625fc09c"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
dependencies = [
"anyhow",
"chrono",
@@ -1142,7 +1142,7 @@ dependencies = [
[[package]]
name = "collab-persistence"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=06e942#06e942cb6433c94b5ecfe1d431b64bba625fc09c"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
dependencies = [
"bincode",
"chrono",
@@ -1162,7 +1162,7 @@ dependencies = [
[[package]]
name = "collab-plugins"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=06e942#06e942cb6433c94b5ecfe1d431b64bba625fc09c"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
dependencies = [
"anyhow",
"async-trait",
@@ -1193,7 +1193,7 @@ dependencies = [
[[package]]
name = "collab-sync"
version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=06e942#06e942cb6433c94b5ecfe1d431b64bba625fc09c"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
dependencies = [
"bytes",
"collab",
@@ -1292,21 +1292,20 @@ dependencies = [
[[package]]
name = "core-graphics-types"
version = "0.1.1"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a68b68b3446082644c91ac778bf50cd4104bfb002b5a6a7c44cca5a2c70788b"
checksum = "2bb142d41022986c1d8ff29103a1411c8a3dfad3552f87a4f8dc50d61d4f4e33"
dependencies = [
"bitflags",
"core-foundation",
"foreign-types",
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.2.7"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e4c1eaa2012c47becbbad2ab175484c2a84d1185b566fb2cc5b8707343dfe58"
checksum = "03e69e28e9f7f77debdedbaafa2866e1de9ba56df55a8bd7cfc724c25a09987c"
dependencies = [
"libc",
]
@@ -1397,7 +1396,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn 2.0.18",
"syn 2.0.22",
]
[[package]]
@@ -1452,7 +1451,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
"syn 2.0.18",
"syn 2.0.22",
]
[[package]]
@@ -1463,7 +1462,7 @@ checksum = "29a358ff9f12ec09c3e61fef9b5a9902623a695a46a917b07f269bff1445611a"
dependencies = [
"darling_core",
"quote",
"syn 2.0.18",
"syn 2.0.22",
]
[[package]]
@@ -1629,7 +1628,7 @@ checksum = "80663502655af01a2902dff3f06869330782267924bf1788410b74edcd93770a"
dependencies = [
"cc",
"rustc_version",
"toml 0.7.4",
"toml 0.7.5",
"vswhom",
"winreg 0.11.0",
]
@@ -1655,6 +1654,12 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "equivalent"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88bffebc5d80432c9b140ee17875ff173a8ab62faad5b257da912bd2f6c1c0a1"
[[package]]
name = "errno"
version = "0.3.1"
@@ -1868,7 +1873,7 @@ dependencies = [
"flowy-notification",
"flowy-task",
"futures",
"indexmap",
"indexmap 1.9.3",
"lazy_static",
"lib-dispatch",
"lib-infra",
@@ -1916,7 +1921,7 @@ dependencies = [
"flowy-derive",
"flowy-error",
"flowy-notification",
"indexmap",
"indexmap 1.9.3",
"lib-dispatch",
"nanoid",
"parking_lot 0.12.1",
@@ -2213,7 +2218,7 @@ checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.18",
"syn 2.0.22",
]
[[package]]
@@ -2294,7 +2299,7 @@ dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps 6.1.0",
"system-deps 6.1.1",
]
[[package]]
@@ -2311,7 +2316,7 @@ dependencies = [
"libc",
"pango-sys",
"pkg-config",
"system-deps 6.1.0",
"system-deps 6.1.1",
]
[[package]]
@@ -2325,7 +2330,7 @@ dependencies = [
"gobject-sys",
"libc",
"pkg-config",
"system-deps 6.1.0",
"system-deps 6.1.1",
]
[[package]]
@@ -2337,15 +2342,15 @@ dependencies = [
"gdk-sys",
"glib-sys",
"libc",
"system-deps 6.1.0",
"system-deps 6.1.1",
"x11",
]
[[package]]
name = "generator"
version = "0.7.4"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3e123d9ae7c02966b4d892e550bdc32164f05853cd40ab570650ad600596a8a"
checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e"
dependencies = [
"cc",
"libc",
@@ -2400,9 +2405,9 @@ dependencies = [
[[package]]
name = "gimli"
version = "0.27.2"
version = "0.27.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad0a93d233ebf96623465aad4046a8d3aa4da22d4f4beba5388838c8a434bbb4"
checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e"
[[package]]
name = "gio"
@@ -2430,7 +2435,7 @@ dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps 6.1.0",
"system-deps 6.1.1",
"winapi",
]
@@ -2476,7 +2481,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef4b192f8e65e9cf76cbf4ea71fa8e3be4a0e18ffe3d68b8da6836974cc5bad4"
dependencies = [
"libc",
"system-deps 6.1.0",
"system-deps 6.1.1",
]
[[package]]
@@ -2517,7 +2522,7 @@ checksum = "0d57ce44246becd17153bd035ab4d32cfee096a657fc01f2231c9278378d1e0a"
dependencies = [
"glib-sys",
"libc",
"system-deps 6.1.0",
"system-deps 6.1.1",
]
[[package]]
@@ -2558,7 +2563,7 @@ dependencies = [
"gobject-sys",
"libc",
"pango-sys",
"system-deps 6.1.0",
"system-deps 6.1.1",
]
[[package]]
@@ -2577,9 +2582,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.3.19"
version = "0.3.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d357c7ae988e7d2182f7d7871d0b963962420b0678b0997ce7de72001aeab782"
checksum = "97ec8491ebaf99c8eaa73058b045fe58073cd6be7f596ac993ced0b0a0c01049"
dependencies = [
"bytes",
"fnv",
@@ -2587,7 +2592,7 @@ dependencies = [
"futures-sink",
"futures-util",
"http",
"indexmap",
"indexmap 1.9.3",
"slab",
"tokio",
"tokio-util",
@@ -2612,6 +2617,12 @@ dependencies = [
"ahash 0.8.3",
]
[[package]]
name = "hashbrown"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a"
[[package]]
name = "heck"
version = "0.3.3"
@@ -2732,9 +2743,9 @@ dependencies = [
[[package]]
name = "hyper"
version = "0.14.26"
version = "0.14.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab302d72a6f11a3b910431ff93aae7e773078c769f0a3ef15fb9ec692ed147d4"
checksum = "ffb1cfd654a8219eaef89881fdb3bb3b1cdc5fa75ded05d6933b2b382e395468"
dependencies = [
"bytes",
"futures-channel",
@@ -2834,17 +2845,6 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8"
dependencies = [
"matches",
"unicode-bidi",
"unicode-normalization",
]
[[package]]
name = "idna"
version = "0.4.0"
@@ -2896,6 +2896,16 @@ dependencies = [
"serde",
]
[[package]]
name = "indexmap"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d"
dependencies = [
"equivalent",
"hashbrown 0.14.0",
]
[[package]]
name = "infer"
version = "0.12.0"
@@ -2927,9 +2937,9 @@ dependencies = [
[[package]]
name = "ipnet"
version = "2.7.2"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12b6ee2129af8d4fb011108c73d99a1b83a85977f23b82460c0ae2e25bb4b57f"
checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6"
[[package]]
name = "itertools"
@@ -3145,9 +3155,9 @@ dependencies = [
[[package]]
name = "lib0"
version = "0.16.5"
version = "0.16.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf23122cb1c970b77ea6030eac5e328669415b65d2ab245c99bfb110f9d62dc"
checksum = "49d27ae71668a38ad135d463703ce0c5d9cf5a29f9a02add7a0dac6ebb523196"
dependencies = [
"serde",
"serde_json",
@@ -3156,9 +3166,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.146"
version = "0.2.147"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f92be4933c13fd498862a9e02a3055f8a8d9c039ce33db97306fd5a6caa7f29b"
checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3"
[[package]]
name = "libloading"
@@ -3631,9 +3641,9 @@ dependencies = [
[[package]]
name = "openssl"
version = "0.10.54"
version = "0.10.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69b3f656a17a6cbc115b5c7a40c616947d213ba182135b014d6051b73ab6f019"
checksum = "345df152bc43501c5eb9e4654ff05f794effb78d4efe3d53abc158baddc0703d"
dependencies = [
"bitflags",
"cfg-if",
@@ -3652,7 +3662,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.18",
"syn 2.0.22",
]
[[package]]
@@ -3663,9 +3673,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "openssl-sys"
version = "0.9.88"
version = "0.9.90"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2ce0f250f34a308dcfdbb351f511359857d4ed2134ba715a4eadd46e1ffd617"
checksum = "374533b0e45f3a7ced10fcaeccca020e66656bc03dac384f852e4e5a7a8104a6"
dependencies = [
"cc",
"libc",
@@ -3717,7 +3727,7 @@ dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps 6.1.0",
"system-deps 6.1.1",
]
[[package]]
@@ -3797,9 +3807,9 @@ checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94"
[[package]]
name = "pest"
version = "2.6.0"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e68e84bfb01f0507134eac1e9b410a12ba379d064eab48c50ba4ce329a527b70"
checksum = "f73935e4d55e2abf7f130186537b19e7a4abc886a0252380b59248af473a3fc9"
dependencies = [
"thiserror",
"ucd-trie",
@@ -3807,9 +3817,9 @@ dependencies = [
[[package]]
name = "pest_derive"
version = "2.6.0"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b79d4c71c865a25a4322296122e3924d30bc8ee0834c8bfc8b95f7f054afbfb"
checksum = "aef623c9bbfa0eedf5a0efba11a5ee83209c326653ca31ff019bec3a95bfff2b"
dependencies = [
"pest",
"pest_generator",
@@ -3817,22 +3827,22 @@ dependencies = [
[[package]]
name = "pest_generator"
version = "2.6.0"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c435bf1076437b851ebc8edc3a18442796b30f1728ffea6262d59bbe28b077e"
checksum = "b3e8cba4ec22bada7fc55ffe51e2deb6a0e0db2d0b7ab0b103acc80d2510c190"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.18",
"syn 2.0.22",
]
[[package]]
name = "pest_meta"
version = "2.6.0"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "745a452f8eb71e39ffd8ee32b3c5f51d03845f99786fa9b68db6ff509c505411"
checksum = "a01f71cb40bd8bb94232df14b946909e14660e33fc05db3e50ae2a82d7ea0ca0"
dependencies = [
"once_cell",
"pest",
@@ -3863,11 +3873,11 @@ dependencies = [
[[package]]
name = "phf"
version = "0.11.1"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928c6535de93548188ef63bb7c4036bd415cd8f36ad25af44b9789b2ee72a48c"
checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc"
dependencies = [
"phf_shared 0.11.1",
"phf_shared 0.11.2",
]
[[package]]
@@ -3892,12 +3902,12 @@ dependencies = [
[[package]]
name = "phf_codegen"
version = "0.11.1"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a56ac890c5e3ca598bbdeaa99964edb5b0258a583a9eb6ef4e89fc85d9224770"
checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a"
dependencies = [
"phf_generator 0.11.1",
"phf_shared 0.11.1",
"phf_generator 0.11.2",
"phf_shared 0.11.2",
]
[[package]]
@@ -3922,11 +3932,11 @@ dependencies = [
[[package]]
name = "phf_generator"
version = "0.11.1"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1181c94580fa345f50f19d738aaa39c0ed30a600d95cb2d3e23f94266f14fbf"
checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0"
dependencies = [
"phf_shared 0.11.1",
"phf_shared 0.11.2",
"rand 0.8.5",
]
@@ -3979,9 +3989,9 @@ dependencies = [
[[package]]
name = "phf_shared"
version = "0.11.1"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1fb5f6f826b772a8d4c0394209441e7d37cbbb967ae9c7e0e8134365c9ee676"
checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b"
dependencies = [
"siphasher",
]
@@ -4003,7 +4013,7 @@ checksum = "39407670928234ebc5e6e580247dd567ad73a3578460c5990f9503df207e8f07"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.18",
"syn 2.0.22",
]
[[package]]
@@ -4031,7 +4041,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bd9647b268a3d3e14ff09c23201133a62589c658db02bb7388c7246aafe0590"
dependencies = [
"base64 0.21.2",
"indexmap",
"indexmap 1.9.3",
"line-wrap",
"quick-xml",
"serde",
@@ -4074,12 +4084,12 @@ checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "prettyplease"
version = "0.2.6"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b69d39aab54d069e7f2fe8cb970493e7834601ca2d8c65fd7bbd183578080d1"
checksum = "9825a04601d60621feed79c4e6b56d65db77cdca55cef43b46b0de1096d1c282"
dependencies = [
"proc-macro2",
"syn 2.0.18",
"syn 2.0.22",
]
[[package]]
@@ -4133,9 +4143,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068"
[[package]]
name = "proc-macro2"
version = "1.0.60"
version = "1.0.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec2b086b7a862cf4de201096214fa870344cf922b2b30c167badb3af3195406"
checksum = "7b368fba921b0dce7e60f5e04ec15e565b3303972b42bcfde1d0713b881959eb"
dependencies = [
"unicode-ident",
]
@@ -4582,11 +4592,11 @@ dependencies = [
[[package]]
name = "rust_decimal"
version = "1.29.1"
version = "1.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26bd36b60561ee1fb5ec2817f198b6fd09fa571c897a5e86d1487cfc2b096dfc"
checksum = "d0446843641c69436765a35a5a77088e28c2e6a12da93e84aa3ab1cd4aa5a042"
dependencies = [
"arrayvec 0.7.3",
"arrayvec 0.7.4",
"borsh",
"bytecheck",
"byteorder",
@@ -4600,9 +4610,9 @@ dependencies = [
[[package]]
name = "rust_decimal_macros"
version = "1.29.1"
version = "1.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e773fd3da1ed42472fdf3cfdb4972948a555bc3d73f5e0bdb99d17e7b54c687"
checksum = "7ca5c398d85f83b9a44de754a2048625a8c5eafcf070da7b8f116b685e2f6608"
dependencies = [
"quote",
"rust_decimal",
@@ -4861,14 +4871,14 @@ checksum = "d9735b638ccc51c28bf6914d90a2e9725b377144fc612c49a611fddd1b631d68"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.18",
"syn 2.0.22",
]
[[package]]
name = "serde_json"
version = "1.0.96"
version = "1.0.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1"
checksum = "46266871c240a00b8f503b877622fe33430b3c7d963bdc0f2adc511e54a1eae3"
dependencies = [
"itoa 1.0.6",
"ryu",
@@ -4883,14 +4893,14 @@ checksum = "bcec881020c684085e55a25f7fd888954d56609ef363479dc5a1305eb0d40cab"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.18",
"syn 2.0.22",
]
[[package]]
name = "serde_spanned"
version = "0.6.2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93107647184f6027e3b7dcb2e11034cf95ffa1e3a682c67951963ac69c1c007d"
checksum = "96426c9936fd7a0124915f9185ea1d20aa9445cc9821142f0a73bc9207a2e186"
dependencies = [
"serde",
]
@@ -4916,7 +4926,7 @@ dependencies = [
"base64 0.21.2",
"chrono",
"hex",
"indexmap",
"indexmap 1.9.3",
"serde",
"serde_json",
"serde_with_macros",
@@ -4932,7 +4942,7 @@ dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.18",
"syn 2.0.22",
]
[[package]]
@@ -4999,9 +5009,9 @@ checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012"
[[package]]
name = "sha2"
version = "0.10.6"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82e6b795fe2e3b1e845bafcb27aa35405c4d47cdfc92af5fc8d3002f76cebdc0"
checksum = "479fb9d862239e610720565ca91403019f2f00410f1864c5aa7479b950a76ed8"
dependencies = [
"cfg-if",
"cpufeatures",
@@ -5245,9 +5255,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.18"
version = "2.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e"
checksum = "2efbeae7acf4eabd6bcdcbd11c92f45231ddda7539edc7806bd1a04a03b24616"
dependencies = [
"proc-macro2",
"quote",
@@ -5269,14 +5279,14 @@ dependencies = [
[[package]]
name = "system-deps"
version = "6.1.0"
version = "6.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5fa6fb9ee296c0dc2df41a656ca7948546d061958115ddb0bcaae43ad0d17d2"
checksum = "30c2de8a4d8f4b823d634affc9cd2a74ec98c53a756f317e529a48046cbf71f3"
dependencies = [
"cfg-expr 0.15.2",
"cfg-expr 0.15.3",
"heck 0.4.1",
"pkg-config",
"toml 0.7.4",
"toml 0.7.5",
"version-compare 0.1.1",
]
@@ -5357,15 +5367,15 @@ dependencies = [
[[package]]
name = "target-lexicon"
version = "0.12.7"
version = "0.12.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd1ba337640d60c3e96bc6f0638a939b9c9a7f2c316a1598c279828b3d1dc8c5"
checksum = "1b1c7f239eb94671427157bd93b3694320f3668d4e1eff08c7285366fd777fac"
[[package]]
name = "tauri"
version = "1.4.0"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc35893c7e08d9564a9206bd52182dce031b0d5132dc946b3e166e00d03f8cfe"
checksum = "7fbe522898e35407a8e60dc3870f7579fea2fc262a6a6072eccdd37ae1e1d91e"
dependencies = [
"anyhow",
"cocoa",
@@ -5542,7 +5552,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5993dc129e544393574288923d1ec447c857f3f644187f4fbf7d9a875fbfc4fb"
dependencies = [
"embed-resource",
"toml 0.7.4",
"toml 0.7.5",
]
[[package]]
@@ -5626,7 +5636,7 @@ checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.18",
"syn 2.0.22",
]
[[package]]
@@ -5729,7 +5739,7 @@ checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.18",
"syn 2.0.22",
]
[[package]]
@@ -5836,9 +5846,9 @@ dependencies = [
[[package]]
name = "toml"
version = "0.7.4"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6135d499e69981f9ff0ef2167955a5333c35e36f6937d382974566b3d5b94ec"
checksum = "1ebafdf5ad1220cb59e7d17cf4d2c72015297b75b19a10472f99b89225089240"
dependencies = [
"serde",
"serde_spanned",
@@ -5848,20 +5858,20 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "0.6.2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a76a9312f5ba4c2dec6b9161fdf25d87ad8a09256ccea5a556fef03c706a10f"
checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.19.10"
version = "0.19.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2380d56e8670370eee6566b0bfd4265f65b3f432e8c6d85623f728d4fa31f739"
checksum = "266f016b7f039eec8a1a80dfe6156b633d208b9fccca5e4db1d6775b0c4e34a7"
dependencies = [
"indexmap",
"indexmap 2.0.0",
"serde",
"serde_spanned",
"toml_datetime",
@@ -5922,13 +5932,13 @@ dependencies = [
[[package]]
name = "tracing-attributes"
version = "0.1.24"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f57e3ca2a01450b1a921183a9c9cbfda207fd822cef4ccb00a65402cbba7a74"
checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.18",
"syn 2.0.22",
]
[[package]]
@@ -6189,7 +6199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50bff7831e19200a85b17131d085c25d7811bc4e186efdaf54bbd132994a88cb"
dependencies = [
"form_urlencoded",
"idna 0.4.0",
"idna",
"percent-encoding",
"serde",
]
@@ -6218,11 +6228,11 @@ dependencies = [
[[package]]
name = "validator"
version = "0.16.0"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32ad5bf234c7d3ad1042e5252b7eddb2c4669ee23f32c7dd0e9b7705f07ef591"
checksum = "b92f40481c04ff1f4f61f304d61793c7b56ff76ac1469f1beb199b1445b253bd"
dependencies = [
"idna 0.2.3",
"idna",
"lazy_static",
"regex",
"serde",
@@ -6345,7 +6355,7 @@ dependencies = [
"once_cell",
"proc-macro2",
"quote",
"syn 2.0.18",
"syn 2.0.22",
"wasm-bindgen-shared",
]
@@ -6379,7 +6389,7 @@ checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.18",
"syn 2.0.22",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
@@ -6444,7 +6454,7 @@ dependencies = [
"pango-sys",
"pkg-config",
"soup2-sys",
"system-deps 6.1.0",
"system-deps 6.1.1",
]
[[package]]
@@ -6887,9 +6897,9 @@ dependencies = [
[[package]]
name = "yrs"
version = "0.16.5"
version = "0.16.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c2aef2bf89b4f7c003f9c73f1c8097427ca32e1d006443f3f607f11e79a797b"
checksum = "04e5192da97bd1621497ddf66b42475fb0cc44b6ebcf64510f0d3827c0b15cad"
dependencies = [
"atomic_refcell",
"lib0",
+6 -6
View File
@@ -34,12 +34,12 @@ default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
[patch.crates-io]
collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "06e942" }
collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "06e942" }
collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "06e942" }
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "06e942" }
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "06e942" }
appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "06e942" }
collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d1882d" }
collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d1882d" }
collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d1882d" }
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d1882d" }
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d1882d" }
appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d1882d" }
#collab = { path = "../../AppFlowy-Collab/collab" }
#collab-folder = { path = "../../AppFlowy-Collab/collab-folder" }
@@ -11,8 +11,13 @@ import {
import { useRangeKeyDown } from '$app/components/document/BlockSelection/RangeKeyDown.hooks';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
import { isApple } from '$app/utils/env';
const onFrameTime = 1000 / 60;
export function useBlockRangeSelection(container: HTMLDivElement) {
const timeStampRef = useRef(0);
const dispatch = useAppDispatch();
const onKeyDown = useRangeKeyDown();
const { docId } = useSubscribeDocument();
@@ -36,10 +41,12 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
useEffect(() => {
if (!range) return;
const { anchor, focus } = range;
if (!anchor || !focus) {
container.classList.remove('caret-transparent');
return;
}
// if the focus block is different from the anchor block, we need to set the caret transparent
if (focus.id !== anchor.id) {
container.classList.add('caret-transparent');
@@ -50,18 +57,21 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
useEffect(() => {
const anchor = anchorRef.current;
if (!anchor || !focus) return;
const selection = window.getSelection();
if (!selection) return;
// update focus point
dispatch(
rangeActions.setFocusPoint({
...focus,
focusPoint: focus,
docId,
})
);
const focused = isFocused(focus.id);
// if the focus block is not focused, we need to set the cursor position
if (!focused) {
// if the focus block is the same as the anchor block, we just update the anchor's range
@@ -70,14 +80,17 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
anchor.point.x - container.scrollLeft,
anchor.point.y - container.scrollTop
);
if (!range) return;
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
return;
}
const node = getNodeTextBoxByBlockId(focus.id);
if (!node) return;
// if the selection is forward, we set the cursor position to the start of the focus block
if (isForward) {
@@ -89,15 +102,33 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
}
}, [container, dispatch, docId, focus, isForward]);
const handleDragStart = useCallback(
const handleDragEnd = useCallback(() => {
timeStampRef.current = Date.now();
if (!isDragging) return;
setFocus(null);
anchorRef.current = null;
dispatch(
rangeActions.setDragging({
isDragging: false,
docId,
})
);
}, [docId, dispatch, isDragging]);
const handleMouseDown = useCallback(
(e: MouseEvent) => {
setForward(true);
const isTapToClick = isApple() && timeStampRef.current > 0 && Date.now() - timeStampRef.current < onFrameTime;
// skip if the target is not a block
const blockId = getBlockIdByPoint(e.target as HTMLElement);
if (!blockId) {
dispatch(rangeActions.initialState(docId));
return;
}
setForward(true);
dispatch(rangeActions.clearRanges({ docId, exclude: blockId }));
const startX = e.clientX + container.scrollLeft;
const startY = e.clientY + container.scrollTop;
@@ -113,18 +144,25 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
anchorRef.current = {
...anchor,
};
// set the anchor point and focus point
dispatch(rangeActions.setAnchorPoint({ ...anchor, docId }));
dispatch(rangeActions.setFocusPoint({ ...anchor, docId }));
dispatch(rangeActions.setAnchorPoint({ docId, anchorPoint: anchor }));
dispatch(rangeActions.setFocusPoint({ docId, focusPoint: anchor }));
// This is a workaround for a bug in Safari where the mouseup event is not fired
if (isTapToClick) {
handleDragEnd();
return;
}
dispatch(
rangeActions.setDragging({
isDragging: true,
docId,
})
);
return;
},
[container.scrollLeft, container.scrollTop, dispatch, docId]
[container.scrollLeft, container.scrollTop, dispatch, docId, handleDragEnd]
);
const handleDraging = useCallback(
@@ -133,12 +171,14 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
// skip if the target is not a block
const blockId = getBlockIdByPoint(e.target as HTMLElement);
if (!blockId) {
return;
}
const endX = e.clientX + container.scrollLeft;
const endY = e.clientY + container.scrollTop;
// set the focus point
setFocus({
id: blockId,
@@ -149,42 +189,35 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
});
// set forward
const anchorId = anchorRef.current.id;
if (anchorId === blockId) {
const startX = anchorRef.current.point.x;
setForward(startX < endX);
return;
}
const startY = anchorRef.current.point.y;
setForward(startY < endY);
},
[container.scrollLeft, container.scrollTop, isDragging]
);
const handleDragEnd = useCallback(() => {
if (!isDragging) return;
setFocus(null);
anchorRef.current = null;
dispatch(
rangeActions.setDragging({
isDragging: false,
docId,
})
);
}, [docId, dispatch, isDragging]);
useEffect(() => {
document.addEventListener('mousedown', handleDragStart);
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('mousemove', handleDraging);
document.addEventListener('mouseup', handleDragEnd);
container.addEventListener('keydown', onKeyDown, true);
return () => {
document.removeEventListener('mousedown', handleDragStart);
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('mousemove', handleDraging);
document.removeEventListener('mouseup', handleDragEnd);
container.removeEventListener('keydown', onKeyDown, true);
};
}, [handleDragStart, handleDragEnd, handleDraging, container, onKeyDown]);
}, [handleMouseDown, handleDragEnd, handleDraging, container, onKeyDown]);
return null;
}
@@ -6,6 +6,8 @@ import BlockMenuTurnInto from '$app/components/document/BlockSideToolbar/BlockMe
import TextField from '@mui/material/TextField';
import { Keyboard } from '$app/constants/document/keyboard';
import { selectOptionByUpDown } from '$app/utils/document/menu';
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
import { BlockType } from '$app/interfaces/document';
enum BlockMenuOption {
Duplicate = 'Duplicate',
@@ -22,6 +24,7 @@ interface Option {
function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
const { handleDelete, handleDuplicate } = useBlockMenu(id);
const { node } = useSubscribeNode(id);
const [subMenuOpened, setSubMenuOpened] = useState(false);
const [hovered, setHovered] = useState<BlockMenuOption | null>(null);
@@ -39,29 +42,36 @@ function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
[onClose]
);
const excludeTurnIntoBlock = useMemo(() => {
return [BlockType.DividerBlock].includes(node.type);
}, [node.type]);
const options: Option[] = useMemo(
() => [
{
operate: () => {
return handleClick({ operate: handleDelete });
() =>
[
{
operate: () => {
return handleClick({ operate: handleDelete });
},
title: 'Delete',
icon: <Delete />,
key: BlockMenuOption.Delete,
},
title: 'Delete',
icon: <Delete />,
key: BlockMenuOption.Delete,
},
{
operate: () => {
return handleClick({ operate: handleDuplicate });
{
operate: () => {
return handleClick({ operate: handleDuplicate });
},
title: 'Duplicate',
icon: <ContentCopy />,
key: BlockMenuOption.Duplicate,
},
title: 'Duplicate',
icon: <ContentCopy />,
key: BlockMenuOption.Duplicate,
},
{
key: BlockMenuOption.TurnInto,
},
],
[handleClick, handleDelete, handleDuplicate]
excludeTurnIntoBlock
? null
: {
key: BlockMenuOption.TurnInto,
},
].filter((item) => item !== null) as Option[],
[excludeTurnIntoBlock, handleClick, handleDelete, handleDuplicate]
);
const onKeyDown = useCallback(
@@ -131,7 +141,10 @@ function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
}}
menuOpened={subMenuOpened}
isHovered={hovered === BlockMenuOption.TurnInto}
onClose={() => setSubMenuOpened(false)}
onClose={() => {
setSubMenuOpened(false);
onClose();
}}
id={id}
/>
);
@@ -1,4 +1,4 @@
import React, { MouseEvent, useRef } from 'react';
import React, { MouseEvent, useEffect, useRef } from 'react';
import { ArrowRight, Transform } from '@mui/icons-material';
import MenuItem from '$app/components/document/_shared/MenuItem';
import TurnIntoPopover from '$app/components/document/_shared/TurnInto';
@@ -17,8 +17,22 @@ function BlockMenuTurnInto({
menuOpened: boolean;
}) {
const ref = useRef<HTMLDivElement | null>(null);
const open = isHovered && menuOpened && Boolean(ref.current);
const [anchorPosition, setAnchorPosition] = React.useState<{ top: number; left: number }>();
const open = Boolean(anchorPosition);
useEffect(() => {
if (isHovered && menuOpened) {
const rect = ref.current?.getBoundingClientRect();
if (!rect) return;
setAnchorPosition({
top: rect.top + rect.height / 2,
left: rect.left + rect.width,
});
} else {
setAnchorPosition(undefined);
}
}, [isHovered, menuOpened]);
return (
<>
<MenuItem
@@ -45,11 +59,8 @@ function BlockMenuTurnInto({
},
}}
onClose={onClose}
anchorEl={ref.current}
anchorOrigin={{
vertical: 'center',
horizontal: 'right',
}}
anchorReference={'anchorPosition'}
anchorPosition={anchorPosition}
transformOrigin={{
vertical: 'center',
horizontal: 'left',
@@ -40,6 +40,10 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement }
top = headingBlockTopOffset[nodeData.level];
}
if (node.type === BlockType.DividerBlock) {
top = -3;
}
setStyle({
opacity: '1',
pointerEvents: 'auto',
@@ -99,39 +103,47 @@ function getNodeIdByPoint(x: number, y: number) {
: null;
}
const origin: {
anchorOrigin: PopoverOrigin;
transformOrigin: PopoverOrigin;
} = {
anchorOrigin: {
vertical: 'bottom',
horizontal: 'right',
},
transformOrigin: {
vertical: 'bottom',
horizontal: 'left',
},
const transformOrigin: PopoverOrigin = {
vertical: 'bottom',
horizontal: 'left',
};
export function usePopover() {
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
const [anchorPosition, setAnchorPosition] = React.useState<{
top: number;
left: number;
}>();
const onClose = useCallback(() => {
setAnchorEl(null);
setAnchorPosition(undefined);
}, []);
const handleOpen = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setAnchorEl(e.currentTarget);
const rect = e.currentTarget.getBoundingClientRect();
setAnchorPosition({
top: rect.top + rect.height,
left: rect.left + rect.width,
});
}, []);
const open = Boolean(anchorEl);
const open = Boolean(anchorPosition);
const onMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
}, []);
return {
anchorEl,
anchorPosition,
onClose,
open,
handleOpen,
...origin,
anchorReference: 'anchorPosition' as const,
transformOrigin,
onMouseDown,
disableRestoreFocus: true,
disableAutoFocus: true,
disableEnforceFocus: true,
};
}
@@ -10,6 +10,8 @@ import ToolbarButton from './ToolbarButton';
import { rectSelectionActions } from '$app_reducers/document/slice';
import { addBlockBelowClickThunk } from '$app_reducers/document/async-actions/menu';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { RANGE_NAME, RECT_RANGE_NAME } from '$app/constants/document/name';
import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection';
export default function BlockSideToolbar({ container }: { container: HTMLDivElement }) {
const dispatch = useAppDispatch();
@@ -17,13 +19,10 @@ export default function BlockSideToolbar({ container }: { container: HTMLDivElem
const { nodeId, style, ref } = useBlockSideToolbar({ container });
const isDragging = useAppSelector(
(state) => state.documentRange[docId]?.isDragging || state.documentRectSelection[docId]?.isDragging
(state) => state[RANGE_NAME][docId]?.isDragging || state[RECT_RANGE_NAME][docId]?.isDragging
);
const { handleOpen, ...popoverProps } = usePopover();
// prevent popover from showing when anchorEl is not in DOM
const showPopover = popoverProps.anchorEl ? document.contains(popoverProps.anchorEl) : true;
if (!nodeId || isDragging) return null;
return (
@@ -64,11 +63,12 @@ export default function BlockSideToolbar({ container }: { container: HTMLDivElem
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
if (!nodeId) return;
dispatch(
rectSelectionActions.setSelectionById({
setRectSelectionThunk({
docId,
blockId: nodeId,
selection: [nodeId],
})
);
handleOpen(e);
}}
>
@@ -77,11 +77,9 @@ export default function BlockSideToolbar({ container }: { container: HTMLDivElem
</div>
</Portal>
{showPopover && (
<Popover {...popoverProps}>
<BlockMenu id={nodeId} onClose={popoverProps.onClose} />
</Popover>
)}
<Popover {...popoverProps}>
<BlockMenu id={nodeId} onClose={popoverProps.onClose} />
</Popover>
</>
);
}
@@ -11,7 +11,10 @@ export function useBlockSlash() {
const { docId } = useSubscribeDocument();
const { blockId, visible, slashText, hoverOption } = useSubscribeSlash();
const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
const [anchorPosition, setAnchorPosition] = React.useState<{
top: number;
left: number;
}>();
useEffect(() => {
if (blockId && visible) {
@@ -19,11 +22,17 @@ export function useBlockSlash() {
const el = blockEl.querySelector(`[role="textbox"]`) as HTMLElement;
if (el) {
setAnchorEl(el);
const rect = el.getBoundingClientRect();
setAnchorPosition({
top: rect.top + rect.height,
left: rect.left,
});
return;
}
}
setAnchorEl(null);
setAnchorPosition(undefined);
}, [blockId, visible]);
useEffect(() => {
@@ -43,11 +52,11 @@ export function useBlockSlash() {
dispatch(slashCommandActions.closeSlashCommand(docId));
}, [dispatch, docId]);
const open = Boolean(anchorEl);
const open = Boolean(anchorPosition);
return {
open,
anchorEl,
anchorPosition,
onClose,
blockId,
searchText,
@@ -5,18 +5,15 @@ import { useBlockSlash } from '$app/components/document/BlockSlash/index.hooks';
import { Keyboard } from '$app/constants/document/keyboard';
function BlockSlash({ container }: { container: HTMLDivElement }) {
const { blockId, open, onClose, anchorEl, searchText, hoverOption } = useBlockSlash();
const { blockId, open, onClose, anchorPosition, searchText, hoverOption } = useBlockSlash();
if (!blockId) return null;
return (
<Popover
open={open}
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
anchorReference={'anchorPosition'}
anchorPosition={anchorPosition}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
@@ -0,0 +1,54 @@
import React from 'react';
import { BlockType, NestedBlock } from '$app/interfaces/document';
import KatexMath from '$app/components/document/_shared/KatexMath';
import Popover from '@mui/material/Popover';
import EquationEditContent from '$app/components/document/_shared/TemporaryInput/EquationEditContent';
import { useEquationBlock } from '$app/components/document/EquationBlock/useEquationBlock';
import { Functions } from '@mui/icons-material';
function EquationBlock({ node }: { node: NestedBlock<BlockType.EquationBlock> }) {
const { ref, value, onChange, onOpenPopover, open, anchorPosition, onConfirm, onClosePopover } =
useEquationBlock(node);
const formula = open ? value : node.data.formula;
return (
<>
<div
ref={ref}
onClick={onOpenPopover}
className={'flex min-h-[59px] cursor-pointer items-center justify-center overflow-hidden hover:bg-main-selector'}
>
{formula ? (
<KatexMath latex={formula} />
) : (
<span className={'flex text-shade-2'}>
<Functions />
<span>Add a TeX equation</span>
</span>
)}
</div>
<Popover
transformOrigin={{
vertical: 'top',
horizontal: 'center',
}}
onMouseDown={(e) => e.stopPropagation()}
onClose={onClosePopover}
open={open}
anchorReference={'anchorPosition'}
anchorPosition={anchorPosition}
>
<EquationEditContent
placeholder={'c = \\pm\\sqrt{a^2 + b^2\\text{ if }a\\neq 0\\text{ or }b\\neq 0}'}
multiline={true}
value={value}
onChange={onChange}
onConfirm={onConfirm}
/>
</Popover>
</>
);
}
export default EquationBlock;
@@ -0,0 +1,71 @@
import { useCallback, useRef, useState } from 'react';
import { BlockType, NestedBlock } from '$app/interfaces/document';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { useAppDispatch } from '$app/stores/store';
import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
import { rectSelectionActions } from '$app_reducers/document/slice';
import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection';
export function useEquationBlock(node: NestedBlock<BlockType.EquationBlock>) {
const { controller, docId } = useSubscribeDocument();
const id = node.id;
const dispatch = useAppDispatch();
const formula = node.data.formula;
const ref = useRef<HTMLDivElement>(null);
const [value, setValue] = useState(formula);
const [anchorPosition, setAnchorPosition] = useState<{
top: number;
left: number;
}>();
const open = Boolean(anchorPosition);
const onChange = useCallback((newVal: string) => {
setValue(newVal);
}, []);
const onOpenPopover = useCallback(() => {
setValue(formula);
const rect = ref.current?.getBoundingClientRect();
if (!rect) return;
setAnchorPosition({
top: rect.top + rect.height,
left: rect.left + rect.width / 2,
});
}, [formula]);
const onClosePopover = useCallback(() => {
setAnchorPosition(undefined);
dispatch(
setRectSelectionThunk({
docId,
selection: [id],
})
);
}, [dispatch, id, docId]);
const onConfirm = useCallback(async () => {
await dispatch(
updateNodeDataThunk({
id,
data: {
formula: value,
},
controller,
})
);
onClosePopover();
}, [dispatch, id, value, controller, onClosePopover]);
return {
open,
ref,
value,
onChange,
onOpenPopover,
onClosePopover,
onConfirm,
anchorPosition,
};
}
@@ -17,6 +17,7 @@ import CalloutBlock from '$app/components/document/CalloutBlock';
import BlockOverlay from '$app/components/document/Overlay/BlockOverlay';
import CodeBlock from '$app/components/document/CodeBlock';
import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks';
import EquationBlock from '$app/components/document/EquationBlock';
function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) {
const { node, childIds, isSelected, ref } = useNode(id);
@@ -26,38 +27,50 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
case BlockType.TextBlock: {
return <TextBlock node={node} childIds={childIds} />;
}
case BlockType.HeadingBlock: {
return <HeadingBlock node={node} />;
}
case BlockType.TodoListBlock: {
return <TodoListBlock node={node} childIds={childIds} />;
}
case BlockType.QuoteBlock: {
return <QuoteBlock node={node} childIds={childIds} />;
}
case BlockType.BulletedListBlock: {
return <BulletedListBlock node={node} childIds={childIds} />;
}
case BlockType.NumberedListBlock: {
return <NumberedListBlock node={node} childIds={childIds} />;
}
case BlockType.ToggleListBlock: {
return <ToggleListBlock node={node} childIds={childIds} />;
}
case BlockType.DividerBlock: {
return <DividerBlock />;
}
case BlockType.CalloutBlock: {
return <CalloutBlock node={node} childIds={childIds} />;
}
case BlockType.CodeBlock:
return <CodeBlock node={node} />;
case BlockType.EquationBlock:
return <EquationBlock node={node} />;
default:
return <UnSupportedBlock />;
}
}, [node, childIds]);
const className = props.className ? ` ${props.className}` : '';
if (!node) return null;
return (
@@ -7,6 +7,7 @@ import { useCopy } from '$app/components/document/_shared/CopyPasteHooks/useCopy
import { usePaste } from '$app/components/document/_shared/CopyPasteHooks/usePaste';
import LinkEditPopover from '$app/components/document/_shared/TextLink/LinkEditPopover';
import { useUndoRedo } from '$app/components/document/_shared/UndoHooks/useUndoRedo';
import TemporaryPopover from '$app/components/document/_shared/TemporaryInput/TemporaryPopover';
export default function Overlay({ container }: { container: HTMLDivElement }) {
useCopy(container);
@@ -19,6 +20,7 @@ export default function Overlay({ container }: { container: HTMLDivElement }) {
<BlockSelection container={container} />
<BlockSlash container={container} />
<LinkEditPopover />
<TemporaryPopover />
</>
);
}
@@ -0,0 +1,31 @@
import { TextAction, TextActionMenuProps } from '$app/interfaces/document';
export const defaultTextActionItems = [
TextAction.Turn,
TextAction.Link,
TextAction.Bold,
TextAction.Italic,
TextAction.Underline,
TextAction.Strikethrough,
TextAction.Code,
TextAction.Equation,
];
const groupKeys = {
comment: [],
format: [
TextAction.Bold,
TextAction.Italic,
TextAction.Underline,
TextAction.Strikethrough,
TextAction.Code,
TextAction.Equation,
],
link: [TextAction.Link],
turn: [TextAction.Turn],
};
export const multiLineTextActionProps: TextActionMenuProps = {
customItems: [TextAction.Bold, TextAction.Italic, TextAction.Underline, TextAction.Strikethrough, TextAction.Code],
};
export const multiLineTextActionGroups = [groupKeys.format];
export const textActionGroups = [groupKeys.turn, groupKeys.format, groupKeys.link];
@@ -1,6 +1,5 @@
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { calcToolbarPosition } from '$app/utils/document/toolbar';
import { useAppSelector } from '$app/stores/store';
import { getNode } from '$app/utils/document/node';
import { debounce } from '$app/utils/tool';
import { useSubscribeCaret } from '$app/components/document/_shared/SubscribeSelection.hooks';
@@ -15,9 +14,11 @@ export function useMenuStyle(container: HTMLDivElement) {
const reCalculatePosition = useCallback(() => {
const el = ref.current;
if (!el || !id) return;
const node = getNode(id);
if (!node) return;
const position = calcToolbarPosition(el, node, container);
@@ -50,6 +51,7 @@ export function useMenuStyle(container: HTMLDivElement) {
setIsScrolling(true);
debounceScrollEnd();
};
container.addEventListener('scroll', handleScroll);
return () => {
debounceScrollEnd.cancel();
@@ -1,8 +1,11 @@
import { useMenuStyle } from './index.hooks';
import TextActionMenuList from '$app/components/document/TextActionMenu/menu';
import BlockPortal from '$app/components/document/BlockPortal';
import { useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
import { debounce } from '$app/utils/tool';
import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
const { ref, id } = useMenuStyle(container);
@@ -27,26 +30,57 @@ const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
</BlockPortal>
);
};
const TextActionMenu = ({ container }: { container: HTMLDivElement }) => {
const range = useSubscribeRanges();
const { docId } = useSubscribeDocument();
const [show, setShow] = useState(false);
const debounceShow = useMemo(() => {
return debounce(() => {
setShow(true);
}, 100);
}, []);
const canShow = useMemo(() => {
const { isDragging, focus, anchor, ranges, caret } = range;
// don't show if dragging
if (isDragging) return false;
// don't show if no focus or anchor
if (!caret) return false;
const isSameLine = anchor?.id === focus?.id;
if (!anchor || !focus) return false;
const anchorNode = getBlock(docId, anchor.id);
const focusNode = getBlock(docId, focus.id);
// include document title
if (!anchorNode.parent || !focusNode.parent) return false;
const isSameLine = anchor.id === focus.id;
// show toolbar if range has multiple nodes
if (!isSameLine) return true;
const caretRange = ranges[caret.id];
// don't show if no caret range
const caretRange = ranges?.[caret.id];
if (!caretRange) return false;
// show toolbar if range is not collapsed
return caretRange.length > 0;
}, [range]);
}, [docId, range]);
if (!canShow) return null;
useEffect(() => {
if (!canShow) {
debounceShow.cancel();
setShow(false);
return;
}
debounceShow();
}, [canShow, debounceShow]);
if (!show) return null;
return <TextActionComponent container={container} />;
};
@@ -1,37 +1,36 @@
import IconButton from '@mui/material/IconButton';
import FormatIcon from './FormatIcon';
import React, { useCallback, useEffect, useMemo, useContext } from 'react';
import { TextAction } from '$app/interfaces/document';
import React, { useCallback, useEffect, useMemo } from 'react';
import { TemporaryType, TextAction } from '$app/interfaces/document';
import MenuTooltip from '$app/components/document/TextActionMenu/menu/MenuTooltip';
import { getFormatActiveThunk, toggleFormatThunk } from '$app_reducers/document/async-actions/format';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
import { newLinkThunk } from '$app_reducers/document/async-actions/link';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { RANGE_NAME } from '$app/constants/document/name';
import { createTemporary } from '$app_reducers/document/async-actions/temporary';
import {
CodeOutlined,
FormatBold,
FormatItalic,
FormatUnderlined,
Functions,
StrikethroughSOutlined,
} from '@mui/icons-material';
import LinkIcon from '@mui/icons-material/AddLink';
export const iconSize = { width: 18, height: 18 };
const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) => {
const dispatch = useAppDispatch();
const { docId, controller } = useSubscribeDocument();
const focusId = useAppSelector((state) => state.documentRange[docId]?.focus?.id || '');
const focusId = useAppSelector((state) => state[RANGE_NAME][docId]?.focus?.id || '');
const { node: focusNode } = useSubscribeNode(focusId);
const [isActive, setIsActive] = React.useState(false);
const color = useMemo(() => (isActive ? '#00BCF0' : 'white'), [isActive]);
const formatTooltips: Record<string, string> = useMemo(
() => ({
[TextAction.Bold]: 'Bold',
[TextAction.Italic]: 'Italic',
[TextAction.Underline]: 'Underline',
[TextAction.Strikethrough]: 'Strike through',
[TextAction.Code]: 'Mark as Code',
[TextAction.Link]: 'Add Link',
}),
[]
);
const isFormatActive = useCallback(async () => {
if (!focusNode) return false;
const { payload: isActive } = await dispatch(
@@ -40,6 +39,7 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
docId,
})
);
return !!isActive;
}, [docId, dispatch, format, focusNode]);
@@ -65,6 +65,34 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
);
}, [dispatch, docId]);
const addTemporaryInput = useCallback(
(type: TemporaryType) => {
dispatch(createTemporary({ type, docId }));
},
[dispatch, docId]
);
useEffect(() => {
void (async () => {
const isActive = await isFormatActive();
setIsActive(isActive);
})();
}, [isFormatActive]);
const formatTooltips: Record<string, string> = useMemo(
() => ({
[TextAction.Bold]: 'Bold',
[TextAction.Italic]: 'Italic',
[TextAction.Underline]: 'Underline',
[TextAction.Strikethrough]: 'Strike through',
[TextAction.Code]: 'Mark as Code',
[TextAction.Link]: 'Add Link',
[TextAction.Equation]: 'Create equation',
}),
[]
);
const formatClick = useCallback(
(format: TextAction) => {
switch (format) {
@@ -76,22 +104,48 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
return toggleFormat(format);
case TextAction.Link:
return addLink();
case TextAction.Equation:
return addTemporaryInput(TemporaryType.Equation);
}
},
[addLink, toggleFormat]
[addLink, addTemporaryInput, toggleFormat]
);
useEffect(() => {
void (async () => {
const isActive = await isFormatActive();
setIsActive(isActive);
})();
}, [isFormatActive]);
const formatIcon = useMemo(() => {
switch (icon) {
case TextAction.Bold:
return <FormatBold sx={iconSize} />;
case TextAction.Underline:
return <FormatUnderlined sx={iconSize} />;
case TextAction.Italic:
return <FormatItalic sx={iconSize} />;
case TextAction.Code:
return <CodeOutlined sx={iconSize} />;
case TextAction.Strikethrough:
return <StrikethroughSOutlined sx={iconSize} />;
case TextAction.Link:
return (
<div className={'flex items-center justify-center px-1 text-[0.8rem]'}>
<LinkIcon
sx={{
fontSize: '1.2rem',
marginRight: '0.25rem',
}}
/>
<div className={'underline'}>Link</div>
</div>
);
case TextAction.Equation:
return <Functions sx={iconSize} />;
default:
return null;
}
}, [icon]);
return (
<MenuTooltip title={formatTooltips[format]}>
<IconButton size='small' sx={{ color }} onClick={() => formatClick(format)}>
<FormatIcon icon={icon} />
{formatIcon}
</IconButton>
</MenuTooltip>
);
@@ -1,34 +0,0 @@
import React from 'react';
import { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material';
import { TextAction } from '$app/interfaces/document';
import LinkIcon from '@mui/icons-material/AddLink';
export const iconSize = { width: 18, height: 18 };
export default function FormatIcon({ icon }: { icon: string }) {
switch (icon) {
case TextAction.Bold:
return <FormatBold sx={iconSize} />;
case TextAction.Underline:
return <FormatUnderlined sx={iconSize} />;
case TextAction.Italic:
return <FormatItalic sx={iconSize} />;
case TextAction.Code:
return <CodeOutlined sx={iconSize} />;
case TextAction.Strikethrough:
return <StrikethroughSOutlined sx={iconSize} />;
case TextAction.Link:
return (
<div className={'flex items-center justify-center px-1 text-[0.8rem]'}>
<LinkIcon
sx={{
fontSize: '1.2rem',
marginRight: '0.25rem',
}}
/>
<div className={'underline'}>Link</div>
</div>
);
default:
return null;
}
}
@@ -6,18 +6,26 @@ import MenuTooltip from './MenuTooltip';
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
function TurnIntoSelect({ id }: { id: string }) {
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
const [anchorPosition, setAnchorPosition] = React.useState<{
top: number;
left: number;
}>();
const { node } = useSubscribeNode(id);
const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
const rect = event.currentTarget.getBoundingClientRect();
setAnchorPosition({
top: rect.top + rect.height + 5,
left: rect.left,
});
}, []);
const handleClose = useCallback(() => {
setAnchorEl(null);
setAnchorPosition(undefined);
}, []);
const open = Boolean(anchorEl);
const open = Boolean(anchorPosition);
return (
<>
@@ -33,14 +41,11 @@ function TurnIntoSelect({ id }: { id: string }) {
id={id}
open={open}
onClose={handleClose}
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'center',
horizontal: 'center',
}}
anchorReference={'anchorPosition'}
anchorPosition={anchorPosition}
transformOrigin={{
vertical: 'center',
horizontal: 'center',
vertical: 'top',
horizontal: 'left',
}}
/>
</>
@@ -1,14 +1,13 @@
import { useMemo } from 'react';
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
import { BlockType, TextAction } from '$app/interfaces/document';
import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
import {
blockConfig,
defaultTextActionProps,
defaultTextActionItems,
multiLineTextActionGroups,
multiLineTextActionProps,
textActionGroups,
} from '$app/constants/document/config';
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
import { TextAction } from '$app/interfaces/document';
import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
} from '$app/components/document/TextActionMenu/config';
export function useTextActionMenu() {
const range = useSubscribeRanges();
@@ -22,12 +21,9 @@ export function useTextActionMenu() {
const items = useMemo(() => {
if (!node) return [];
if (isSingleLine) {
const config = blockConfig[node.type];
const { customItems, excludeItems } = {
...defaultTextActionProps,
...config.textActionMenuProps,
};
return customItems?.filter((item) => !excludeItems?.includes(item)) || [];
const excludeItems = node.type === BlockType.CodeBlock ? [TextAction.Code] : [];
return defaultTextActionItems?.filter((item) => !excludeItems?.includes(item)) || [];
} else {
return multiLineTextActionProps.customItems || [];
}
@@ -36,6 +32,7 @@ export function useTextActionMenu() {
// the groups have default items, so we need to filter the items if this node has excluded items
const groupItems: TextAction[][] = useMemo(() => {
const groups = node ? textActionGroups : multiLineTextActionGroups;
return groups.map((group) => {
return group.filter((item) => items.includes(item));
});
@@ -17,6 +17,7 @@ function TextActionMenuList() {
case TextAction.Underline:
case TextAction.Strikethrough:
case TextAction.Code:
case TextAction.Equation:
return <FormatButton format={action} icon={action} />;
default:
return null;
@@ -0,0 +1,64 @@
import { Keyboard } from '$app/constants/document/keyboard';
import { BlockType } from '$app/interfaces/document';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
export const turnIntoConfig: Record<
BlockType,
{
type: BlockType;
markdownRegexp: RegExp;
triggerKey: string;
}
> = {
[BlockType.HeadingBlock]: {
type: BlockType.HeadingBlock,
markdownRegexp: /^(#{1,3})(\s)+$/,
triggerKey: Keyboard.keys.SPACE,
},
[BlockType.TodoListBlock]: {
type: BlockType.TodoListBlock,
markdownRegexp: /^((-)?\[(x|\s)?\])(\s)+$/,
triggerKey: Keyboard.keys.SPACE,
},
[BlockType.BulletedListBlock]: {
type: BlockType.BulletedListBlock,
markdownRegexp: /^(\s*[-+*])(\s)+$/,
triggerKey: Keyboard.keys.SPACE,
},
[BlockType.NumberedListBlock]: {
type: BlockType.NumberedListBlock,
markdownRegexp: /^(\s*[\d|a-zA-Z]+\.)(\s)+$/,
triggerKey: Keyboard.keys.SPACE,
},
[BlockType.QuoteBlock]: {
type: BlockType.QuoteBlock,
markdownRegexp: /^("|“|”)(\s)+$/,
triggerKey: Keyboard.keys.SPACE,
},
[BlockType.ToggleListBlock]: {
type: BlockType.ToggleListBlock,
markdownRegexp: /^(>)(\s)+$/,
triggerKey: Keyboard.keys.SPACE,
},
[BlockType.CalloutBlock]: {
type: BlockType.CalloutBlock,
markdownRegexp: /^(\[!)(TIP|INFO|WARNING|DANGER)(\])(\s)+$/,
triggerKey: Keyboard.keys.SPACE,
},
[BlockType.EquationBlock]: {
type: BlockType.EquationBlock,
markdownRegexp: /^(\${2})(\s)*(.+)(\s)*(\${2})$/,
triggerKey: Keyboard.keys.DOLLAR,
},
[BlockType.DividerBlock]: {
type: BlockType.DividerBlock,
markdownRegexp: /^(-{3,})$/,
triggerKey: Keyboard.keys.REDUCE,
},
[BlockType.CodeBlock]: {
type: BlockType.CodeBlock,
markdownRegexp: /^(```)$/,
triggerKey: Keyboard.keys.BACK_QUOTE,
},
};
@@ -1,8 +1,7 @@
import { useCallback, useContext, useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { Keyboard } from '$app/constants/document/keyboard';
import isHotkey from 'is-hotkey';
import { useAppDispatch } from '@/appflowy_app/stores/store';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import {
enterActionForBlockThunk,
tabActionForBlockThunk,
@@ -90,6 +89,7 @@ export function useKeyDown(id: string) {
const onKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const filteredEvents = interceptEvents.filter((event) => event.canHandle(e));
filteredEvents.forEach((event) => {
e.stopPropagation();
event.handler(e);
@@ -1,7 +1,6 @@
import { useCallback, useContext, useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { BlockType } from '$app/interfaces/document';
import { useAppDispatch } from '$app/stores/store';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { turnToBlockThunk } from '$app_reducers/document/async-actions';
import { blockConfig } from '$app/constants/document/config';
@@ -10,9 +9,9 @@ import { useRangeRef } from '$app/components/document/_shared/SubscribeSelection
import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
import isHotkey from 'is-hotkey';
import { slashCommandActions } from '$app_reducers/document/slice';
import { Keyboard } from '$app/constants/document/keyboard';
import { getDeltaText } from '$app/utils/document/delta';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { turnIntoConfig } from './shortchut';
export function useTurnIntoBlockEvents(id: string) {
const { docId, controller } = useSubscribeDocument();
@@ -22,27 +21,34 @@ export function useTurnIntoBlockEvents(id: string) {
const getFlag = useCallback(() => {
const range = rangeRef.current?.caret;
if (!range || range.id !== id) return;
const node = getBlock(docId, id);
const delta = new Delta(node.data.delta || []);
return getDeltaText(delta.slice(0, range.index));
}, [docId, id, rangeRef]);
const getDeltaContent = useCallback(() => {
const range = rangeRef.current?.caret;
if (!range || range.id !== id) return;
const node = getBlock(docId, id);
const delta = new Delta(node.data.delta || []);
const content = delta.slice(range.index);
return new Delta(content);
}, [docId, id, rangeRef]);
const canHandle = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>, type: BlockType, triggerKey: string) => {
(event: React.KeyboardEvent<HTMLDivElement>, type: BlockType) => {
{
const config = blockConfig[type];
const triggerKey = event.key === turnIntoConfig[type].triggerKey ? event.key : undefined;
if (!triggerKey) return false;
const regex = turnIntoConfig[type].markdownRegexp;
const regex = config.markdownRegexps;
// This error will be thrown if the block type is not in the config, and it will happen in development environment
if (!regex) {
throw new Error(`canHandle: block type ${type} is not supported`);
@@ -53,10 +59,12 @@ export function useTurnIntoBlockEvents(id: string) {
if (!isTrigger) {
return false;
}
const flag = getFlag();
if (!flag) return false;
return regex.some((r) => r.test(`${flag}${triggerKey}`));
return regex.test(`${flag}${triggerKey}`);
}
},
[getFlag]
@@ -64,18 +72,35 @@ export function useTurnIntoBlockEvents(id: string) {
const getTurnIntoBlockDelta = useCallback(() => {
const content = getDeltaContent();
if (!content) return;
return {
delta: content.ops,
};
}, [getDeltaContent]);
const getAttrs = useCallback(
(type: BlockType) => {
const flag = getFlag();
if (!flag) return;
const triggerKey = turnIntoConfig[type].triggerKey;
const regex = turnIntoConfig[type].markdownRegexp;
const match = `${flag}${triggerKey}`.match(regex);
return match?.[3];
},
[getFlag]
);
const spaceTriggerMap = useMemo(() => {
return {
[BlockType.HeadingBlock]: () => {
const flag = getFlag();
if (!flag) return;
const level = flag.match(/#/g)?.length;
if (!level || level > 3) return;
return {
level,
@@ -84,6 +109,7 @@ export function useTurnIntoBlockEvents(id: string) {
},
[BlockType.TodoListBlock]: () => {
const flag = getFlag();
if (!flag) return;
return {
@@ -97,8 +123,10 @@ export function useTurnIntoBlockEvents(id: string) {
[BlockType.ToggleListBlock]: getTurnIntoBlockDelta,
[BlockType.CalloutBlock]: () => {
const flag = getFlag();
if (!flag) return;
const tag = flag.match(/(TIP|INFO|WARNING|DANGER)/g)?.[0];
if (!tag) return;
const iconMap: Record<string, string> = {
TIP: '💡',
@@ -106,6 +134,7 @@ export function useTurnIntoBlockEvents(id: string) {
WARNING: '⚠️',
DANGER: '‼️',
};
return {
icon: iconMap[tag],
...getTurnIntoBlockDelta(),
@@ -117,24 +146,24 @@ export function useTurnIntoBlockEvents(id: string) {
const turnIntoBlockEvents = useMemo(() => {
const spaceTriggerEvents = Object.entries(spaceTriggerMap).map(([type, getData]) => {
const blockType = type as BlockType;
const triggerKey = Keyboard.keys.Space;
return {
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => canHandle(e, blockType, triggerKey),
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => canHandle(e, blockType),
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault();
if (!controller) return;
const data = getData();
if (!data) return;
dispatch(turnToBlockThunk({ id, data, type: blockType, controller }));
},
};
});
return [
...spaceTriggerEvents,
{
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) =>
canHandle(e, BlockType.DividerBlock, Keyboard.keys.Reduce),
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => canHandle(e, BlockType.DividerBlock),
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault();
if (!controller) return;
@@ -153,8 +182,7 @@ export function useTurnIntoBlockEvents(id: string) {
},
},
{
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) =>
canHandle(e, BlockType.CodeBlock, Keyboard.keys.BackQuote),
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => canHandle(e, BlockType.CodeBlock),
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault();
if (!controller) return;
@@ -163,13 +191,28 @@ export function useTurnIntoBlockEvents(id: string) {
...defaultData,
delta: getDeltaContent()?.ops as Op[],
};
dispatch(turnToBlockThunk({ id, data, type: BlockType.CodeBlock, controller }));
},
},
{
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => canHandle(e, BlockType.EquationBlock),
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault();
const formula = getAttrs(BlockType.EquationBlock);
const data = {
formula,
};
dispatch(turnToBlockThunk({ id, data, type: BlockType.EquationBlock, controller }));
},
},
{
// Here custom slash key event for TextBlock
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
const flag = getFlag();
return isHotkey('/', e) && flag === '';
},
handler: (_: React.KeyboardEvent<HTMLDivElement>) => {
@@ -183,7 +226,7 @@ export function useTurnIntoBlockEvents(id: string) {
},
},
];
}, [canHandle, controller, dispatch, docId, getDeltaContent, getFlag, id, spaceTriggerMap]);
}, [canHandle, controller, dispatch, docId, getAttrs, getDeltaContent, getFlag, id, spaceTriggerMap]);
return turnIntoBlockEvents;
}
@@ -32,7 +32,7 @@ export default function TodoListBlock({
/>
</div>
</div>
<div className={'flex-1'}>
<div className={`flex-1 ${checked ? 'text-shade-2 line-through' : ''}`}>
<TextBlock node={node} />
</div>
</div>
@@ -61,19 +61,18 @@ export function useCommonKeyEvents(id: string) {
{
// handle left arrow key and no other key is pressed
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
return isHotkey(Keyboard.keys.LEFT, e) && caretRef.current?.index === 0 && caretRef.current?.length === 0;
return isHotkey(Keyboard.keys.LEFT, e);
},
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
dispatch(leftActionForBlockThunk({ docId, id }));
},
},
{
// handle right arrow key and no other key is pressed
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
const block = getBlock(docId, id);
const isEndOfBlock = caretRef.current?.index === new Delta(block.data.delta).length();
return isHotkey(Keyboard.keys.RIGHT, e) && isEndOfBlock && caretRef.current?.length === 0;
return isHotkey(Keyboard.keys.RIGHT, e);
},
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault();
@@ -86,6 +85,7 @@ export function useCommonKeyEvents(id: string) {
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
if (!controller) return;
const format = parseFormat(e);
if (!format) return;
dispatch(
toggleFormatThunk({
@@ -97,5 +97,6 @@ export function useCommonKeyEvents(id: string) {
},
];
}, [docId, caretRef, controller, dispatch, focused, id]);
return commonKeyEvents;
}
@@ -1,4 +1,4 @@
import { useCallback, useContext, useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { RangeStatic } from 'quill';
import { useAppDispatch } from '$app/stores/store';
import { rangeActions } from '$app_reducers/document/slice';
@@ -44,7 +44,15 @@ export function useSelection(id: string) {
);
useEffect(() => {
if (rangeRef.current && rangeRef.current?.isDragging) return;
if (rangeRef.current) {
const { isDragging, anchor, focus } = rangeRef.current;
const mouseDownFocused = anchor?.point.x === focus?.point.x && anchor?.point.y === focus?.point.y;
if (isDragging && !mouseDownFocused) {
return;
}
}
if (!focusCaret) {
setSelection(undefined);
return;
@@ -0,0 +1,143 @@
import React, { useCallback, useContext, useEffect, useRef } from 'react';
import './inline.css';
import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks';
import { useFocused, useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks';
import { RangeStaticNoId, TemporaryType } from '$app/interfaces/document';
import { useAppDispatch } from '$app/stores/store';
import { createTemporary } from '$app_reducers/document/async-actions/temporary';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import KatexMath from '$app/components/document/_shared/KatexMath';
const LEFT_CARET_CLASS = 'inline-block-with-cursor-left';
const RIGHT_CARET_CLASS = 'inline-block-with-cursor-right';
function InlineContainer({
isFirst,
isLast,
children,
getSelection,
selectedText,
data,
temporaryType,
}: {
getSelection: (node: Element) => RangeStaticNoId | null;
children: React.ReactNode;
formula: string;
selectedText: string;
isLast: boolean;
isFirst: boolean;
data: {
latex?: string;
};
temporaryType: TemporaryType;
}) {
const id = useContext(NodeIdContext);
const { docId } = useSubscribeDocument();
const { focused, focusCaret } = useFocused(id);
const rangeRef = useRangeRef();
const ref = useRef<HTMLSpanElement>(null);
const dispatch = useAppDispatch();
const onClick = useCallback(
(node: HTMLSpanElement) => {
const selection = getSelection(node);
if (!selection) return;
const temporaryData = temporaryType === TemporaryType.Equation ? { latex: data.latex } : {};
dispatch(
createTemporary({
docId,
state: {
id,
selection,
selectedText,
type: temporaryType,
data: temporaryData as { latex: string },
},
})
);
},
[getSelection, temporaryType, data.latex, dispatch, docId, id, selectedText]
);
const renderNode = useCallback(() => {
switch (temporaryType) {
case TemporaryType.Equation:
return <KatexMath latex={data.latex!} isInline />;
default:
return null;
}
}, [data, temporaryType]);
const resetCaret = useCallback(() => {
if (!ref.current) return;
ref.current.classList.remove(RIGHT_CARET_CLASS);
ref.current.classList.remove(LEFT_CARET_CLASS);
}, []);
useEffect(() => {
resetCaret();
if (!ref.current) return;
if (!focused || !focusCaret || rangeRef.current?.isDragging) {
return;
}
const inlineBlockSelection = getSelection(ref.current);
if (!inlineBlockSelection) return;
const distance = inlineBlockSelection.index - focusCaret.index;
if (distance === 0 && isFirst) {
ref.current.classList.add(LEFT_CARET_CLASS);
return;
}
if (distance === -1) {
ref.current.classList.add(RIGHT_CARET_CLASS);
return;
}
}, [focused, focusCaret, getSelection, resetCaret, isFirst, rangeRef]);
useEffect(() => {
if (!ref.current) return;
const onMouseDown = (e: MouseEvent) => {
if (e.target === ref.current) {
e.stopPropagation();
e.preventDefault();
}
};
// prevent page scroll when the caret change by mouse down
document.addEventListener('mousedown', onMouseDown, true);
return () => {
document.removeEventListener('mousedown', onMouseDown, true);
};
}, []);
if (!selectedText) return null;
return (
<span className={'inline-block-with-cursor relative'} ref={ref} onClick={() => onClick(ref.current!)}>
<span
style={{
pointerEvents: 'none',
}}
className={`absolute caret-transparent opacity-0`}
>
{children}
</span>
<span
data-slate-placeholder={true}
contentEditable={false}
style={{
pointerEvents: 'none',
}}
>
{renderNode()}
</span>
{isLast && <span data-slate-string={false}>&#xFEFF;</span>}
</span>
);
}
export default InlineContainer;
@@ -0,0 +1,31 @@
.inline-block-with-cursor {
position: relative;
display: inline-block;
padding: 0 2px;
}
.inline-block-with-cursor-left::before,
.inline-block-with-cursor-right::after {
content: '';
position: absolute;
top: 0px;
width: 1px;
height: 100%;
background-color: rgb(55, 53, 47);
opacity: 0.5;
animation: cursor-blink 1s infinite;
}
.inline-block-with-cursor-left::before {
left: -1px;
}
.inline-block-with-cursor-right::after {
right: -1px;
}
@keyframes cursor-blink {
0% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 0; }
}
@@ -0,0 +1,4 @@
.katex-html {
white-space: normal;
}
@@ -0,0 +1,10 @@
import React from 'react';
import 'katex/dist/katex.min.css';
import { BlockMath, InlineMath } from 'react-katex';
import './index.css';
function KatexMath({ latex, isInline = false }: { latex: string; isInline?: boolean }) {
return isInline ? <InlineMath math={latex} /> : <BlockMath math={latex} />;
}
export default KatexMath;
@@ -49,7 +49,11 @@ const MenuItem = forwardRef(function (
}}
>
<div
className={`mr-2 flex h-[${imgSize.height}px] w-[${imgSize.width}px] items-center justify-center rounded border border-shade-5`}
style={{
width: imgSize.width,
height: imgSize.height,
}}
className={`mr-2 flex items-center justify-center rounded border border-shade-5`}
>
{icon}
</div>
@@ -4,6 +4,9 @@ import { useCallback, useRef } from 'react';
import TextLink from '../TextLink';
import { converToIndexLength } from '$app/utils/document/slate_editor';
import LinkHighLight from '$app/components/document/_shared/TextLink/LinkHighLight';
import TemporaryInput from '$app/components/document/_shared/TemporaryInput';
import InlineContainer from '$app/components/document/_shared/InlineBlock/InlineContainer';
import { TemporaryType } from '$app/interfaces/document';
interface Attributes {
bold?: boolean;
@@ -16,6 +19,8 @@ interface Attributes {
prism_token?: string;
link_selection_lighted?: boolean;
link_placeholder?: string;
temporary?: boolean;
formula?: string;
}
interface TextLeafProps extends RenderLeafProps {
leaf: BaseText & Attributes;
@@ -27,6 +32,9 @@ const TextLeaf = (props: TextLeafProps) => {
const { attributes, children, leaf, isCodeBlock, editor } = props;
const ref = useRef<HTMLSpanElement>(null);
const customAttributes = {
...attributes,
};
let newChildren = children;
if (leaf.code) {
@@ -51,6 +59,7 @@ const TextLeaf = (props: TextLeafProps) => {
anchor: { path, offset: 0 },
focus: { path, offset: leaf.text.length },
});
return selection;
},
[editor, leaf]
@@ -64,6 +73,26 @@ const TextLeaf = (props: TextLeafProps) => {
);
}
if (leaf.formula) {
const { isLast, text, parent } = children.props;
const temporaryType = TemporaryType.Equation;
const data = { latex: leaf.formula };
newChildren = (
<InlineContainer
isLast={isLast}
isFirst={text === parent.children[0]}
getSelection={getSelection}
formula={leaf.formula}
data={data}
temporaryType={temporaryType}
selectedText={leaf.text}
>
{newChildren}
</InlineContainer>
);
}
const className = [
isCodeBlock && 'token',
leaf.prism_token && leaf.prism_token,
@@ -83,8 +112,13 @@ const TextLeaf = (props: TextLeafProps) => {
</LinkHighLight>
);
}
if (leaf.temporary) {
newChildren = <TemporaryInput leaf={leaf}>{newChildren}</TemporaryInput>;
}
return (
<span ref={ref} {...attributes} className={className.join(' ')}>
<span ref={ref} {...customAttributes} className={className.join(' ')}>
{newChildren}
</span>
);
@@ -0,0 +1,156 @@
import { TextAction } from '$app/interfaces/document';
import { Keyboard } from '$app/constants/document/keyboard';
import { ReactEditor } from 'slate-react';
import { Editor, Range } from 'slate';
import { converToSlatePoint } from '$app/utils/document/slate_editor';
import { EQUATION_PLACEHOLDER } from '$app/constants/document/name';
const bold = {
type: TextAction.Bold,
/**
* ** or __
*/
markdownRegexp: /(\*\*|__)([^\s](?:[^\s]*?[^\s])?)(\*\*|__)$/,
};
const italic = {
type: TextAction.Italic,
/**
* * or _
*/
markdownRegexp: /(\*|_)([^\s](?:[^\s]*?[^\s])?)(\*|_)$/,
};
const strikethrough = {
type: TextAction.Strikethrough,
/**
* ~~
*/
markdownRegexp: /(~~)([^\s](?:[^\s]*?[^\s])?)(~~)$/,
};
const inlineCode = {
type: TextAction.Code,
/**
* `
*/
markdownRegexp: /(`)([^\s](?:[^\s]*?[^\s])?)(`)$/,
};
const inlineEquation = {
type: TextAction.Equation,
/**
* $
*/
markdownRegexp: /(\$)([^\s](?:[^\s]*?[^\s])?)(\$)$/,
};
const config: Record<
string,
{
type: TextAction;
getValue?: (matchStr: string) => string | boolean;
markdownRegexp: RegExp;
}[]
> = {
[Keyboard.keys.ASTERISK]: [bold, italic],
[Keyboard.keys.UNDER_SCORE]: [bold, italic],
[Keyboard.keys.TILDE]: [strikethrough],
[Keyboard.keys.BACK_QUOTE]: [inlineCode],
[Keyboard.keys.DOLLAR]: [inlineEquation],
};
export const withMarkdown = (editor: ReactEditor) => {
const { insertText } = editor;
editor.insertText = (text) => {
const { selection } = editor;
const char = text.charAt(text.length - 1);
const matchFormatTypes = config[char];
if (matchFormatTypes && matchFormatTypes.length > 0 && selection && Range.isCollapsed(selection)) {
const { anchor } = selection;
const start = Editor.start(editor, []);
const range = { anchor, focus: start };
const textString = Editor.string(editor, range) + text;
const prevChar = textString.charAt(textString.length - 2);
// If the previous character is a space, we don't want to trigger the markdown
if (prevChar === ' ') {
return insertText(text);
}
for (const formatType of matchFormatTypes) {
const match = textString.match(formatType.markdownRegexp);
if (match) {
const pluralStart = match[0].substring(0, 2) === char.padStart(2, char);
const pluralEnd = prevChar === char;
if (pluralStart && !pluralEnd) {
break;
}
const matchIndex = match.index || 0;
if (formatType.type === TextAction.Equation) {
formatEquation(editor, matchIndex, match[2]);
return;
}
// format already applied
editor.select({
anchor,
focus: converToSlatePoint(editor, matchIndex),
});
if (isMarkAction(editor, formatType.type)) {
editor.select(anchor);
break;
}
Editor.addMark(editor, formatType.type, true);
// delete extra characters
editor.select(converToSlatePoint(editor, matchIndex));
editor.delete({
distance: pluralStart ? 2 : 1,
});
editor.select(converToSlatePoint(editor, matchIndex + match[2].length));
if (pluralStart) {
editor.delete({
distance: 1,
});
}
return;
}
}
}
insertText(text);
};
return editor;
};
function isMarkAction(editor: Editor, format: string) {
const marks = Editor.marks(editor) as Record<string, boolean> | null;
return marks ? !!marks[format] : false;
}
function formatEquation(editor: Editor, index: number, latex: string) {
editor.select(converToSlatePoint(editor, index));
editor.delete({
distance: latex.length + 1,
});
editor.insertNode(
{
text: EQUATION_PLACEHOLDER,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
formula: latex,
},
{
select: true,
}
);
}
@@ -15,6 +15,8 @@ import Delta from 'quill-delta';
import isHotkey from 'is-hotkey';
import { useSlateYjs } from '$app/components/document/_shared/SlateEditor/useSlateYjs';
const AFTER_RENDER_DELAY = 100;
export function useEditor({
onChange,
onSelectionChange,
@@ -24,6 +26,7 @@ export function useEditor({
onKeyDown,
isCodeBlock,
linkDecorateSelection,
temporarySelection,
}: EditorProps) {
const { editor } = useSlateYjs({ delta });
const ref = useRef<HTMLDivElement | null>(null);
@@ -31,6 +34,7 @@ export function useEditor({
const onSelectionChangeHandler = useCallback(
(slateSelection: Selection) => {
const rangeStatic = converToIndexLength(editor, slateSelection);
onSelectionChange?.(rangeStatic, null);
},
[editor, onSelectionChange]
@@ -39,6 +43,7 @@ export function useEditor({
const onChangeHandler = useCallback(
(slateValue: Descendant[]) => {
const oldContents = delta || new Delta();
onChange?.(convertToDelta(slateValue), oldContents);
onSelectionChangeHandler(editor.selection);
},
@@ -67,8 +72,10 @@ export function useEditor({
) => {
if (!selection) return null;
const range = convertToSlateSelection(selection.index, selection.length, editor.children) as BaseRange;
if (range && !Range.isCollapsed(range)) {
const intersection = Range.intersection(range, Editor.range(editor, path));
if (intersection) {
return {
...intersection,
@@ -76,6 +83,7 @@ export function useEditor({
};
}
}
return null;
},
[editor]
@@ -93,11 +101,14 @@ export function useEditor({
link_selection_lighted: true,
link_placeholder: linkDecorateSelection?.placeholder,
}),
getDecorateRange(path, temporarySelection, {
temporary: true,
}),
].filter((range) => range !== null) as Range[];
return ranges;
},
[decorateSelection, linkDecorateSelection, getDecorateRange]
[temporarySelection, decorateSelection, linkDecorateSelection, getDecorateRange]
);
const onKeyDownRewrite = useCallback(
@@ -107,6 +118,7 @@ export function useEditor({
event.preventDefault();
editor.insertText('\n');
};
// There is different behavior for code block and normal text
// In code block, we press enter to insert a new line
// In normal text, we press shift + enter to insert a new line
@@ -115,11 +127,13 @@ export function useEditor({
insertBreak();
return;
}
if (isHotkey(Keyboard.keys.TAB, event)) {
event.preventDefault();
indent(editor, 2);
return;
}
if (isHotkey(Keyboard.keys.SHIFT_TAB, event)) {
event.preventDefault();
outdent(editor, 2);
@@ -141,13 +155,16 @@ export function useEditor({
useEffect(() => {
if (!ref.current) return;
const isFocused = ReactEditor.isFocused(editor);
if (!selection) {
isFocused && editor.deselect();
return;
}
const slateSelection = convertToSlateSelection(selection.index, selection.length, editor.children);
if (!slateSelection) return;
if (isFocused && JSON.stringify(slateSelection) === JSON.stringify(editor.selection)) return;
@@ -156,15 +173,16 @@ export function useEditor({
// because the slate must be focused before change selection,
// but then it will trigger selection change, and the selection is not what we want
const isSuccess = focusNodeByIndex(ref.current, selection.index, selection.length);
if (!isSuccess) {
Transforms.select(editor, slateSelection);
} else {
// Fix: the slate is possible to lose focus in next tick after focusNodeByIndex
requestAnimationFrame(() => {
setTimeout(() => {
if (window.getSelection()?.type === 'None' && !editor.selection) {
Transforms.select(editor, slateSelection);
}
});
}, AFTER_RENDER_DELAY);
}
}, [editor, selection]);
@@ -5,6 +5,7 @@ import { convertToSlateValue } from '$app/utils/document/slate_editor';
import { slateNodesToInsertDelta, withYjs, YjsEditor } from '@slate-yjs/core';
import { withReact } from 'slate-react';
import { createEditor } from 'slate';
import { withMarkdown } from '$app/components/document/_shared/SlateEditor/markdown';
export function useSlateYjs({ delta }: { delta?: Delta }) {
const [yText, setYText] = useState<Y.Text | undefined>(undefined);
@@ -13,13 +14,14 @@ export function useSlateYjs({ delta }: { delta?: Delta }) {
const sharedType = yDoc.get('content', Y.XmlText) as Y.XmlText;
const value = convertToSlateValue(delta || new Delta());
const insertDelta = slateNodesToInsertDelta(value);
sharedType.applyDelta(insertDelta);
setYText(insertDelta[0].insert as Y.Text);
return sharedType;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const editor = useMemo(() => withReact(withYjs(createEditor(), sharedType)), []);
const editor = useMemo(() => withYjs(withMarkdown(withReact(createEditor())), sharedType), []);
// Connect editor in useEffect to comply with concurrent mode requirements.
useEffect(() => {
@@ -33,6 +35,7 @@ export function useSlateYjs({ delta }: { delta?: Delta }) {
if (!yText) return;
const oldContents = new Delta(yText.toDelta());
const diffDelta = oldContents.diff(delta || new Delta());
if (diffDelta.ops.length === 0) return;
yText.applyDelta(diffDelta.ops);
}, [delta, editor, yText]);
@@ -1,10 +1,12 @@
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { useContext } from 'react';
import { useAppSelector } from '$app/stores/store';
import { DOCUMENT_NAME } from '$app/constants/document/name';
export function useSubscribeDocument() {
const controller = useContext(DocumentControllerContext);
const docId = controller.documentId;
return {
docId,
controller,
@@ -14,7 +16,8 @@ export function useSubscribeDocument() {
export function useSubscribeDocumentData() {
const { docId } = useSubscribeDocument();
const data = useAppSelector((state) => {
return state.document[docId];
return state[DOCUMENT_NAME][docId];
});
return data;
}
@@ -1,11 +1,12 @@
import { useAppSelector } from '$app/stores/store';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { TEXT_LINK_NAME } from '$app/constants/document/name';
export function useSubscribeLinkPopover() {
const { docId } = useSubscribeDocument();
const linkPopover = useAppSelector((state) => {
return state.documentLinkPopover[docId];
return state[TEXT_LINK_NAME][docId];
});
return linkPopover;
@@ -2,6 +2,7 @@ import { store, useAppSelector } from '@/appflowy_app/stores/store';
import { createContext, useMemo } from 'react';
import { Node } from '$app/interfaces/document';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { DOCUMENT_NAME, RECT_RANGE_NAME } from '$app/constants/document/name';
/**
* Subscribe node information
@@ -11,20 +12,23 @@ export function useSubscribeNode(id: string) {
const { docId } = useSubscribeDocument();
const node = useAppSelector<Node>((state) => {
const documentState = state.document[docId];
const documentState = state[DOCUMENT_NAME][docId];
return documentState?.nodes[id];
});
const childIds = useAppSelector<string[] | undefined>((state) => {
const documentState = state.document[docId];
const documentState = state[DOCUMENT_NAME][docId];
if (!documentState) return;
const childrenId = documentState.nodes[id]?.children;
if (!childrenId) return;
return documentState.children[childrenId];
});
const isSelected = useAppSelector<boolean>((state) => {
return state.documentRectSelection[docId]?.selection.includes(id) || false;
return state[RECT_RANGE_NAME][docId]?.selection.includes(id) || false;
});
// Memoize the node and its children
@@ -1,10 +1,12 @@
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { useAppSelector } from '$app/stores/store';
import { RECT_RANGE_NAME } from '$app/constants/document/name';
export function useSubscribeRectRange() {
const { docId } = useSubscribeDocument();
const rectRange = useAppSelector((state) => {
return state.documentRectSelection[docId];
return state[RECT_RANGE_NAME][docId];
});
return rectRange;
}
@@ -2,16 +2,25 @@ import { useAppSelector } from '$app/stores/store';
import { RangeState, RangeStatic } from '$app/interfaces/document';
import { useMemo, useRef } from 'react';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { RANGE_NAME, TEMPORARY_NAME, TEXT_LINK_NAME } from '$app/constants/document/name';
export function useSubscribeDecorate(id: string) {
const { docId } = useSubscribeDocument();
const decorateSelection = useAppSelector((state) => {
return state.documentRange[docId]?.ranges[id];
return state[RANGE_NAME][docId]?.ranges[id];
});
const temporarySelection = useAppSelector((state) => {
const temporary = state[TEMPORARY_NAME][docId];
if (!temporary || temporary.id !== id) return;
return temporary.selection;
});
const linkDecorateSelection = useAppSelector((state) => {
const linkPopoverState = state.documentLinkPopover[docId];
const linkPopoverState = state[TEXT_LINK_NAME][docId];
if (!linkPopoverState?.open || linkPopoverState?.id !== id) return;
return {
selection: linkPopoverState.selection,
@@ -22,18 +31,22 @@ export function useSubscribeDecorate(id: string) {
return {
decorateSelection,
linkDecorateSelection,
temporarySelection,
};
}
export function useFocused(id: string) {
const { docId } = useSubscribeDocument();
const caretRef = useRef<RangeStatic>();
const focusCaret = useAppSelector((state) => {
const currentCaret = state.documentRange[docId]?.caret;
const currentCaret = state[RANGE_NAME][docId]?.caret;
caretRef.current = currentCaret;
if (currentCaret?.id === id) {
return currentCaret;
}
return null;
});
@@ -52,8 +65,10 @@ export function useRangeRef() {
const { docId, controller } = useSubscribeDocument();
const rangeRef = useRef<RangeState>();
useAppSelector((state) => {
const currentRange = state.documentRange[docId];
const currentRange = state[RANGE_NAME][docId];
rangeRef.current = currentRange;
});
return rangeRef;
@@ -63,7 +78,7 @@ export function useSubscribeRanges() {
const { docId } = useSubscribeDocument();
const rangeState = useAppSelector((state) => {
return state.documentRange[docId];
return state[RANGE_NAME][docId];
});
return rangeState;
@@ -73,7 +88,7 @@ export function useSubscribeCaret() {
const { docId } = useSubscribeDocument();
const caret = useAppSelector((state) => {
return state.documentRange[docId]?.caret;
return state[RANGE_NAME][docId]?.caret;
});
return caret;
@@ -1,11 +1,12 @@
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { useAppSelector } from '$app/stores/store';
import { SLASH_COMMAND_NAME } from '$app/constants/document/name';
export function useSubscribeSlashState() {
const { docId } = useSubscribeDocument();
const slashCommandState = useAppSelector((state) => {
return state.documentSlashCommand[docId];
return state[SLASH_COMMAND_NAME][docId];
});
return slashCommandState;
@@ -0,0 +1,11 @@
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { useAppSelector } from '$app/stores/store';
import { TemporaryState } from '$app/interfaces/document';
import { TEMPORARY_NAME } from '$app/constants/document/name';
export function useSubscribeTemporary(): TemporaryState {
const { docId } = useSubscribeDocument();
const temporaryState = useAppSelector((state) => state[TEMPORARY_NAME][docId]);
return temporaryState;
}
@@ -0,0 +1,56 @@
import React from 'react';
import TextField from '@mui/material/TextField';
import { CheckOutlined, FunctionsOutlined } from '@mui/icons-material';
import { Divider, IconButton, InputAdornment } from '@mui/material';
function EquationEditContent({
value,
onChange,
onConfirm,
placeholder = 'E = mc^2',
multiline = false,
}: {
value: string;
placeholder?: string;
onChange: (newVal: string) => void;
onConfirm: () => void;
multiline?: boolean;
}) {
return (
<div className={'flex p-2'}>
<TextField
placeholder={placeholder}
autoFocus={true}
multiline={multiline}
label='Equation'
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onConfirm();
}
}}
InputProps={{
startAdornment: (
<InputAdornment position='start'>
<FunctionsOutlined />
</InputAdornment>
),
}}
variant='standard'
value={value}
onChange={(e) => {
const newVal = e.target.value;
if (newVal === value) return;
onChange(newVal);
}}
/>
<Divider sx={{ height: 'initial', marginLeft: '10px' }} orientation='vertical' />
<IconButton onClick={onConfirm} color='primary' sx={{ p: '10px' }} aria-label='directions'>
<CheckOutlined />
</IconButton>
</div>
);
}
export default EquationEditContent;
@@ -0,0 +1,19 @@
import React, { useRef } from 'react';
import { Functions } from '@mui/icons-material';
import KatexMath from '$app/components/document/_shared/KatexMath';
function TemporaryEquation({ latex }: { latex: string }) {
return (
<span className={'rounded bg-shade-6 px-1 py-0.5'} contentEditable={false}>
{latex ? (
<KatexMath latex={latex} isInline />
) : (
<span className={'text-shade-3'}>
<Functions /> {'New equation'}
</span>
)}
</span>
);
}
export default TemporaryEquation;
@@ -0,0 +1,135 @@
import React, { useCallback, useMemo } from 'react';
import Popover from '@mui/material/Popover';
import { RangeStaticNoId, TemporaryData, TemporaryState, TemporaryType } from '$app/interfaces/document';
import EquationEditContent from '$app/components/document/_shared/TemporaryInput/EquationEditContent';
import { temporaryActions } from '$app_reducers/document/temporary_slice';
import { rangeActions } from '$app_reducers/document/slice';
import { formatTemporary } from '$app_reducers/document/async-actions/temporary';
import { useAppDispatch } from '$app/stores/store';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { useSubscribeTemporary } from '$app/components/document/_shared/SubscribeTemporary.hooks';
const AFTER_RENDER_DELAY = 100;
function TemporaryPopover() {
const temporaryState = useSubscribeTemporary();
const anchorPosition = useMemo(() => temporaryState?.popoverPosition, [temporaryState]);
const open = Boolean(anchorPosition);
const id = temporaryState?.id;
const dispatch = useAppDispatch();
const { docId, controller } = useSubscribeDocument();
const onChangeData = useCallback(
(data: TemporaryData) => {
dispatch(
temporaryActions.updateTemporaryState({
id: docId,
state: {
data,
id,
},
})
);
},
[dispatch, docId, id]
);
const resetCaret = useCallback(
(id: string, selection: RangeStaticNoId) => {
dispatch(
rangeActions.setCaret({
docId,
caret: {
id,
index: selection.index + selection.length,
length: 0,
},
})
);
},
[dispatch, docId]
);
const onClose = useCallback(() => {
dispatch(
temporaryActions.updateTemporaryState({
id: docId,
state: {
id,
popoverPosition: null,
},
})
);
}, [dispatch, docId, id]);
const handleClose = useCallback(() => {
if (!temporaryState) return;
onClose();
dispatch(temporaryActions.deleteTemporaryState(docId));
resetCaret(temporaryState.id, temporaryState.selection);
}, [dispatch, docId, onClose, resetCaret, temporaryState]);
const onConfirm = useCallback(async () => {
const res = await dispatch(
formatTemporary({
controller,
})
);
const state = res.payload as TemporaryState;
if (!state) return;
const { id, selection } = state;
onClose();
dispatch(rangeActions.clearRanges({ docId }));
dispatch(temporaryActions.deleteTemporaryState(docId));
// wait slate to update the dom
setTimeout(() => {
resetCaret(id, selection);
}, AFTER_RENDER_DELAY);
}, [dispatch, controller, onClose, docId, resetCaret]);
const renderPopoverContent = useCallback(() => {
if (!temporaryState) return null;
const { type, data } = temporaryState;
switch (type) {
case TemporaryType.Equation:
return (
<EquationEditContent
value={data.latex}
onChange={(latex: string) =>
onChangeData({
latex,
})
}
onConfirm={onConfirm}
/>
);
}
}, [onChangeData, onConfirm, temporaryState]);
return (
<Popover
onClose={handleClose}
open={open}
anchorPosition={anchorPosition ? anchorPosition : undefined}
onMouseDown={(e) => e.stopPropagation()}
disableAutoFocus={true}
disableRestoreFocus={true}
anchorReference={'anchorPosition'}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center',
}}
>
{renderPopoverContent()}
</Popover>
);
}
export default TemporaryPopover;
@@ -0,0 +1,77 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { TemporaryType } from '$app/interfaces/document';
import TemporaryEquation from '$app/components/document/_shared/TemporaryInput/TemporaryEquation';
import { useSubscribeTemporary } from '$app/components/document/_shared/SubscribeTemporary.hooks';
import { isOverlappingPrefix } from '$app/utils/document/temporary';
import { PopoverPosition } from '@mui/material';
import { useAppDispatch } from '$app/stores/store';
import { temporaryActions } from '$app_reducers/document/temporary_slice';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
function TemporaryInput({ leaf, children }: { leaf: { text: string }; children: React.ReactNode }) {
const temporaryState = useSubscribeTemporary();
const id = temporaryState?.id;
const dispatch = useAppDispatch();
const ref = useRef<HTMLSpanElement>(null);
const { docId } = useSubscribeDocument();
const match = useMemo(() => {
if (!leaf.text) return false;
if (!temporaryState) return false;
const { selectedText, type } = temporaryState;
switch (type) {
case TemporaryType.Equation:
// when the leaf is split, the placeholder is not the same as the leaf text,
// so we can only check for overlapping prefix and hidden other leafs
return leaf.text === selectedText || isOverlappingPrefix(leaf.text, selectedText);
default:
return false;
}
}, [temporaryState, leaf.text]);
const renderPlaceholder = useCallback(() => {
if (!temporaryState) return null;
const { type, data } = temporaryState;
switch (type) {
case TemporaryType.Equation:
return <TemporaryEquation latex={data.latex} />;
default:
return null;
}
}, [temporaryState]);
const setAnchorPosition = useCallback(
(position: PopoverPosition | null) => {
dispatch(
temporaryActions.updateTemporaryState({
id: docId,
state: {
id,
popoverPosition: position,
},
})
);
},
[dispatch, docId, id]
);
useEffect(() => {
if (!ref.current || !match) return;
const { width, height, top, left } = ref.current.getBoundingClientRect();
setAnchorPosition({
top: top + height,
left: left + width / 2,
});
}, [dispatch, docId, id, match, setAnchorPosition]);
return (
<span ref={ref}>
{match ? renderPlaceholder() : null}
<span className={'absolute opacity-0'}>{children}</span>
</span>
);
}
export default TemporaryInput;
@@ -1,4 +1,5 @@
import React from 'react';
import { isOverlappingPrefix } from '$app/utils/document/temporary';
function LinkHighLight({ children, leaf, title }: { leaf: { text: string }; title: string; children: React.ReactNode }) {
return (
@@ -19,15 +20,3 @@ function LinkHighLight({ children, leaf, title }: { leaf: { text: string }; titl
}
export default LinkHighLight;
function isOverlappingPrefix(first: string, second: string): boolean {
if (first.length === 0 || second.length === 0) return false;
let i = 0;
while (i < first.length) {
const chars = first.substring(i);
if (chars.length > second.length) return false;
if (second.startsWith(chars)) return true;
i++;
}
return false;
}
@@ -4,11 +4,43 @@ import { BlockData, BlockType, NestedBlock } from '$app/interfaces/document';
import { blockConfig } from '$app/constants/document/config';
import { turnToBlockThunk } from '$app_reducers/document/async-actions';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import Delta from 'quill-delta';
import { getDeltaText } from '$app/utils/document/delta';
import { rangeActions, rectSelectionActions } from '$app_reducers/document/slice';
import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection';
export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: () => void }) {
const dispatch = useAppDispatch();
const { controller } = useSubscribeDocument();
const { controller, docId } = useSubscribeDocument();
const getTurnIntoData = useCallback(
(targetType: BlockType, sourceNode: NestedBlock) => {
if (targetType === sourceNode.type) return;
const config = blockConfig[targetType];
const defaultData = config.defaultData;
const data: BlockData<any> = {
...defaultData,
delta: sourceNode?.data?.delta || [],
};
if (targetType === BlockType.EquationBlock) {
data.formula = getDeltaText(new Delta(sourceNode.data.delta));
delete data.delta;
}
if (sourceNode.type === BlockType.EquationBlock) {
data.delta = [
{
insert: node.data.formula,
},
];
}
return data;
},
[node.data.formula]
);
const turnIntoBlock = useCallback(
async (type: BlockType, isSelected: boolean, data?: BlockData<any>) => {
@@ -17,27 +49,34 @@ export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: ()
return;
}
const config = blockConfig[type];
await dispatch(
const updateData = {
...getTurnIntoData(type, node),
...data,
};
const { payload: newBlockId } = await dispatch(
turnToBlockThunk({
id: node.id,
controller,
type,
data: {
...config.defaultData,
delta: node?.data?.delta || [],
...data,
},
data: updateData,
})
);
onClose?.();
dispatch(
setRectSelectionThunk({
docId,
selection: [newBlockId as string],
})
);
},
[onClose, controller, dispatch, node]
[controller, getTurnIntoData, node, dispatch, onClose, docId]
);
const turnIntoHeading = useCallback(
(level: number, isSelected: boolean) => {
turnIntoBlock(BlockType.HeadingBlock, isSelected, { level });
return turnIntoBlock(BlockType.HeadingBlock, isSelected, { level });
},
[turnIntoBlock]
);
@@ -120,11 +120,12 @@ const TurnIntoPopover = ({
title: 'Callout',
icon: <Lightbulb />,
},
// {
// type: BlockType.EquationBlock,
// title: 'Block Equation',
// icon: <Functions />,
// },
{
key: SlashCommandOptionKey.EQUATION,
type: BlockType.EquationBlock,
title: 'Block Equation',
icon: <Functions />,
},
],
[node?.data?.level, turnIntoHeading]
);
@@ -1,3 +0,0 @@
export const BLOCK_MAP_NAME = 'blocks';
export const META_NAME = 'meta';
export const CHILDREN_MAP_NAME = 'children_map';
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more