chore: add clipper extension package (#2281)
16
packages/clipper/.eslintrc.js
Normal file
@@ -0,0 +1,16 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['../../common.eslintrc.js'],
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
ignorePatterns: ['**/*.spec.ts', '__mocks__'],
|
||||
plugins: ['@typescript-eslint', 'prettier'],
|
||||
env: {
|
||||
browser: true,
|
||||
},
|
||||
globals: {
|
||||
__WEB_VERSION__: true,
|
||||
},
|
||||
}
|
||||
2
packages/clipper/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
web-ext-artifacts
|
||||
53
packages/clipper/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# @standardnotes/clipper
|
||||
|
||||
## Development flow
|
||||
|
||||
- Run `yarn watch:web` in a terminal to watch changes in the `@standardnotes/web` package
|
||||
|
||||
### Chromium
|
||||
|
||||
- Run `yarn watch-mv3` in another terminal to watch changes in the extension source
|
||||
|
||||
#### "Load unpacked" method
|
||||
|
||||
- Go to `chrome://extensions`
|
||||
- Enable `Developer mode`
|
||||
- Click "Load unpacked" and select the `dist` folder in the current package
|
||||
|
||||
You might need to manually press the reload button when you make changers
|
||||
|
||||
#### CLI method
|
||||
|
||||
```console
|
||||
yarn run-chromium --chromium-profile=PATH/TO/PROFILE
|
||||
```
|
||||
|
||||
- You might need to specify the Chromium binary using the `--chromium-binary` argument
|
||||
- Running `yarn run-chromium` without the `--chromium-profile` argument will create a new temporary profile every time
|
||||
|
||||
This method will automatically reload the extension when you make changes
|
||||
|
||||
### Firefox
|
||||
|
||||
- Run `yarn watch` in another terminal to watch changes in the extension source
|
||||
|
||||
```console
|
||||
yarn run-firefox --firefox-profile=PATH/TO/PROFILE
|
||||
```
|
||||
|
||||
- You might need to specify the Firefox binary using the `--firefox` or `-f` argument
|
||||
- Running `yarn run-firefox` without the `--firefox-profile` argument will create a new temporary profile every time
|
||||
|
||||
## Build
|
||||
|
||||
## Firefox
|
||||
|
||||
```console
|
||||
yarn build-firefox
|
||||
```
|
||||
|
||||
## Chromium
|
||||
|
||||
```console
|
||||
yarn build-chromium
|
||||
```
|
||||
BIN
packages/clipper/images/icon128.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
packages/clipper/images/icon16.png
Normal file
|
After Width: | Height: | Size: 510 B |
BIN
packages/clipper/images/icon32.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
packages/clipper/images/icon48.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
packages/clipper/images/icon96.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
35
packages/clipper/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@standardnotes/clipper",
|
||||
"description": "Web clipper browser extension for Standard Notes",
|
||||
"version": "1.0.1",
|
||||
"scripts": {
|
||||
"build-mv2": "yarn clean && webpack --config ./webpack.config.prod.js",
|
||||
"build-mv3": "yarn clean && MANIFEST_VERSION=3 webpack --config ./webpack.config.prod.js",
|
||||
"watch:web": "BUILD_TARGET=clipper yarn workspace @standardnotes/web run watch",
|
||||
"watch-mv2": "webpack --config ./webpack.config.dev.js --watch",
|
||||
"watch-mv3": "MANIFEST_VERSION=3 webpack --config ./webpack.config.dev.js --watch",
|
||||
"build-firefox": "BUILD_TARGET=clipper yarn build:web && EXT_TARGET=firefox yarn build-mv2 && web-ext build --source-dir ./dist --overwrite-dest",
|
||||
"build-chromium": "BUILD_TARGET=clipper yarn build:web && EXT_TARGET=chromium yarn build-mv3 && web-ext build --source-dir ./dist --overwrite-dest",
|
||||
"run-firefox": "web-ext run -t firefox-desktop --keep-profile-changes -s ./dist",
|
||||
"run-chromium": "web-ext run -t chromium --keep-profile-changes -s ./dist",
|
||||
"lint": "eslint src/ && yarn tsc",
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"tsc": "tsc --project tsconfig.json",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/webextension-polyfill": "^0.10.0",
|
||||
"babel-loader": "^9.1.0",
|
||||
"copy-webpack-plugin": "11.0.0",
|
||||
"eslint": "^8.29.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"ts-loader": "^9.4.2",
|
||||
"typescript": "*",
|
||||
"web-ext": "^7.5.0",
|
||||
"webextension-polyfill": "^0.10.0",
|
||||
"webpack": "*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mozilla/readability": "^0.4.2"
|
||||
}
|
||||
}
|
||||
32
packages/clipper/src/background/background.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { runtime, action, browserAction, windows, storage } from 'webextension-polyfill'
|
||||
import { RuntimeMessage, RuntimeMessageTypes } from '../types/message'
|
||||
|
||||
const isFirefox = navigator.userAgent.indexOf('Firefox/') !== -1
|
||||
|
||||
const openPopupAndClipSelection = async (payload: { title: string; content: string }) => {
|
||||
await storage.local.set({ clip: payload })
|
||||
|
||||
if (isFirefox) {
|
||||
const popupURL = await browserAction.getPopup({})
|
||||
await windows.create({
|
||||
type: 'detached_panel',
|
||||
url: popupURL,
|
||||
width: 350,
|
||||
height: 450,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const openPopup = runtime.getManifest().manifest_version === 3 ? action.openPopup : browserAction.openPopup
|
||||
|
||||
void openPopup()
|
||||
}
|
||||
|
||||
runtime.onMessage.addListener((message: RuntimeMessage) => {
|
||||
if (message.type === RuntimeMessageTypes.OpenPopupWithSelection) {
|
||||
if (!message.payload) {
|
||||
return
|
||||
}
|
||||
void openPopupAndClipSelection(message.payload)
|
||||
}
|
||||
})
|
||||
119
packages/clipper/src/content/content.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { runtime } from 'webextension-polyfill'
|
||||
import { Readability } from '@mozilla/readability'
|
||||
import { RuntimeMessage, RuntimeMessageTypes } from '../types/message'
|
||||
|
||||
let isSelectingNodeForClipping = false
|
||||
|
||||
runtime.onMessage.addListener(async (message: RuntimeMessage) => {
|
||||
switch (message.type) {
|
||||
case RuntimeMessageTypes.StartNodeSelection: {
|
||||
isSelectingNodeForClipping = true
|
||||
return
|
||||
}
|
||||
case RuntimeMessageTypes.HasSelection: {
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!selection) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (selection.rangeCount < 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
|
||||
return !range.collapsed
|
||||
}
|
||||
case RuntimeMessageTypes.GetSelection: {
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!selection || selection.rangeCount < 1) {
|
||||
return
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
|
||||
const result = document.createElement('div')
|
||||
result.appendChild(range.cloneContents())
|
||||
|
||||
return { title: document.title, content: result.innerHTML, url: window.location.href }
|
||||
}
|
||||
case RuntimeMessageTypes.GetFullPage: {
|
||||
return { title: document.title, content: document.body.innerHTML, url: window.location.href }
|
||||
}
|
||||
case RuntimeMessageTypes.GetArticle: {
|
||||
const documentClone = document.cloneNode(true) as Document
|
||||
const article = new Readability(documentClone).parse()
|
||||
if (!article) {
|
||||
return
|
||||
}
|
||||
return { title: article.title, content: article.content, url: window.location.href }
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
const nodeOverlayElement = document.createElement('div')
|
||||
nodeOverlayElement.style.border = '2px solid #086dd6'
|
||||
nodeOverlayElement.style.position = 'fixed'
|
||||
nodeOverlayElement.style.top = '0'
|
||||
nodeOverlayElement.style.left = '0'
|
||||
nodeOverlayElement.style.zIndex = '69420'
|
||||
nodeOverlayElement.style.width = window.innerWidth + 'px'
|
||||
nodeOverlayElement.style.height = window.innerHeight - 4 + 'px'
|
||||
nodeOverlayElement.style.pointerEvents = 'none'
|
||||
nodeOverlayElement.style.visibility = 'hidden'
|
||||
nodeOverlayElement.id = 'sn-clipper-node-overlay'
|
||||
|
||||
document.body.appendChild(nodeOverlayElement)
|
||||
|
||||
window.addEventListener('mousemove', (event) => {
|
||||
if (!isSelectingNodeForClipping) {
|
||||
nodeOverlayElement.style.visibility = 'hidden'
|
||||
return
|
||||
}
|
||||
nodeOverlayElement.style.visibility = ''
|
||||
const { target } = event
|
||||
if (!target || !(target instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
const targetRect = target.getBoundingClientRect()
|
||||
nodeOverlayElement.style.width = targetRect.width + 'px'
|
||||
nodeOverlayElement.style.height = targetRect.height + 'px'
|
||||
nodeOverlayElement.style.transform = `translate3d(${targetRect.x}px, ${targetRect.y}px, 0)`
|
||||
})
|
||||
|
||||
const disableNodeSelection = () => {
|
||||
isSelectingNodeForClipping = false
|
||||
nodeOverlayElement.style.visibility = 'hidden'
|
||||
}
|
||||
|
||||
window.addEventListener('click', (event) => {
|
||||
if (!isSelectingNodeForClipping) {
|
||||
return
|
||||
}
|
||||
disableNodeSelection()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const { target } = event
|
||||
if (!target || !(target instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
const title = document.title
|
||||
const content = target.outerHTML
|
||||
void runtime.sendMessage({
|
||||
type: RuntimeMessageTypes.OpenPopupWithSelection,
|
||||
payload: { title, content, url: window.location.href },
|
||||
} as RuntimeMessage)
|
||||
})
|
||||
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (!isSelectingNodeForClipping) {
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
disableNodeSelection()
|
||||
}
|
||||
})
|
||||
33
packages/clipper/src/manifest.v2.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Standard Notes Clipper",
|
||||
"description": "Web clipper for Standard Notes",
|
||||
"permissions": ["activeTab", "storage"],
|
||||
"browser_action": {
|
||||
"default_popup": "popup/index.html?route=extension"
|
||||
},
|
||||
"background": {
|
||||
"scripts": ["background.js"],
|
||||
"persistent": false
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["http://*/*", "https://*/*"],
|
||||
"js": ["content.js"]
|
||||
}
|
||||
],
|
||||
"content_security_policy": "default-src 'self'; script-src 'self' 'wasm-eval' 'wasm-unsafe-eval'; worker-src blob:; connect-src * data: blob:; style-src 'unsafe-inline' 'self'; frame-src * blob:; img-src * data: blob:;",
|
||||
"icons": {
|
||||
"16": "images/icon16.png",
|
||||
"32": "images/icon32.png",
|
||||
"48": "images/icon48.png",
|
||||
"96": "images/icon96.png",
|
||||
"128": "images/icon128.png"
|
||||
},
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "{9f917dfe-accd-4d3a-9685-33c3ac0ca643}",
|
||||
"strict_min_version": "48.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
34
packages/clipper/src/manifest.v3.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Standard Notes Clipper",
|
||||
"description": "Web clipper for Standard Notes",
|
||||
"permissions": ["activeTab", "storage"],
|
||||
"action": {
|
||||
"default_popup": "popup/index.html?route=extension"
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["http://*/*", "https://*/*"],
|
||||
"js": ["content.js"]
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"16": "images/icon16.png",
|
||||
"32": "images/icon32.png",
|
||||
"48": "images/icon48.png",
|
||||
"96": "images/icon96.png",
|
||||
"128": "images/icon128.png"
|
||||
},
|
||||
"content_security_policy": {
|
||||
"extension_pages": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; connect-src * data: blob:; style-src 'unsafe-inline' 'self'; frame-src * blob:; img-src * data: blob:;"
|
||||
},
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "{9f917dfe-accd-4d3a-9685-33c3ac0ca643}",
|
||||
"strict_min_version": "48.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
7
packages/clipper/src/popup/globals.js
Normal file
@@ -0,0 +1,7 @@
|
||||
window.defaultSyncServer = 'https://api.standardnotes.com'
|
||||
window.defaultFilesHost = 'https://files.standardnotes.com'
|
||||
window.enabledUnfinishedFeatures = false
|
||||
window.websocketUrl = 'wss://sockets.standardnotes.com'
|
||||
window.purchaseUrl = 'https://standardnotes.com/purchase'
|
||||
window.plansUrl = 'https://standardnotes.com/plans'
|
||||
window.dashboardUrl = 'https://standardnotes.com/dashboard'
|
||||
26
packages/clipper/src/popup/index.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta content="IE=edge" http-equiv="X-UA-Compatible" />
|
||||
<meta content="viewport-fit=cover, width=device-width, initial-scale=1" name="viewport" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
|
||||
<title>Standard Notes</title>
|
||||
|
||||
<script src="./globals.js"></script>
|
||||
|
||||
<script src="../web/app.js" debug="false"></script>
|
||||
<link rel="stylesheet" media="all" href="../web/app.css" debug="false" />
|
||||
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
min-width: 350px;
|
||||
max-width: 350px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body></body>
|
||||
</html>
|
||||
36
packages/clipper/src/types/message.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export const RuntimeMessageTypes = {
|
||||
GetArticle: 'get-article',
|
||||
GetSelection: 'get-selection',
|
||||
HasSelection: 'has-selection',
|
||||
GetFullPage: 'get-full-page',
|
||||
OpenPopupWithSelection: 'open-popup-with-selection',
|
||||
StartNodeSelection: 'start-node-selection',
|
||||
} as const
|
||||
|
||||
export type RuntimeMessageType = typeof RuntimeMessageTypes[keyof typeof RuntimeMessageTypes]
|
||||
|
||||
type MessagesWithClipPayload = typeof RuntimeMessageTypes.OpenPopupWithSelection
|
||||
|
||||
export type ClipPayload = {
|
||||
title: string
|
||||
content: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type RuntimeMessageReturnTypes = {
|
||||
[RuntimeMessageTypes.GetArticle]: ClipPayload
|
||||
[RuntimeMessageTypes.GetSelection]: ClipPayload
|
||||
[RuntimeMessageTypes.HasSelection]: boolean
|
||||
[RuntimeMessageTypes.GetFullPage]: ClipPayload
|
||||
[RuntimeMessageTypes.OpenPopupWithSelection]: void
|
||||
[RuntimeMessageTypes.StartNodeSelection]: void
|
||||
}
|
||||
|
||||
export type RuntimeMessage =
|
||||
| {
|
||||
type: MessagesWithClipPayload
|
||||
payload: ClipPayload
|
||||
}
|
||||
| {
|
||||
type: Exclude<RuntimeMessageType, MessagesWithClipPayload>
|
||||
}
|
||||
14
packages/clipper/src/utils/sendMessageToActiveTab.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { tabs } from 'webextension-polyfill'
|
||||
import { RuntimeMessageReturnTypes, RuntimeMessageType } from '../types/message'
|
||||
|
||||
export default async function sendMessageToActiveTab<T extends RuntimeMessageType>(
|
||||
type: T,
|
||||
): Promise<RuntimeMessageReturnTypes[T] | undefined> {
|
||||
const [activeTab] = await tabs.query({ active: true, currentWindow: true, windowType: 'normal' })
|
||||
|
||||
if (!activeTab || !activeTab.id) {
|
||||
return
|
||||
}
|
||||
|
||||
return await tabs.sendMessage(activeTab.id, { type })
|
||||
}
|
||||
28
packages/clipper/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"skipLibCheck": true,
|
||||
"lib": [
|
||||
"ES2022"
|
||||
],
|
||||
"target": "ES2019",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"allowJs": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"isolatedModules": false,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"newLine": "lf",
|
||||
"declarationDir": "dist/@types",
|
||||
"baseUrl": ".",
|
||||
"typeRoots": [
|
||||
"../../node_modules/@types",
|
||||
"node_modules/@types"
|
||||
],
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
9
packages/clipper/webpack.config.dev.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const { merge } = require('webpack-merge')
|
||||
const config = require('./webpack.config.js')
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
return merge(config(env, argv), {
|
||||
mode: 'development',
|
||||
devtool: 'cheap-module-source-map',
|
||||
})
|
||||
}
|
||||
69
packages/clipper/webpack.config.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const CopyPlugin = require('copy-webpack-plugin')
|
||||
const package = require('./package.json')
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
const isProd = !argv.watch
|
||||
|
||||
return {
|
||||
entry: {
|
||||
background: './src/background/background.ts',
|
||||
content: './src/content/content.ts',
|
||||
},
|
||||
output: {
|
||||
filename: '[name].js',
|
||||
},
|
||||
plugins: [
|
||||
new CopyPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: '../web/dist',
|
||||
to: './web',
|
||||
globOptions: {
|
||||
ignore: isProd ? ['**/app.js.map'] : [],
|
||||
},
|
||||
},
|
||||
{
|
||||
from: `./src/manifest.v${process.env.MANIFEST_VERSION || 2}.json`,
|
||||
to: './manifest.json',
|
||||
transform: (content) => {
|
||||
const manifest = JSON.parse(content.toString())
|
||||
manifest.version = package.version
|
||||
if (process.env.EXT_TARGET === 'chromium') {
|
||||
delete manifest.browser_specific_settings
|
||||
}
|
||||
return JSON.stringify(manifest, null, 2)
|
||||
},
|
||||
},
|
||||
{
|
||||
from: './src/popup',
|
||||
to: './popup',
|
||||
},
|
||||
{
|
||||
from: './images',
|
||||
to: './images',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
extensions: ['.ts', '.js'],
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(js|ts)$/,
|
||||
exclude: /node_modules/,
|
||||
use: [
|
||||
'babel-loader',
|
||||
{
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
transpileOnly: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
9
packages/clipper/webpack.config.prod.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const { merge } = require('webpack-merge')
|
||||
const config = require('./webpack.config.js')
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
return merge(config(env, argv), {
|
||||
mode: 'production',
|
||||
devtool: 'source-map',
|
||||
})
|
||||
}
|
||||
@@ -36,6 +36,8 @@ export enum FeatureIdentifier {
|
||||
TaskEditor = 'org.standardnotes.simple-task-editor',
|
||||
TokenVaultEditor = 'org.standardnotes.token-vault',
|
||||
|
||||
Extension = 'org.standardnotes.extension',
|
||||
|
||||
DeprecatedMarkdownVisualEditor = 'org.standardnotes.markdown-visual-editor',
|
||||
DeprecatedBoldEditor = 'org.standardnotes.bold-editor',
|
||||
DeprecatedMarkdownBasicEditor = 'org.standardnotes.simple-markdown-editor',
|
||||
|
||||
@@ -43,5 +43,13 @@ export function clientFeatures(): FeatureDescription[] {
|
||||
permission_name: PermissionName.Files,
|
||||
description: '',
|
||||
},
|
||||
{
|
||||
name: 'Extension',
|
||||
availableInSubscriptions: [SubscriptionName.PlusPlan, SubscriptionName.ProPlan],
|
||||
availableInRoles: [RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser],
|
||||
identifier: FeatureIdentifier.Extension,
|
||||
permission_name: PermissionName.Extension,
|
||||
description: '',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -40,4 +40,5 @@ export enum PermissionName {
|
||||
UniversalSecondFactor = 'server:universal-second-factor',
|
||||
SubscriptionSharing = 'server:subscription-sharing',
|
||||
SuperEditor = 'editor:super-editor',
|
||||
Extension = 'app:extension',
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -17,6 +17,7 @@ import {
|
||||
DisplayOptions,
|
||||
ItemsKeyInterface,
|
||||
ItemContent,
|
||||
DecryptedPayload,
|
||||
} from '@standardnotes/models'
|
||||
|
||||
export interface ItemsClientInterface {
|
||||
@@ -37,6 +38,15 @@ export interface ItemsClientInterface {
|
||||
|
||||
createPayloadFromObject(object: DecryptedTransferPayload): DecryptedPayloadInterface
|
||||
|
||||
createTemplateItem<
|
||||
C extends ItemContent = ItemContent,
|
||||
I extends DecryptedItemInterface<C> = DecryptedItemInterface<C>,
|
||||
>(
|
||||
contentType: ContentType,
|
||||
content?: C,
|
||||
override?: Partial<DecryptedPayload<C>>,
|
||||
): I
|
||||
|
||||
get trashedItems(): SNNote[]
|
||||
|
||||
setPrimaryItemDisplayOptions(options: DisplayOptions): void
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export const ValidAppViewRoutes = ['u2f'] as const
|
||||
export const ValidAppViewRoutes = ['u2f', 'extension'] as const
|
||||
|
||||
export type AppViewRouteParam = typeof ValidAppViewRoutes[number]
|
||||
|
||||
@@ -102,7 +102,9 @@ export class RouteParser implements RouteParserInterface {
|
||||
return RouteType.Onboarding
|
||||
}
|
||||
|
||||
if (this.path !== RootRoutes.None) {
|
||||
const isIndexPath = this.path.endsWith('index.html')
|
||||
|
||||
if (this.path !== RootRoutes.None && !isIndexPath) {
|
||||
return RouteType.None
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
"ts-jest": "^29.0.3",
|
||||
"ts-loader": "^9.4.2",
|
||||
"typescript": "*",
|
||||
"webextension-polyfill": "^0.10.0",
|
||||
"webpack": "*",
|
||||
"webpack-dev-server": "*",
|
||||
"webpack-merge": "^5.8.0"
|
||||
|
||||
@@ -410,8 +410,8 @@ export class WebApplication extends SNApplication implements WebApplicationInter
|
||||
return this.getViewControllerManager().subscriptionController.hasValidSubscription()
|
||||
}
|
||||
|
||||
openPurchaseFlow(): void {
|
||||
this.getViewControllerManager().purchaseFlowController.openPurchaseFlow()
|
||||
async openPurchaseFlow() {
|
||||
await this.getViewControllerManager().purchaseFlowController.openPurchaseFlow()
|
||||
}
|
||||
|
||||
addNativeMobileEventListener = (listener: NativeMobileEventListener) => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import PreferencesViewWrapper from '@/Components/Preferences/PreferencesViewWrap
|
||||
import ChallengeModal from '@/Components/ChallengeModal/ChallengeModal'
|
||||
import NotesContextMenu from '@/Components/NotesContextMenu/NotesContextMenu'
|
||||
import PurchaseFlowWrapper from '@/Components/PurchaseFlow/PurchaseFlowWrapper'
|
||||
import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { FunctionComponent, useCallback, useEffect, useMemo, useState, lazy } from 'react'
|
||||
import RevisionHistoryModal from '@/Components/RevisionHistoryModal/RevisionHistoryModal'
|
||||
import PremiumModalProvider from '@/Hooks/usePremiumModal'
|
||||
import ConfirmSignoutContainer from '@/Components/ConfirmSignoutModal/ConfirmSignoutModal'
|
||||
@@ -36,6 +36,8 @@ type Props = {
|
||||
mainApplicationGroup: ApplicationGroup
|
||||
}
|
||||
|
||||
const LazyLoadedClipperView = lazy(() => import('../ClipperView/ClipperView'))
|
||||
|
||||
const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicationGroup }) => {
|
||||
const platformString = getPlatformString()
|
||||
const [launched, setLaunched] = useState(false)
|
||||
@@ -174,6 +176,40 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
|
||||
return <AndroidBackHandlerProvider application={application}>{renderChallenges()}</AndroidBackHandlerProvider>
|
||||
}
|
||||
|
||||
const route = application.routeService.getRoute()
|
||||
|
||||
if (route.type === RouteType.AppViewRoute && route.appViewRouteParam === 'extension') {
|
||||
return (
|
||||
<ApplicationProvider application={application}>
|
||||
<CommandProvider service={application.keyboardService}>
|
||||
<AndroidBackHandlerProvider application={application}>
|
||||
<ResponsivePaneProvider paneController={application.getViewControllerManager().paneController}>
|
||||
<PremiumModalProvider
|
||||
application={application}
|
||||
featuresController={viewControllerManager.featuresController}
|
||||
>
|
||||
<LinkingControllerProvider controller={viewControllerManager.linkingController}>
|
||||
<FileDragNDropProvider
|
||||
application={application}
|
||||
featuresController={viewControllerManager.featuresController}
|
||||
filesController={viewControllerManager.filesController}
|
||||
>
|
||||
<LazyLoadedClipperView
|
||||
viewControllerManager={viewControllerManager}
|
||||
applicationGroup={mainApplicationGroup}
|
||||
/>
|
||||
<ToastContainer />
|
||||
{renderChallenges()}
|
||||
</FileDragNDropProvider>
|
||||
</LinkingControllerProvider>
|
||||
</PremiumModalProvider>
|
||||
</ResponsivePaneProvider>
|
||||
</AndroidBackHandlerProvider>
|
||||
</CommandProvider>
|
||||
</ApplicationProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ApplicationProvider application={application}>
|
||||
<CommandProvider service={application.keyboardService}>
|
||||
@@ -208,7 +244,6 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
|
||||
{renderChallenges()}
|
||||
<>
|
||||
<NotesContextMenu
|
||||
application={application}
|
||||
navigationController={viewControllerManager.navigationController}
|
||||
notesController={viewControllerManager.notesController}
|
||||
linkingController={viewControllerManager.linkingController}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -7,18 +6,16 @@ import Popover from '../Popover/Popover'
|
||||
import RoundIconButton from '../Button/RoundIconButton'
|
||||
import { getIconAndTintForNoteType } from '@/Utils/Items/Icons/getIconAndTintForNoteType'
|
||||
import { CHANGE_EDITOR_COMMAND, keyboardStringForShortcut } from '@standardnotes/ui-services'
|
||||
import { useApplication } from '../ApplicationProvider'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
viewControllerManager: ViewControllerManager
|
||||
onClickPreprocessing?: () => Promise<void>
|
||||
}
|
||||
|
||||
const ChangeEditorButton: FunctionComponent<Props> = ({
|
||||
application,
|
||||
viewControllerManager,
|
||||
onClickPreprocessing,
|
||||
}: Props) => {
|
||||
const ChangeEditorButton: FunctionComponent<Props> = ({ viewControllerManager, onClickPreprocessing }: Props) => {
|
||||
const application = useApplication()
|
||||
|
||||
const note = viewControllerManager.notesController.firstSelectedNote
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useApplication } from '../ApplicationProvider'
|
||||
import Icon from '../Icon/Icon'
|
||||
import { confirmDialog } from '@standardnotes/ui-services'
|
||||
import { BlocksEditorComposer } from '../SuperEditor/BlocksEditorComposer'
|
||||
import { BlocksEditor } from '../SuperEditor/BlocksEditor'
|
||||
import { SNNote } from '@standardnotes/snjs'
|
||||
import { NoteSyncController } from '@/Controllers/NoteSyncController'
|
||||
import LinkedItemBubblesContainer from '../LinkedItems/LinkedItemBubblesContainer'
|
||||
import { LinkingController } from '@/Controllers/LinkingController'
|
||||
import Button from '../Button/Button'
|
||||
import Spinner from '../Spinner/Spinner'
|
||||
|
||||
const ClippedNoteView = ({
|
||||
note,
|
||||
linkingController,
|
||||
clearClip,
|
||||
isFirefoxPopup,
|
||||
}: {
|
||||
note: SNNote
|
||||
linkingController: LinkingController
|
||||
clearClip: () => void
|
||||
isFirefoxPopup: boolean
|
||||
}) => {
|
||||
const application = useApplication()
|
||||
|
||||
const syncController = useRef(new NoteSyncController(application, note))
|
||||
useEffect(() => {
|
||||
const currentController = syncController.current
|
||||
return () => {
|
||||
currentController.deinit()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const [title, setTitle] = useState(() => note.title)
|
||||
useEffect(() => {
|
||||
void syncController.current.saveAndAwaitLocalPropagation({
|
||||
title,
|
||||
isUserModified: true,
|
||||
dontGeneratePreviews: true,
|
||||
})
|
||||
}, [application.items, title])
|
||||
|
||||
const handleChange = useCallback(async (value: string, preview: string) => {
|
||||
void syncController.current.saveAndAwaitLocalPropagation({
|
||||
text: value,
|
||||
isUserModified: true,
|
||||
previews: {
|
||||
previewPlain: preview,
|
||||
previewHtml: undefined,
|
||||
},
|
||||
})
|
||||
}, [])
|
||||
|
||||
const [isDiscarding, setIsDiscarding] = useState(false)
|
||||
const discardNote = useCallback(async () => {
|
||||
if (
|
||||
await confirmDialog({
|
||||
text: 'Are you sure you want to discard this clip?',
|
||||
confirmButtonText: 'Discard',
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
) {
|
||||
setIsDiscarding(true)
|
||||
application.mutator
|
||||
.deleteItem(note)
|
||||
.then(() => {
|
||||
if (isFirefoxPopup) {
|
||||
window.close()
|
||||
}
|
||||
clearClip()
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setIsDiscarding(false))
|
||||
}
|
||||
}, [application.mutator, clearClip, isFirefoxPopup, note])
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<div className="border-b border-border p-3">
|
||||
<div className="mb-3 flex w-full items-center gap-3">
|
||||
{!isFirefoxPopup && (
|
||||
<Button className="flex items-center justify-center" fullWidth onClick={clearClip} disabled={isDiscarding}>
|
||||
<Icon type="arrow-left" className="mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="flex items-center justify-center"
|
||||
fullWidth
|
||||
primary
|
||||
colorStyle="danger"
|
||||
onClick={discardNote}
|
||||
disabled={isDiscarding}
|
||||
>
|
||||
{isDiscarding ? (
|
||||
<Spinner className="h-6 w-6 text-danger-contrast" />
|
||||
) : (
|
||||
<>
|
||||
<Icon type="trash-filled" className="mr-2" />
|
||||
Discard
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<input
|
||||
className="w-full text-base font-semibold"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(event) => {
|
||||
setTitle(event.target.value)
|
||||
}}
|
||||
/>
|
||||
<LinkedItemBubblesContainer linkingController={linkingController} item={note} hideToggle />
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<BlocksEditorComposer initialValue={note.text}>
|
||||
<BlocksEditor onChange={handleChange}></BlocksEditor>
|
||||
</BlocksEditorComposer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ClippedNoteView
|
||||
@@ -0,0 +1,327 @@
|
||||
import { ApplicationGroup } from '@/Application/ApplicationGroup'
|
||||
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
|
||||
import { SNLogoFull } from '@standardnotes/icons'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { AccountMenuPane } from '../AccountMenu/AccountMenuPane'
|
||||
import MenuPaneSelector from '../AccountMenu/MenuPaneSelector'
|
||||
import { useApplication } from '../ApplicationProvider'
|
||||
import Icon from '../Icon/Icon'
|
||||
import Menu from '../Menu/Menu'
|
||||
import MenuItem from '../Menu/MenuItem'
|
||||
import { storage as extensionStorage, windows } from 'webextension-polyfill'
|
||||
import sendMessageToActiveTab from '@standardnotes/clipper/src/utils/sendMessageToActiveTab'
|
||||
import { ClipPayload, RuntimeMessageTypes } from '@standardnotes/clipper/src/types/message'
|
||||
import { confirmDialog } from '@standardnotes/ui-services'
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ContentType,
|
||||
FeatureIdentifier,
|
||||
FeatureStatus,
|
||||
NoteContent,
|
||||
NoteType,
|
||||
SNNote,
|
||||
} from '@standardnotes/snjs'
|
||||
import { addToast, ToastType } from '@standardnotes/toast'
|
||||
import { getSuperJSONFromClipPayload } from './getSuperJSONFromClipHTML'
|
||||
import ClippedNoteView from './ClippedNoteView'
|
||||
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon'
|
||||
import Button from '../Button/Button'
|
||||
import { openSubscriptionDashboard } from '@/Utils/ManageSubscription'
|
||||
import { useStateRef } from '@/Hooks/useStateRef'
|
||||
|
||||
const Header = () => (
|
||||
<div className="flex items-center border-b border-border p-1 px-3 py-2 text-base font-semibold text-info-contrast">
|
||||
<SNLogoFull className="h-7" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const ClipperView = ({
|
||||
viewControllerManager,
|
||||
applicationGroup,
|
||||
}: {
|
||||
viewControllerManager: ViewControllerManager
|
||||
applicationGroup: ApplicationGroup
|
||||
}) => {
|
||||
const application = useApplication()
|
||||
|
||||
const [currentWindow, setCurrentWindow] = useState<Awaited<ReturnType<typeof windows.getCurrent>>>()
|
||||
useEffect(() => {
|
||||
windows
|
||||
.getCurrent({
|
||||
populate: true,
|
||||
})
|
||||
.then((window) => {
|
||||
setCurrentWindow(window)
|
||||
})
|
||||
.catch(console.error)
|
||||
}, [])
|
||||
const isFirefoxPopup = !!currentWindow && currentWindow.type === 'popup' && currentWindow.incognito === false
|
||||
|
||||
const [user, setUser] = useState(() => application.getUser())
|
||||
const [isEntitledToExtension, setIsEntitled] = useState(
|
||||
() => application.features.getFeatureStatus(FeatureIdentifier.Extension) === FeatureStatus.Entitled,
|
||||
)
|
||||
const isEntitledRef = useStateRef(isEntitledToExtension)
|
||||
const hasSubscription = application.hasValidSubscription()
|
||||
useEffect(() => {
|
||||
return application.addEventObserver(async (event) => {
|
||||
switch (event) {
|
||||
case ApplicationEvent.SignedIn:
|
||||
case ApplicationEvent.SignedOut:
|
||||
case ApplicationEvent.UserRolesChanged:
|
||||
setUser(application.getUser())
|
||||
setIsEntitled(application.features.getFeatureStatus(FeatureIdentifier.Extension) === FeatureStatus.Entitled)
|
||||
break
|
||||
case ApplicationEvent.FeaturesUpdated:
|
||||
setIsEntitled(application.features.getFeatureStatus(FeatureIdentifier.Extension) === FeatureStatus.Entitled)
|
||||
break
|
||||
}
|
||||
})
|
||||
}, [application])
|
||||
|
||||
const [menuPane, setMenuPane] = useState<AccountMenuPane>()
|
||||
|
||||
const activateRegisterPane = useCallback(() => {
|
||||
setMenuPane(AccountMenuPane.Register)
|
||||
}, [setMenuPane])
|
||||
|
||||
const activateSignInPane = useCallback(() => {
|
||||
setMenuPane(AccountMenuPane.SignIn)
|
||||
}, [setMenuPane])
|
||||
|
||||
const showSignOutConfirmation = useCallback(async () => {
|
||||
if (
|
||||
await confirmDialog({
|
||||
title: 'Sign Out',
|
||||
text: 'Are you sure you want to sign out?',
|
||||
confirmButtonText: 'Sign Out',
|
||||
confirmButtonStyle: 'danger',
|
||||
cancelButtonText: 'Cancel',
|
||||
})
|
||||
) {
|
||||
await application.user.signOut()
|
||||
}
|
||||
}, [application.user])
|
||||
|
||||
const [hasSelection, setHasSelection] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const checkIfPageHasSelection = async () => {
|
||||
setHasSelection(Boolean(await sendMessageToActiveTab(RuntimeMessageTypes.HasSelection)))
|
||||
}
|
||||
|
||||
void checkIfPageHasSelection()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}, [user])
|
||||
|
||||
const [clipPayload, setClipPayload] = useState<ClipPayload>()
|
||||
useEffect(() => {
|
||||
const getClipFromStorage = async () => {
|
||||
const result = await extensionStorage.local.get('clip')
|
||||
if (!result.clip) {
|
||||
return
|
||||
}
|
||||
setClipPayload(result.clip)
|
||||
void extensionStorage.local.remove('clip')
|
||||
}
|
||||
|
||||
void getClipFromStorage()
|
||||
}, [])
|
||||
|
||||
const clearClip = useCallback(() => {
|
||||
setClipPayload(undefined)
|
||||
}, [])
|
||||
|
||||
const [clippedNote, setClippedNote] = useState<SNNote>()
|
||||
useEffect(() => {
|
||||
if (!isEntitledRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
async function createNoteFromClip() {
|
||||
if (!clipPayload) {
|
||||
setClippedNote(undefined)
|
||||
return
|
||||
}
|
||||
if (!clipPayload.content) {
|
||||
addToast({
|
||||
type: ToastType.Error,
|
||||
message: 'No content to clip',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const editorStateJSON = await getSuperJSONFromClipPayload(clipPayload)
|
||||
|
||||
const note = application.items.createTemplateItem<NoteContent, SNNote>(ContentType.Note, {
|
||||
title: clipPayload.title,
|
||||
text: editorStateJSON,
|
||||
editorIdentifier: FeatureIdentifier.SuperEditor,
|
||||
noteType: NoteType.Super,
|
||||
references: [],
|
||||
})
|
||||
|
||||
void application.items.insertItem(note).then((note) => {
|
||||
setClippedNote(note as SNNote)
|
||||
addToast({
|
||||
type: ToastType.Success,
|
||||
message: 'Note clipped successfully',
|
||||
})
|
||||
void application.sync.sync()
|
||||
})
|
||||
}
|
||||
|
||||
void createNoteFromClip()
|
||||
}, [application.items, application.sync, clipPayload, isEntitledRef])
|
||||
|
||||
const upgradePlan = useCallback(async () => {
|
||||
if (hasSubscription) {
|
||||
await openSubscriptionDashboard(application)
|
||||
} else {
|
||||
await application.openPurchaseFlow()
|
||||
}
|
||||
window.close()
|
||||
}, [application, hasSubscription])
|
||||
|
||||
if (user && !isEntitledToExtension) {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<div className="px-3 py-3">
|
||||
<div
|
||||
className="mx-auto mb-5 flex h-24 w-24 items-center justify-center rounded-[50%] bg-contrast"
|
||||
aria-hidden={true}
|
||||
>
|
||||
<Icon className={`h-12 w-12 ${PremiumFeatureIconClass}`} size={'custom'} type={PremiumFeatureIconName} />
|
||||
</div>
|
||||
<div className="mb-1 text-center text-lg font-bold">Enable Advanced Features</div>
|
||||
<div className="mb-3 text-center">
|
||||
To take advantage of <span className="font-semibold">Web Clipper</span> and other advanced features, upgrade
|
||||
your current plan.
|
||||
</div>
|
||||
<Button className="mb-2" fullWidth primary onClick={upgradePlan}>
|
||||
Upgrade
|
||||
</Button>
|
||||
<Button fullWidth onClick={showSignOutConfirmation}>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (clippedNote) {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<ClippedNoteView
|
||||
note={clippedNote}
|
||||
key={clippedNote.uuid}
|
||||
linkingController={viewControllerManager.linkingController}
|
||||
clearClip={clearClip}
|
||||
isFirefoxPopup={isFirefoxPopup}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
{menuPane ? (
|
||||
<div className="py-1">
|
||||
<MenuPaneSelector
|
||||
viewControllerManager={viewControllerManager}
|
||||
application={application}
|
||||
mainApplicationGroup={applicationGroup}
|
||||
menuPane={menuPane}
|
||||
setMenuPane={setMenuPane}
|
||||
closeMenu={() => setMenuPane(undefined)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Menu a11yLabel="User account menu" isOpen={true}>
|
||||
<MenuItem onClick={activateRegisterPane}>
|
||||
<Icon type="user" className="mr-2 h-6 w-6 text-neutral md:h-5 md:w-5" />
|
||||
Create free account
|
||||
</MenuItem>
|
||||
<MenuItem onClick={activateSignInPane}>
|
||||
<Icon type="signIn" className="mr-2 h-6 w-6 text-neutral md:h-5 md:w-5" />
|
||||
Sign in
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<div>
|
||||
<Menu a11yLabel="Extension menu" isOpen={true} className="pb-1">
|
||||
<MenuItem
|
||||
onClick={async () => {
|
||||
const payload = await sendMessageToActiveTab(RuntimeMessageTypes.GetFullPage)
|
||||
if (!payload) {
|
||||
return
|
||||
}
|
||||
setClipPayload(payload)
|
||||
}}
|
||||
>
|
||||
Clip full page
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={async () => {
|
||||
const payload = await sendMessageToActiveTab(RuntimeMessageTypes.GetArticle)
|
||||
if (!payload) {
|
||||
return
|
||||
}
|
||||
setClipPayload(payload)
|
||||
}}
|
||||
>
|
||||
Clip article
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={!hasSelection}
|
||||
onClick={async () => {
|
||||
const payload = await sendMessageToActiveTab(RuntimeMessageTypes.GetSelection)
|
||||
if (!payload) {
|
||||
return
|
||||
}
|
||||
setClipPayload(payload)
|
||||
}}
|
||||
>
|
||||
Clip current selection
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={async () => {
|
||||
void sendMessageToActiveTab(RuntimeMessageTypes.StartNodeSelection)
|
||||
window.close()
|
||||
}}
|
||||
>
|
||||
Select elements to clip
|
||||
</MenuItem>
|
||||
<div className="border-t border-border px-3 pt-3 pb-1 text-base text-foreground">
|
||||
<div>You're signed in as:</div>
|
||||
<div className="wrap my-0.5 font-bold">{user.email}</div>
|
||||
<span className="text-neutral">{application.getHost()}</span>
|
||||
</div>
|
||||
<MenuItem onClick={showSignOutConfirmation}>
|
||||
<Icon type="signOut" className="mr-2 h-6 w-6 text-neutral" />
|
||||
Sign out
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ClipperView
|
||||
@@ -0,0 +1,59 @@
|
||||
import { $createParagraphNode, $getRoot, $insertNodes, LexicalNode } from 'lexical'
|
||||
import { $generateNodesFromDOM } from '../SuperEditor/Lexical/Utils/generateNodesFromDOM'
|
||||
import { createHeadlessEditor } from '@lexical/headless'
|
||||
import { BlockEditorNodes } from '../SuperEditor/Lexical/Nodes/AllNodes'
|
||||
import BlocksEditorTheme from '../SuperEditor/Lexical/Theme/Theme'
|
||||
import { ClipPayload } from '@standardnotes/clipper/src/types/message'
|
||||
|
||||
export const getSuperJSONFromClipPayload = async (clipPayload: ClipPayload) => {
|
||||
const editor = createHeadlessEditor({
|
||||
namespace: 'BlocksEditor',
|
||||
theme: BlocksEditorTheme,
|
||||
editable: false,
|
||||
onError: (error: Error) => console.error(error),
|
||||
nodes: [...BlockEditorNodes],
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
editor.update(() => {
|
||||
const parser = new DOMParser()
|
||||
|
||||
const clipSourceDOM = parser.parseFromString(
|
||||
`<p>Clip source: <a href="${clipPayload.url}">${clipPayload.url}</a></p>`,
|
||||
'text/html',
|
||||
)
|
||||
const clipSourceParagraphNode = $generateNodesFromDOM(editor, clipSourceDOM).concat(
|
||||
$createParagraphNode(),
|
||||
$createParagraphNode(),
|
||||
)
|
||||
$getRoot().select()
|
||||
$insertNodes(clipSourceParagraphNode)
|
||||
|
||||
const dom = parser.parseFromString(clipPayload.content, 'text/html')
|
||||
const generatedNodes = $generateNodesFromDOM(editor, dom)
|
||||
const nodesToInsert: LexicalNode[] = []
|
||||
generatedNodes.forEach((node) => {
|
||||
const type = node.getType()
|
||||
|
||||
// Wrap text & link nodes with paragraph since they can't
|
||||
// be top-level nodes in Super
|
||||
if (type === 'text' || type === 'link') {
|
||||
const paragraphNode = $createParagraphNode()
|
||||
paragraphNode.append(node)
|
||||
nodesToInsert.push(paragraphNode)
|
||||
return
|
||||
} else {
|
||||
nodesToInsert.push(node)
|
||||
}
|
||||
|
||||
nodesToInsert.push($createParagraphNode())
|
||||
})
|
||||
$getRoot().selectEnd()
|
||||
$insertNodes(nodesToInsert.concat($createParagraphNode()))
|
||||
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
return JSON.stringify(editor.getEditorState().toJSON())
|
||||
}
|
||||
@@ -55,7 +55,6 @@ const ContextMenuCell = ({
|
||||
notesController: NotesController
|
||||
historyModalController: HistoryModalController
|
||||
}) => {
|
||||
const application = useApplication()
|
||||
const [contextMenuVisible, setContextMenuVisible] = useState(false)
|
||||
const anchorElementRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
@@ -112,7 +111,6 @@ const ContextMenuCell = ({
|
||||
{allItemsAreNotes && (
|
||||
<NotesOptions
|
||||
notes={items as SNNote[]}
|
||||
application={application}
|
||||
navigationController={navigationController}
|
||||
notesController={notesController}
|
||||
linkingController={linkingController}
|
||||
@@ -461,7 +459,6 @@ const ContentTableView = ({
|
||||
<Menu className="select-none" a11yLabel="Note context menu" isOpen={true}>
|
||||
<NotesOptions
|
||||
notes={[contextMenuItem]}
|
||||
application={application}
|
||||
navigationController={navigationController}
|
||||
notesController={notesController}
|
||||
linkingController={linkingController}
|
||||
|
||||
@@ -19,7 +19,7 @@ const UpgradeNow = ({ application, featuresController, subscriptionContoller }:
|
||||
if (hasAccount && application.isNativeIOS()) {
|
||||
application.showPremiumModal()
|
||||
} else {
|
||||
application.openPurchaseFlow()
|
||||
void application.openPurchaseFlow()
|
||||
}
|
||||
}, [application, hasAccount])
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ export const IconNameToSvgMapping = {
|
||||
pencil: icons.PencilIcon,
|
||||
pin: icons.PinIcon,
|
||||
restore: icons.RestoreIcon,
|
||||
save: icons.SaveIcon,
|
||||
search: icons.SearchIcon,
|
||||
security: icons.SecurityIcon,
|
||||
server: icons.ServerIcon,
|
||||
|
||||
@@ -17,14 +17,21 @@ import RoundIconButton from '../Button/RoundIconButton'
|
||||
type Props = {
|
||||
linkingController: LinkingController
|
||||
item: DecryptedItemInterface
|
||||
hideToggle?: boolean
|
||||
}
|
||||
|
||||
const LinkedItemBubblesContainer = ({ item, linkingController }: Props) => {
|
||||
const LinkedItemBubblesContainer = ({ item, linkingController, hideToggle = false }: Props) => {
|
||||
const { toggleAppPane } = useResponsiveAppPane()
|
||||
|
||||
const commandService = useCommandService()
|
||||
|
||||
const { unlinkItemFromSelectedItem: unlinkItem, activateItem } = linkingController
|
||||
const { unlinkItems, activateItem } = linkingController
|
||||
const unlinkItem = useCallback(
|
||||
async (itemToUnlink: LinkableItem) => {
|
||||
void unlinkItems(item, itemToUnlink)
|
||||
},
|
||||
[item, unlinkItems],
|
||||
)
|
||||
|
||||
const { notesLinkedToItem, filesLinkedToItem, tagsLinkedToItem, notesLinkingToItem, filesLinkingToItem } =
|
||||
useItemLinks(item)
|
||||
@@ -143,7 +150,7 @@ const LinkedItemBubblesContainer = ({ item, linkingController }: Props) => {
|
||||
item={item}
|
||||
/>
|
||||
</div>
|
||||
{itemsToDisplay.length > 0 && (
|
||||
{itemsToDisplay.length > 0 && !hideToggle && (
|
||||
<RoundIconButton
|
||||
id="toggle-linking-container"
|
||||
label="Toggle linked items container"
|
||||
|
||||
@@ -21,7 +21,7 @@ type MenuItemProps = {
|
||||
|
||||
const MenuItem = forwardRef(
|
||||
(
|
||||
{ children, className = '', icon, iconClassName, tabIndex, shortcut, ...props }: MenuItemProps,
|
||||
{ children, className = '', icon, iconClassName, tabIndex, shortcut, disabled, ...props }: MenuItemProps,
|
||||
ref: Ref<HTMLButtonElement>,
|
||||
) => {
|
||||
return (
|
||||
@@ -34,9 +34,11 @@ const MenuItem = forwardRef(
|
||||
'flex w-full cursor-pointer select-none border-0 bg-transparent px-3 py-2 text-left md:py-1.5',
|
||||
'text-mobile-menu-item text-text hover:bg-contrast hover:text-foreground',
|
||||
'focus:bg-info-backdrop focus:shadow-none md:text-tablet-menu-item lg:text-menu-item',
|
||||
'disabled:cursor-not-allowed disabled:opacity-60',
|
||||
className,
|
||||
className.includes('items-') ? '' : 'items-center',
|
||||
)}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{shortcut && <KeyboardShortcutIndicator className="mr-2" shortcut={shortcut} />}
|
||||
|
||||
@@ -47,7 +47,6 @@ const MultipleSelectedNotes = ({
|
||||
<PinNoteButton notesController={notesController} />
|
||||
</div>
|
||||
<NotesOptionsPanel
|
||||
application={application}
|
||||
navigationController={navigationController}
|
||||
notesController={notesController}
|
||||
linkingController={linkingController}
|
||||
|
||||
@@ -19,7 +19,7 @@ const NoSubscriptionBanner = ({
|
||||
if (application.isNativeIOS()) {
|
||||
application.showPremiumModal()
|
||||
} else {
|
||||
application.openPurchaseFlow()
|
||||
void application.openPurchaseFlow()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { noteTypeForEditorIdentifier } from '@standardnotes/features'
|
||||
import { InfoStrings } from '@standardnotes/services'
|
||||
import {
|
||||
NoteMutator,
|
||||
SNNote,
|
||||
SNTag,
|
||||
NoteContent,
|
||||
DecryptedItemInterface,
|
||||
PayloadEmitSource,
|
||||
PrefKey,
|
||||
} from '@standardnotes/models'
|
||||
import { SNNote, SNTag, NoteContent, DecryptedItemInterface, PayloadEmitSource, PrefKey } from '@standardnotes/models'
|
||||
import { UuidString } from '@standardnotes/snjs'
|
||||
import { removeFromArray, Deferred } from '@standardnotes/utils'
|
||||
import { removeFromArray } from '@standardnotes/utils'
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { ItemViewControllerInterface } from './ItemViewControllerInterface'
|
||||
import { TemplateNoteViewControllerOptions } from './TemplateNoteViewControllerOptions'
|
||||
import { EditorSaveTimeoutDebounce } from './EditorSaveTimeoutDebounce'
|
||||
import { log, LoggingDomain } from '@/Logging'
|
||||
import { NoteSaveFunctionParams, NoteSyncController } from '../../../Controllers/NoteSyncController'
|
||||
|
||||
export type EditorValues = {
|
||||
title: string
|
||||
text: string
|
||||
}
|
||||
|
||||
const StringEllipses = '...'
|
||||
const NotePreviewCharLimit = 160
|
||||
|
||||
export class NoteViewController implements ItemViewControllerInterface {
|
||||
public item!: SNNote
|
||||
public dealloced = false
|
||||
@@ -35,10 +23,10 @@ export class NoteViewController implements ItemViewControllerInterface {
|
||||
|
||||
private innerValueChangeObservers: ((note: SNNote, source: PayloadEmitSource) => void)[] = []
|
||||
private disposers: (() => void)[] = []
|
||||
private saveTimeout?: ReturnType<typeof setTimeout>
|
||||
private defaultTagUuid: UuidString | undefined
|
||||
private defaultTag?: SNTag
|
||||
private savingLocallyPromise: ReturnType<typeof Deferred<void>> | null = null
|
||||
|
||||
private syncController: NoteSyncController
|
||||
|
||||
constructor(
|
||||
private application: WebApplication,
|
||||
@@ -56,15 +44,17 @@ export class NoteViewController implements ItemViewControllerInterface {
|
||||
if (this.defaultTagUuid) {
|
||||
this.defaultTag = this.application.items.findItem(this.defaultTagUuid) as SNTag
|
||||
}
|
||||
|
||||
this.syncController = new NoteSyncController(this.application, this.item)
|
||||
}
|
||||
|
||||
deinit(): void {
|
||||
if (!this.savingLocallyPromise) {
|
||||
if (!this.syncController.savingLocallyPromise) {
|
||||
this.performDeinitSafely()
|
||||
return
|
||||
}
|
||||
|
||||
void this.savingLocallyPromise.promise.then(() => {
|
||||
void this.syncController.savingLocallyPromise.promise.then(() => {
|
||||
this.performDeinitSafely()
|
||||
})
|
||||
}
|
||||
@@ -80,8 +70,6 @@ export class NoteViewController implements ItemViewControllerInterface {
|
||||
;(this.item as unknown) = undefined
|
||||
|
||||
this.innerValueChangeObservers.length = 0
|
||||
|
||||
this.saveTimeout = undefined
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
@@ -185,59 +173,11 @@ export class NoteViewController implements ItemViewControllerInterface {
|
||||
}
|
||||
}
|
||||
|
||||
public async saveAndAwaitLocalPropagation(params: {
|
||||
title?: string
|
||||
text?: string
|
||||
isUserModified: boolean
|
||||
bypassDebouncer?: boolean
|
||||
dontGeneratePreviews?: boolean
|
||||
previews?: { previewPlain: string; previewHtml?: string }
|
||||
customMutate?: (mutator: NoteMutator) => void
|
||||
}): Promise<void> {
|
||||
public async saveAndAwaitLocalPropagation(params: NoteSaveFunctionParams): Promise<void> {
|
||||
if (this.needsInit) {
|
||||
throw Error('NoteViewController not initialized')
|
||||
}
|
||||
|
||||
this.savingLocallyPromise = Deferred<void>()
|
||||
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout)
|
||||
}
|
||||
|
||||
const noDebounce = params.bypassDebouncer || this.application.noAccount()
|
||||
|
||||
const syncDebouceMs = noDebounce
|
||||
? EditorSaveTimeoutDebounce.ImmediateChange
|
||||
: this.application.isNativeMobileWeb()
|
||||
? EditorSaveTimeoutDebounce.NativeMobileWeb
|
||||
: EditorSaveTimeoutDebounce.Desktop
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.saveTimeout = setTimeout(() => {
|
||||
void this.undebouncedSave({
|
||||
...params,
|
||||
onLocalPropagationComplete: () => {
|
||||
if (this.savingLocallyPromise) {
|
||||
this.savingLocallyPromise.resolve()
|
||||
}
|
||||
resolve()
|
||||
},
|
||||
})
|
||||
}, syncDebouceMs)
|
||||
})
|
||||
}
|
||||
|
||||
private async undebouncedSave(params: {
|
||||
title?: string
|
||||
text?: string
|
||||
bypassDebouncer?: boolean
|
||||
isUserModified?: boolean
|
||||
dontGeneratePreviews?: boolean
|
||||
previews?: { previewPlain: string; previewHtml?: string }
|
||||
customMutate?: (mutator: NoteMutator) => void
|
||||
onLocalPropagationComplete?: () => void
|
||||
onRemoteSyncComplete?: () => void
|
||||
}): Promise<void> {
|
||||
log(LoggingDomain.NoteView, 'Saving note', params)
|
||||
|
||||
const isTemplate = this.isTemplateNote
|
||||
@@ -246,46 +186,6 @@ export class NoteViewController implements ItemViewControllerInterface {
|
||||
await this.insertTemplatedNote()
|
||||
}
|
||||
|
||||
if (!this.application.items.findItem(this.item.uuid)) {
|
||||
void this.application.alertService.alert(InfoStrings.InvalidNote)
|
||||
return
|
||||
}
|
||||
|
||||
await this.application.mutator.changeItem(
|
||||
this.item,
|
||||
(mutator) => {
|
||||
const noteMutator = mutator as NoteMutator
|
||||
if (params.customMutate) {
|
||||
params.customMutate(noteMutator)
|
||||
}
|
||||
|
||||
if (params.title != undefined) {
|
||||
noteMutator.title = params.title
|
||||
}
|
||||
|
||||
if (params.text != undefined) {
|
||||
noteMutator.text = params.text
|
||||
}
|
||||
|
||||
if (params.previews) {
|
||||
noteMutator.preview_plain = params.previews.previewPlain
|
||||
noteMutator.preview_html = params.previews.previewHtml
|
||||
} else if (!params.dontGeneratePreviews && params.text != undefined) {
|
||||
const noteText = params.text || ''
|
||||
const truncate = noteText.length > NotePreviewCharLimit
|
||||
const substring = noteText.substring(0, NotePreviewCharLimit)
|
||||
const previewPlain = substring + (truncate ? StringEllipses : '')
|
||||
noteMutator.preview_plain = previewPlain
|
||||
noteMutator.preview_html = undefined
|
||||
}
|
||||
},
|
||||
params.isUserModified,
|
||||
)
|
||||
|
||||
void this.application.sync.sync().then(() => {
|
||||
params.onRemoteSyncComplete?.()
|
||||
})
|
||||
|
||||
params.onLocalPropagationComplete?.()
|
||||
await this.syncController.saveAndAwaitLocalPropagation(params)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,37 @@
|
||||
import { FunctionComponent } from 'react'
|
||||
import { FunctionComponent, useState } from 'react'
|
||||
import Icon from '../Icon/Icon'
|
||||
|
||||
type Props = {
|
||||
onMouseLeave: () => void
|
||||
onMouseOver: () => void
|
||||
onClick: () => void
|
||||
showLockedIcon: boolean
|
||||
lockText: string
|
||||
noteLocked: boolean
|
||||
}
|
||||
|
||||
const EditingDisabledBanner: FunctionComponent<Props> = ({
|
||||
onMouseLeave,
|
||||
onMouseOver,
|
||||
onClick,
|
||||
showLockedIcon,
|
||||
lockText,
|
||||
}) => {
|
||||
const background = showLockedIcon ? 'bg-warning-faded' : 'bg-info-faded'
|
||||
const iconColor = showLockedIcon ? 'text-accessory-tint-3' : 'text-accessory-tint-1'
|
||||
const textColor = showLockedIcon ? 'text-warning' : 'text-accessory-tint-1'
|
||||
const EditingDisabledBanner: FunctionComponent<Props> = ({ onClick, noteLocked }) => {
|
||||
const [showDisabledCopy, setShowDisabledCopy] = useState(() => noteLocked)
|
||||
|
||||
const background = showDisabledCopy ? 'bg-warning-faded' : 'bg-info-faded'
|
||||
const iconColor = showDisabledCopy ? 'text-accessory-tint-3' : 'text-accessory-tint-1'
|
||||
const textColor = showDisabledCopy ? 'text-warning' : 'text-accessory-tint-1'
|
||||
|
||||
const text = showDisabledCopy ? 'Note editing disabled.' : 'Enable editing'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex items-center ${background} cursor-pointer px-3.5 py-2 text-sm`}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onMouseOver={onMouseOver}
|
||||
onMouseLeave={() => {
|
||||
setShowDisabledCopy(true)
|
||||
}}
|
||||
onMouseOver={() => {
|
||||
setShowDisabledCopy(false)
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
{showLockedIcon ? (
|
||||
{showDisabledCopy ? (
|
||||
<Icon type="pencil-off" className={`${iconColor} mr-3 flex fill-current`} />
|
||||
) : (
|
||||
<Icon type="pencil" className={`${iconColor} mr-3 flex fill-current`} />
|
||||
)}
|
||||
<span className={textColor}>{lockText}</span>
|
||||
<span className={textColor}>{text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@ import { NoteViewController } from './Controller/NoteViewController'
|
||||
import { PlainEditor, PlainEditorInterface } from './PlainEditor/PlainEditor'
|
||||
|
||||
const MinimumStatusDuration = 400
|
||||
const NoteEditingDisabledText = 'Note editing disabled.'
|
||||
|
||||
function sortAlphabetically(array: SNComponent[]): SNComponent[] {
|
||||
return array.sort((a, b) => (a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1))
|
||||
@@ -59,12 +58,10 @@ type State = {
|
||||
editorStateDidLoad: boolean
|
||||
editorTitle: string
|
||||
isDesktop?: boolean
|
||||
lockText: string
|
||||
marginResizersEnabled?: boolean
|
||||
noteLocked: boolean
|
||||
noteStatus?: NoteStatus
|
||||
saveError?: boolean
|
||||
showLockedIcon: boolean
|
||||
showProtectedWarning: boolean
|
||||
spellcheck: boolean
|
||||
stackComponentViewers: ComponentViewerInterface[]
|
||||
@@ -116,10 +113,8 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
editorStateDidLoad: false,
|
||||
editorTitle: '',
|
||||
isDesktop: isDesktopApplication(),
|
||||
lockText: NoteEditingDisabledText,
|
||||
noteStatus: undefined,
|
||||
noteLocked: this.controller.item.locked,
|
||||
showLockedIcon: true,
|
||||
showProtectedWarning: false,
|
||||
spellcheck: true,
|
||||
stackComponentViewers: [],
|
||||
@@ -830,21 +825,8 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
|
||||
{this.state.noteLocked && (
|
||||
<EditingDisabledBanner
|
||||
onMouseLeave={() => {
|
||||
this.setState({
|
||||
lockText: NoteEditingDisabledText,
|
||||
showLockedIcon: true,
|
||||
})
|
||||
}}
|
||||
onMouseOver={() => {
|
||||
this.setState({
|
||||
lockText: 'Enable editing',
|
||||
showLockedIcon: false,
|
||||
})
|
||||
}}
|
||||
onClick={() => this.viewControllerManager.notesController.setLockSelectedNotes(!this.state.noteLocked)}
|
||||
showLockedIcon={this.state.showLockedIcon}
|
||||
lockText={this.state.lockText}
|
||||
noteLocked={this.state.noteLocked}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -886,7 +868,6 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
featuresController={this.viewControllerManager.featuresController}
|
||||
/>
|
||||
<ChangeEditorButton
|
||||
application={this.application}
|
||||
viewControllerManager={this.viewControllerManager}
|
||||
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
|
||||
/>
|
||||
@@ -895,7 +876,6 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
|
||||
/>
|
||||
<NotesOptionsPanel
|
||||
application={this.application}
|
||||
navigationController={this.viewControllerManager.navigationController}
|
||||
notesController={this.viewControllerManager.notesController}
|
||||
linkingController={this.viewControllerManager.linkingController}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import NotesOptions from '@/Components/NotesOptions/NotesOptions'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { NotesController } from '@/Controllers/NotesController/NotesController'
|
||||
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
|
||||
import { HistoryModalController } from '@/Controllers/NoteHistory/HistoryModalController'
|
||||
@@ -10,7 +9,6 @@ import { LinkingController } from '@/Controllers/LinkingController'
|
||||
import Menu from '../Menu/Menu'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
navigationController: NavigationController
|
||||
notesController: NotesController
|
||||
linkingController: LinkingController
|
||||
@@ -18,7 +16,6 @@ type Props = {
|
||||
}
|
||||
|
||||
const NotesContextMenu = ({
|
||||
application,
|
||||
navigationController,
|
||||
notesController,
|
||||
linkingController,
|
||||
@@ -49,7 +46,6 @@ const NotesContextMenu = ({
|
||||
<Menu className="select-none" a11yLabel="Note context menu" isOpen={contextMenuOpen}>
|
||||
<NotesOptions
|
||||
notes={notesController.selectedNotes}
|
||||
application={application}
|
||||
navigationController={navigationController}
|
||||
notesController={notesController}
|
||||
linkingController={linkingController}
|
||||
|
||||
@@ -34,6 +34,7 @@ import MenuSwitchButtonItem from '../Menu/MenuSwitchButtonItem'
|
||||
import MenuItem from '../Menu/MenuItem'
|
||||
import ModalOverlay from '../Modal/ModalOverlay'
|
||||
import SuperExportModal from './SuperExportModal'
|
||||
import { useApplication } from '../ApplicationProvider'
|
||||
|
||||
const iconSize = MenuItemIconSize
|
||||
const iconClassDanger = `text-danger mr-2 ${iconSize}`
|
||||
@@ -42,13 +43,14 @@ const iconClassSuccess = `text-success mr-2 ${iconSize}`
|
||||
|
||||
const NotesOptions = ({
|
||||
notes,
|
||||
application,
|
||||
navigationController,
|
||||
notesController,
|
||||
linkingController,
|
||||
historyModalController,
|
||||
closeMenu,
|
||||
}: NotesOptionsProps) => {
|
||||
const application = useApplication()
|
||||
|
||||
const [altKeyDown, setAltKeyDown] = useState(false)
|
||||
const { toggleAppPane } = useResponsiveAppPane()
|
||||
const commandService = useCommandService()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import NotesOptions from './NotesOptions'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { NotesController } from '@/Controllers/NotesController/NotesController'
|
||||
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
|
||||
import { HistoryModalController } from '@/Controllers/NoteHistory/HistoryModalController'
|
||||
@@ -11,7 +10,6 @@ import RoundIconButton from '../Button/RoundIconButton'
|
||||
import Menu from '../Menu/Menu'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
navigationController: NavigationController
|
||||
notesController: NotesController
|
||||
linkingController: LinkingController
|
||||
@@ -20,7 +18,6 @@ type Props = {
|
||||
}
|
||||
|
||||
const NotesOptionsPanel = ({
|
||||
application,
|
||||
navigationController,
|
||||
notesController,
|
||||
linkingController,
|
||||
@@ -57,7 +54,6 @@ const NotesOptionsPanel = ({
|
||||
<Menu a11yLabel="Note options menu" isOpen={isOpen}>
|
||||
<NotesOptions
|
||||
notes={notesController.selectedNotes}
|
||||
application={application}
|
||||
navigationController={navigationController}
|
||||
notesController={notesController}
|
||||
linkingController={linkingController}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { HistoryModalController } from '@/Controllers/NoteHistory/HistoryModalController'
|
||||
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
|
||||
import { NotesController } from '@/Controllers/NotesController/NotesController'
|
||||
@@ -7,7 +6,6 @@ import { SNNote } from '@standardnotes/snjs'
|
||||
|
||||
export type NotesOptionsProps = {
|
||||
notes: SNNote[]
|
||||
application: WebApplication
|
||||
navigationController: NavigationController
|
||||
notesController: NotesController
|
||||
linkingController: LinkingController
|
||||
|
||||
@@ -19,7 +19,7 @@ const NoProSubscription: FunctionComponent<Props> = ({ application, text }) => {
|
||||
if (application.isNativeIOS()) {
|
||||
application.showPremiumModal()
|
||||
} else {
|
||||
application.openPurchaseFlow()
|
||||
void application.openPurchaseFlow()
|
||||
}
|
||||
} catch (e) {
|
||||
setPurchaseFlowError(errorMessage)
|
||||
|
||||
@@ -18,7 +18,7 @@ const NoSubscription: FunctionComponent<Props> = ({ application }) => {
|
||||
if (application.isNativeIOS()) {
|
||||
application.showPremiumModal()
|
||||
} else {
|
||||
application.openPurchaseFlow()
|
||||
void application.openPurchaseFlow()
|
||||
}
|
||||
} catch (e) {
|
||||
setPurchaseFlowError(errorMessage)
|
||||
|
||||
@@ -89,7 +89,7 @@ const CreateAccount: FunctionComponent<Props> = ({ viewControllerManager, applic
|
||||
await application.register(email, password)
|
||||
|
||||
viewControllerManager.purchaseFlowController.closePurchaseFlow()
|
||||
viewControllerManager.purchaseFlowController.openPurchaseFlow()
|
||||
void viewControllerManager.purchaseFlowController.openPurchaseFlow()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
application.alertService.alert(err as string).catch(console.error)
|
||||
|
||||
@@ -75,7 +75,7 @@ const SignIn: FunctionComponent<Props> = ({ viewControllerManager, application }
|
||||
throw new Error(response.data.error?.message)
|
||||
} else {
|
||||
viewControllerManager.purchaseFlowController.closePurchaseFlow()
|
||||
viewControllerManager.purchaseFlowController.openPurchaseFlow()
|
||||
void viewControllerManager.purchaseFlowController.openPurchaseFlow()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
|
||||
@@ -45,7 +45,7 @@ const PurchaseFlowView: FunctionComponent<PurchaseFlowViewProps> = ({ viewContro
|
||||
>
|
||||
<Icon type="close" className="text-neutral" />
|
||||
</button>
|
||||
<SNLogoFull className="mb-5" />
|
||||
<SNLogoFull className="mb-5 h-7" />
|
||||
<PurchaseFlowPaneSelector
|
||||
currentPane={currentPane}
|
||||
viewControllerManager={viewControllerManager}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import { $isElementNode, DOMChildConversion, DOMConversion, DOMConversionFn, LexicalEditor, LexicalNode } from 'lexical'
|
||||
|
||||
/**
|
||||
* How you parse your html string to get a document is left up to you. In the browser you can use the native
|
||||
* DOMParser API to generate a document (see clipboard.ts), but to use in a headless environment you can use JSDom
|
||||
* or an equivilant library and pass in the document here.
|
||||
*/
|
||||
export function $generateNodesFromDOM(editor: LexicalEditor, dom: Document): Array<LexicalNode> {
|
||||
let lexicalNodes: Array<LexicalNode> = []
|
||||
const elements = dom.body ? dom.body.childNodes : []
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const element = elements[i]
|
||||
|
||||
if (!IGNORE_TAGS.has(element.nodeName)) {
|
||||
const lexicalNode = $createNodesFromDOM(element, editor)
|
||||
|
||||
if (lexicalNode !== null) {
|
||||
lexicalNodes = lexicalNodes.concat(lexicalNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lexicalNodes
|
||||
}
|
||||
|
||||
function getConversionFunction(domNode: Node, editor: LexicalEditor): DOMConversionFn | null {
|
||||
const { nodeName } = domNode
|
||||
|
||||
const cachedConversions = editor._htmlConversions.get(nodeName.toLowerCase())
|
||||
|
||||
let currentConversion: DOMConversion | null = null
|
||||
|
||||
if (cachedConversions !== undefined) {
|
||||
for (const cachedConversion of cachedConversions) {
|
||||
const domConversion = cachedConversion(domNode)
|
||||
|
||||
if (
|
||||
domConversion !== null &&
|
||||
(currentConversion === null || currentConversion.priority < domConversion.priority)
|
||||
) {
|
||||
currentConversion = domConversion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return currentConversion !== null ? currentConversion.conversion : null
|
||||
}
|
||||
|
||||
const IGNORE_TAGS = new Set(['STYLE', 'SCRIPT'])
|
||||
|
||||
function $createNodesFromDOM(
|
||||
node: Node,
|
||||
editor: LexicalEditor,
|
||||
forChildMap: Map<string, DOMChildConversion> = new Map(),
|
||||
parentLexicalNode?: LexicalNode | null | undefined,
|
||||
preformatted = false,
|
||||
): Array<LexicalNode> {
|
||||
let lexicalNodes: Array<LexicalNode> = []
|
||||
|
||||
if (IGNORE_TAGS.has(node.nodeName)) {
|
||||
return lexicalNodes
|
||||
}
|
||||
|
||||
let currentLexicalNode = null
|
||||
const transformFunction = getConversionFunction(node, editor)
|
||||
const transformOutput = transformFunction ? transformFunction(node as HTMLElement, undefined, preformatted) : null
|
||||
let postTransform = null
|
||||
|
||||
if (transformOutput !== null) {
|
||||
postTransform = transformOutput.after
|
||||
currentLexicalNode = transformOutput.node
|
||||
|
||||
if (currentLexicalNode !== null) {
|
||||
for (const [, forChildFunction] of forChildMap) {
|
||||
currentLexicalNode = forChildFunction(currentLexicalNode, parentLexicalNode)
|
||||
|
||||
if (!currentLexicalNode) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLexicalNode) {
|
||||
lexicalNodes.push(currentLexicalNode)
|
||||
}
|
||||
}
|
||||
|
||||
if (transformOutput.forChild != null) {
|
||||
forChildMap.set(node.nodeName, transformOutput.forChild)
|
||||
}
|
||||
}
|
||||
|
||||
// If the DOM node doesn't have a transformer, we don't know what
|
||||
// to do with it but we still need to process any childNodes.
|
||||
const children = node.childNodes
|
||||
let childLexicalNodes = []
|
||||
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
childLexicalNodes.push(
|
||||
...$createNodesFromDOM(
|
||||
children[i],
|
||||
editor,
|
||||
new Map(forChildMap),
|
||||
currentLexicalNode,
|
||||
preformatted || (transformOutput && transformOutput.preformatted) === true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (postTransform != null) {
|
||||
childLexicalNodes = postTransform(childLexicalNodes)
|
||||
}
|
||||
|
||||
if (currentLexicalNode == null) {
|
||||
// If it hasn't been converted to a LexicalNode, we hoist its children
|
||||
// up to the same level as it.
|
||||
lexicalNodes = lexicalNodes.concat(childLexicalNodes)
|
||||
} else {
|
||||
if ($isElementNode(currentLexicalNode)) {
|
||||
// If the current node is a ElementNode after conversion,
|
||||
// we can append all the children to it.
|
||||
currentLexicalNode.append(...childLexicalNodes)
|
||||
}
|
||||
}
|
||||
|
||||
return lexicalNodes
|
||||
}
|
||||
@@ -112,7 +112,7 @@ export class CollapsibleContainerNode extends ElementNode {
|
||||
}
|
||||
|
||||
getOpen(): boolean {
|
||||
return this.getLatest().__open
|
||||
return this.__open
|
||||
}
|
||||
|
||||
toggleOpen(): void {
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { useEffect } from 'react'
|
||||
import { $convertFromMarkdownString, TRANSFORMERS } from '@lexical/markdown'
|
||||
import { $generateNodesFromDOM } from '@lexical/html'
|
||||
import { $createParagraphNode, $createRangeSelection } from 'lexical'
|
||||
import { $createParagraphNode, $createRangeSelection, LexicalEditor } from 'lexical'
|
||||
import { handleEditorChange } from '../../Utils'
|
||||
import { SuperNotePreviewCharLimit } from '../../SuperEditor'
|
||||
import { $generateNodesFromDOM } from '../../Lexical/Utils/generateNodesFromDOM'
|
||||
|
||||
/** Note that markdown conversion does not insert new lines. See: https://github.com/facebook/lexical/issues/2815 */
|
||||
export default function ImportPlugin({
|
||||
text,
|
||||
format,
|
||||
onChange,
|
||||
customImportFunction,
|
||||
}: {
|
||||
text: string
|
||||
format: 'md' | 'html'
|
||||
onChange: (value: string, preview: string) => void
|
||||
customImportFunction?: (editor: LexicalEditor, text: string) => void
|
||||
}): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
@@ -24,19 +26,24 @@ export default function ImportPlugin({
|
||||
return
|
||||
}
|
||||
|
||||
if (customImportFunction) {
|
||||
customImportFunction(editor, text)
|
||||
return
|
||||
}
|
||||
|
||||
editor.update(() => {
|
||||
if (format === 'md') {
|
||||
$convertFromMarkdownString(text, [...TRANSFORMERS])
|
||||
} else {
|
||||
const parser = new DOMParser()
|
||||
const dom = parser.parseFromString(text, 'text/html')
|
||||
const nodes = $generateNodesFromDOM(editor, dom)
|
||||
const nodesToInsert = $generateNodesFromDOM(editor, dom)
|
||||
const selection = $createRangeSelection()
|
||||
const newLineNode = $createParagraphNode()
|
||||
selection.insertNodes([newLineNode, ...nodes])
|
||||
selection.insertNodes([newLineNode, ...nodesToInsert])
|
||||
}
|
||||
})
|
||||
}, [editor, text, format])
|
||||
}, [editor, text, format, customImportFunction])
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerUpdateListener(({ editorState }) => {
|
||||
|
||||
114
packages/web/src/javascripts/Controllers/NoteSyncController.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { NoteMutator, SNNote } from '@standardnotes/models'
|
||||
import { InfoStrings } from '@standardnotes/snjs'
|
||||
import { Deferred } from '@standardnotes/utils'
|
||||
import { EditorSaveTimeoutDebounce } from '../Components/NoteView/Controller/EditorSaveTimeoutDebounce'
|
||||
|
||||
const NotePreviewCharLimit = 160
|
||||
|
||||
export type NoteSaveFunctionParams = {
|
||||
title?: string
|
||||
text?: string
|
||||
bypassDebouncer?: boolean
|
||||
isUserModified?: boolean
|
||||
dontGeneratePreviews?: boolean
|
||||
previews?: { previewPlain: string; previewHtml?: string }
|
||||
customMutate?: (mutator: NoteMutator) => void
|
||||
onLocalPropagationComplete?: () => void
|
||||
onRemoteSyncComplete?: () => void
|
||||
}
|
||||
|
||||
export class NoteSyncController {
|
||||
savingLocallyPromise: ReturnType<typeof Deferred<void>> | null = null
|
||||
|
||||
private saveTimeout?: ReturnType<typeof setTimeout>
|
||||
|
||||
constructor(private application: WebApplication, private item: SNNote) {}
|
||||
|
||||
deinit() {
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout)
|
||||
}
|
||||
if (this.savingLocallyPromise) {
|
||||
this.savingLocallyPromise.reject()
|
||||
}
|
||||
this.savingLocallyPromise = null
|
||||
this.saveTimeout = undefined
|
||||
;(this.application as unknown) = undefined
|
||||
;(this.item as unknown) = undefined
|
||||
}
|
||||
|
||||
public async saveAndAwaitLocalPropagation(params: NoteSaveFunctionParams): Promise<void> {
|
||||
this.savingLocallyPromise = Deferred<void>()
|
||||
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout)
|
||||
}
|
||||
|
||||
const noDebounce = params.bypassDebouncer || this.application.noAccount()
|
||||
|
||||
const syncDebouceMs = noDebounce
|
||||
? EditorSaveTimeoutDebounce.ImmediateChange
|
||||
: this.application.isNativeMobileWeb()
|
||||
? EditorSaveTimeoutDebounce.NativeMobileWeb
|
||||
: EditorSaveTimeoutDebounce.Desktop
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.saveTimeout = setTimeout(() => {
|
||||
void this.undebouncedSave({
|
||||
...params,
|
||||
onLocalPropagationComplete: () => {
|
||||
if (this.savingLocallyPromise) {
|
||||
this.savingLocallyPromise.resolve()
|
||||
}
|
||||
resolve()
|
||||
},
|
||||
})
|
||||
}, syncDebouceMs)
|
||||
})
|
||||
}
|
||||
|
||||
private async undebouncedSave(params: NoteSaveFunctionParams): Promise<void> {
|
||||
if (!this.application.items.findItem(this.item.uuid)) {
|
||||
void this.application.alertService.alert(InfoStrings.InvalidNote)
|
||||
return
|
||||
}
|
||||
|
||||
await this.application.mutator.changeItem(
|
||||
this.item,
|
||||
(mutator) => {
|
||||
const noteMutator = mutator as NoteMutator
|
||||
if (params.customMutate) {
|
||||
params.customMutate(noteMutator)
|
||||
}
|
||||
|
||||
if (params.title != undefined) {
|
||||
noteMutator.title = params.title
|
||||
}
|
||||
|
||||
if (params.text != undefined) {
|
||||
noteMutator.text = params.text
|
||||
}
|
||||
|
||||
if (params.previews) {
|
||||
noteMutator.preview_plain = params.previews.previewPlain
|
||||
noteMutator.preview_html = params.previews.previewHtml
|
||||
} else if (!params.dontGeneratePreviews && params.text != undefined) {
|
||||
const noteText = params.text || ''
|
||||
const truncate = noteText.length > NotePreviewCharLimit
|
||||
const substring = noteText.substring(0, NotePreviewCharLimit)
|
||||
const previewPlain = substring + (truncate ? '...' : '')
|
||||
noteMutator.preview_plain = previewPlain
|
||||
noteMutator.preview_html = undefined
|
||||
}
|
||||
},
|
||||
params.isUserModified,
|
||||
)
|
||||
|
||||
void this.application.sync.sync().then(() => {
|
||||
params.onRemoteSyncComplete?.()
|
||||
})
|
||||
|
||||
params.onLocalPropagationComplete?.()
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export class PurchaseFlowController extends AbstractViewController {
|
||||
this.currentPane = currentPane
|
||||
}
|
||||
|
||||
openPurchaseFlow = (plan = AppleIAPProductId.ProPlanYearly): void => {
|
||||
openPurchaseFlow = async (plan = AppleIAPProductId.ProPlanYearly) => {
|
||||
const user = this.application.getUser()
|
||||
if (!user) {
|
||||
this.isOpen = true
|
||||
@@ -35,9 +35,9 @@ export class PurchaseFlowController extends AbstractViewController {
|
||||
}
|
||||
|
||||
if (this.application.isNativeIOS()) {
|
||||
void this.beginIosIapPurchaseFlow(plan)
|
||||
await this.beginIosIapPurchaseFlow(plan)
|
||||
} else {
|
||||
loadPurchaseFlowUrl(this.application).catch(console.error)
|
||||
await loadPurchaseFlowUrl(this.application)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ export class ApplicationEventObserver implements EventObserverInterface {
|
||||
const route = this.routeService.getRoute()
|
||||
switch (route.type) {
|
||||
case RouteType.Purchase:
|
||||
this.purchaseFlowController.openPurchaseFlow()
|
||||
void this.purchaseFlowController.openPurchaseFlow()
|
||||
|
||||
break
|
||||
case RouteType.Settings: {
|
||||
|
||||
@@ -19,6 +19,34 @@ export const useListKeyboardNavigation = (
|
||||
}
|
||||
}, [])
|
||||
|
||||
const getNextFocusableIndex = useCallback((currentIndex: number, items: HTMLButtonElement[]) => {
|
||||
let nextIndex = currentIndex + 1
|
||||
if (nextIndex > items.length - 1) {
|
||||
nextIndex = 0
|
||||
}
|
||||
while (items[nextIndex].disabled) {
|
||||
nextIndex++
|
||||
if (nextIndex > items.length - 1) {
|
||||
nextIndex = 0
|
||||
}
|
||||
}
|
||||
return nextIndex
|
||||
}, [])
|
||||
|
||||
const getPreviousFocusableIndex = useCallback((currentIndex: number, items: HTMLButtonElement[]) => {
|
||||
let previousIndex = currentIndex - 1
|
||||
if (previousIndex < 0) {
|
||||
previousIndex = items.length - 1
|
||||
}
|
||||
while (items[previousIndex].disabled) {
|
||||
previousIndex--
|
||||
if (previousIndex < 0) {
|
||||
previousIndex = items.length - 1
|
||||
}
|
||||
}
|
||||
return previousIndex
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (container.current) {
|
||||
container.current.tabIndex = FOCUSABLE_BUT_NOT_TABBABLE
|
||||
@@ -37,22 +65,16 @@ export const useListKeyboardNavigation = (
|
||||
listItems.current = Array.from(container.current?.querySelectorAll('button') as NodeListOf<HTMLButtonElement>)
|
||||
|
||||
if (e.key === KeyboardKey.Up) {
|
||||
let previousIndex = focusedItemIndex.current - 1
|
||||
if (previousIndex < 0) {
|
||||
previousIndex = listItems.current.length - 1
|
||||
}
|
||||
const previousIndex = getPreviousFocusableIndex(focusedItemIndex.current, listItems.current)
|
||||
focusItemWithIndex(previousIndex)
|
||||
}
|
||||
|
||||
if (e.key === KeyboardKey.Down) {
|
||||
let nextIndex = focusedItemIndex.current + 1
|
||||
if (nextIndex > listItems.current.length - 1) {
|
||||
nextIndex = 0
|
||||
}
|
||||
const nextIndex = getNextFocusableIndex(focusedItemIndex.current, listItems.current)
|
||||
focusItemWithIndex(nextIndex)
|
||||
}
|
||||
},
|
||||
[container, focusItemWithIndex],
|
||||
[container, focusItemWithIndex, getNextFocusableIndex, getPreviousFocusableIndex],
|
||||
)
|
||||
|
||||
const FIRST_ITEM_FOCUS_TIMEOUT = 20
|
||||
@@ -66,12 +88,13 @@ export const useListKeyboardNavigation = (
|
||||
}
|
||||
|
||||
const selectedItemIndex = Array.from(items).findIndex((item) => item.dataset.selected)
|
||||
const indexToFocus = selectedItemIndex > -1 ? selectedItemIndex : initialFocus
|
||||
let indexToFocus = selectedItemIndex > -1 ? selectedItemIndex : initialFocus
|
||||
indexToFocus = getNextFocusableIndex(indexToFocus, items)
|
||||
|
||||
setTimeout(() => {
|
||||
focusItemWithIndex(indexToFocus, items)
|
||||
}, FIRST_ITEM_FOCUS_TIMEOUT)
|
||||
}, [container, focusItemWithIndex, initialFocus])
|
||||
}, [container, focusItemWithIndex, getNextFocusableIndex, initialFocus])
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldAutoFocus) {
|
||||
|
||||
@@ -8,6 +8,23 @@ require('dotenv').config()
|
||||
|
||||
module.exports = (env) => {
|
||||
mergeWithEnvDefaults(env)
|
||||
|
||||
const copyPluginPatterns = [
|
||||
{ from: 'src/favicon', to: 'favicon' },
|
||||
{ from: 'src/vendor', to: 'dist' },
|
||||
{ from: 'src/404.html' },
|
||||
{ from: 'src/422.html' },
|
||||
{ from: 'src/500.html' },
|
||||
{ from: 'src/index.html' },
|
||||
{ from: 'src/manifest.webmanifest' },
|
||||
{ from: 'src/robots.txt' },
|
||||
{ from: 'src/.well-known', to: '.well-known' },
|
||||
]
|
||||
|
||||
if (process.env.BUILD_TARGET !== 'clipper') {
|
||||
copyPluginPatterns.push({ from: 'src/components', to: 'components' })
|
||||
}
|
||||
|
||||
return {
|
||||
entry: './src/javascripts/index.ts',
|
||||
output: {
|
||||
@@ -36,18 +53,7 @@ module.exports = (env) => {
|
||||
ignoreOrder: true, // Enable to remove warnings about conflicting order
|
||||
}),
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
{ from: 'src/favicon', to: 'favicon' },
|
||||
{ from: 'src/vendor', to: 'dist' },
|
||||
{ from: 'src/components', to: 'components' },
|
||||
{ from: 'src/404.html' },
|
||||
{ from: 'src/422.html' },
|
||||
{ from: 'src/500.html' },
|
||||
{ from: 'src/index.html' },
|
||||
{ from: 'src/manifest.webmanifest' },
|
||||
{ from: 'src/robots.txt' },
|
||||
{ from: 'src/.well-known', to: '.well-known' },
|
||||
],
|
||||
patterns: copyPluginPatterns,
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
|
||||
@@ -8,6 +8,7 @@ module.exports = (env, argv) => {
|
||||
mergeWithEnvDefaults(env)
|
||||
return merge(config(env, argv), {
|
||||
mode: 'development',
|
||||
devtool: process.env.BUILD_TARGET === 'extension' ? 'cheap-module-source-map' : 'inline-source-map',
|
||||
optimization: {
|
||||
minimize: false,
|
||||
},
|
||||
|
||||