refactor changes
No files matched your search
@@ -11,7 +11,9 @@
|
||||
"tauri": "tauri",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "playwright test"
|
||||
"test:ui": "playwright test",
|
||||
"fonts:sync": "node node_modules/margin-shared/bin/sync-fonts.mjs .",
|
||||
"fonts:check": "node node_modules/margin-shared/bin/sync-fonts.mjs . --check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
@@ -28,8 +30,10 @@
|
||||
"highlight.js": "^11.12.0",
|
||||
"katex": "^0.18.4",
|
||||
"lowlight": "^3.3.0",
|
||||
"margin-shared": "file:../../python/margin/shared",
|
||||
"mdast-util-to-markdown": "^2",
|
||||
"mermaid": "^11.17.1",
|
||||
"pdfjs-dist": "^6.2.108",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"remark-frontmatter": "^5",
|
||||
|
||||
@@ -50,12 +50,18 @@ importers:
|
||||
lowlight:
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.0
|
||||
margin-shared:
|
||||
specifier: file:../../python/margin/shared
|
||||
version: file:../../python/margin/shared
|
||||
mdast-util-to-markdown:
|
||||
specifier: ^2
|
||||
version: 2.1.2
|
||||
mermaid:
|
||||
specifier: ^11.17.1
|
||||
version: 11.17.1
|
||||
pdfjs-dist:
|
||||
specifier: ^6.2.108
|
||||
version: 6.2.108
|
||||
react:
|
||||
specifier: ^19.1.0
|
||||
version: 19.2.8
|
||||
@@ -402,6 +408,76 @@ packages:
|
||||
'@mermaid-js/[email protected]':
|
||||
resolution: {integrity: sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==}
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-7jQ47gi+fZ7KJmfc/5rNyy1CYw/cu4kZ0KPIYbo9UUgSdW0bKQJpt+WihEor6s4Lyp7+xc3a+3HeyXmAEbbnPg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-rRjDMZs9pIRKGxgijwezplKc1RnJsqUokrA9h88bbTkqQ+7ePj0ZN4ZnZDy8Vu0tXs7KRlI2tQLaK4mx9QlxHg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-jGcCd+8ra6Q61xKqZeiItujTpp9a9eRLcQ0jW6qYNku+WpupqOPFPY0SrsuSnXFviJwkpKYT9p7QrB4lsf3LNQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-od6I2Y7kU7i1SwZYG2EKW8rWz6JiedtPpko4WEe1DDsiikrfaotVBCRaUTM5/yeZKaZ92EatoAS+5xG+6uJlYA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-yYkPbJDJiWj6N0gASA3CAvRypZmVpJnxU0DQg3aBhneLDQde9TPLKADsQkobNoJUtTT/lj46aWpzT48PDb3Qcg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-PB00MSKAp4VwK/xwe6duKxRKmH8UH4GIl1pqHSbxng0jnU9Dr7FwaDypDiqwNFZ774N+8G7mJLGuLtg9NTcQsg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-TWM2XWJoitLiIPCvgJh7SriC+L/T9qkYCVzC66AidsZy0QP1hkKzBzVwshCdcA3q6fIn3yE0ISbq4lMJSy8jFw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-hb20MxKXXb5IB7AAwN8UHz9WRsa2HmdZfjsDCzjElwJoeV1aotVEwFU4FrFQcYQVzsJQLeaCc/2Qdt/0Q72mMg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-WwPN08IXE4SkL+FhJyPz/iFnycMAUkbphFIT4cmKLlvbSU0Zfn1R7BGJ3Hqky1S89QUYc0Q4IOScXb/42Re9wQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-XkrVqKb+pxyba7kjy2LJvABFVBTE0DNpEl7MrG4OYUmaWarrXH+t54z/Czj2YxCKtizYTV4mg6phNm3x24qjhQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-/SaLcvlqGWdm0HSCWMgHu7cjJiQXfP8/mOY+6dUyV9flQz7sPBBZ+ed2zYtoukojPmxOaL7bm+d/G4GeWWoN7g==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
|
||||
engines: {node: ^22.20 || ^24.12 || >=25}
|
||||
@@ -1370,6 +1446,10 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
margin-shared@file:../../python/margin/shared:
|
||||
resolution: {directory: ../../python/margin/shared, type: directory}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
||||
|
||||
@@ -1538,6 +1618,10 @@ packages:
|
||||
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
||||
engines: {node: '>= 14.16'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==}
|
||||
engines: {node: '>=22.13.0 || >=24'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -2112,6 +2196,54 @@ snapshots:
|
||||
dependencies:
|
||||
'@chevrotain/types': 11.1.2
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas-android-arm64': 1.0.8
|
||||
'@napi-rs/canvas-darwin-arm64': 1.0.8
|
||||
'@napi-rs/canvas-darwin-x64': 1.0.8
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf': 1.0.8
|
||||
'@napi-rs/canvas-linux-arm64-gnu': 1.0.8
|
||||
'@napi-rs/canvas-linux-arm64-musl': 1.0.8
|
||||
'@napi-rs/canvas-linux-riscv64-gnu': 1.0.8
|
||||
'@napi-rs/canvas-linux-x64-gnu': 1.0.8
|
||||
'@napi-rs/canvas-linux-x64-musl': 1.0.8
|
||||
'@napi-rs/canvas-win32-arm64-msvc': 1.0.8
|
||||
'@napi-rs/canvas-win32-x64-msvc': 1.0.8
|
||||
optional: true
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
@@ -3072,6 +3204,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
margin-shared@file:../../python/margin/shared: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -3450,6 +3584,10 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 1.0.8
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2018 The Fraunces Project Authors (https://github.com/undercasetype/Fraunces)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,94 @@
|
||||
Copyright 2021 The Hanken Grotesk Project Authors (https://github.com/marcologous/hanken-grotesk)
|
||||
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2017 The Literata Project Authors (https://github.com/googlefonts/literata)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2014 The Source Serif 4 Project Authors (https://github.com/adobe-fonts/source-serif)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 491 B After Width: | Height: | Size: 716 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 685 B After Width: | Height: | Size: 988 B |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 829 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1022 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 14 KiB |
@@ -1,12 +1,14 @@
|
||||
<svg width="350" height="350" viewBox="0 0 350 350" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Margin Docs mark: a page with a margin rule. Glyph only, matching margin's logo-dark.svg conventions.
|
||||
App icon composition on a 512 canvas: squircle rect x=49 y=49 w=414 h=414 rx=92.5 fill #0d0c0a,
|
||||
then this glyph under transform="translate(56,48) scale(1.142857142857)".
|
||||
That scale puts every stroke centreline on a pixel centre at 32x32, and lifts the glyph 8px
|
||||
above the geometric centre for optical balance. -->
|
||||
<rect x="70" y="35" width="210" height="280" rx="14" stroke="#fcfbf7" stroke-width="10"/>
|
||||
<rect x="93" y="72" width="10" height="206" rx="5" fill="#fcfbf7"/>
|
||||
<rect x="135" y="100" width="122" height="10" rx="5" fill="#fcfbf7"/>
|
||||
<rect x="135" y="170" width="122" height="10" rx="5" fill="#fcfbf7"/>
|
||||
<rect x="135" y="240" width="80" height="10" rx="5" fill="#fcfbf7"/>
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Margin Docs mark: stacked text bars, a paragraph seen as an alignment glyph. Source of truth
|
||||
for every file in this directory; regenerate them with `pnpm tauri icon` after editing.
|
||||
Squircle 828 wide on a 1024 canvas is the macOS icon inset, and every coordinate is a multiple
|
||||
of 32 so bars and gaps land on whole pixels at 32x32. The block spans y 224..768 against a
|
||||
centre of 512, which lifts it 16 above the geometric centre for optical balance. -->
|
||||
<rect x="98" y="98" width="828" height="828" rx="185" fill="#0d0c0a"/>
|
||||
<g fill="#fcfbf7">
|
||||
<rect x="224" y="224" width="576" height="64" rx="32"/>
|
||||
<rect x="224" y="384" width="416" height="64" rx="32"/>
|
||||
<rect x="224" y="544" width="576" height="64" rx="32"/>
|
||||
<rect x="224" y="704" width="288" height="64" rx="32"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 915 B After Width: | Height: | Size: 895 B |
@@ -141,3 +141,42 @@ fn collect_installed(
|
||||
.map(|family| (*family).to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// The document's own face
|
||||
//
|
||||
// Everything above is about the two families the bundle is missing. What follows is about the one
|
||||
// the author chose: src/model/fonts.ts offers six bundled families and whatever this machine has,
|
||||
// and an export has to be handed the bytes of whichever it names.
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
|
||||
/// Every family this machine has, for the "System" group of the font picker.
|
||||
///
|
||||
/// Sorted and deduplicated here rather than in the UI, because a font book has one entry per face
|
||||
/// and the picker wants one per family: a machine with the full Helvetica Neue family would
|
||||
/// otherwise show it nine times.
|
||||
#[tauri::command(async)]
|
||||
pub fn fonts_list_system() -> Vec<String> {
|
||||
let db = system_db();
|
||||
let mut names: Vec<String> = db
|
||||
.faces()
|
||||
.filter_map(|face| face.families.first().map(|(name, _)| name.clone()))
|
||||
.collect();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
|
||||
/// The upright and italic cuts of one installed family, for a document set in a face off the
|
||||
/// machine.
|
||||
///
|
||||
/// Four queries and not two, because a family whose bold is a separate file gives fontdb a
|
||||
/// different id for it and the collection has to be loaded either way. `seen` is what stops a `.ttc`
|
||||
/// holding all four from being read four times.
|
||||
pub fn system_faces(family: &str) -> Vec<Vec<u8>> {
|
||||
let db = system_db();
|
||||
let mut fonts = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
faces_for(&db, family, &mut fonts, &mut seen);
|
||||
fonts
|
||||
}
|
||||
@@ -297,6 +297,7 @@ pub fn run() {
|
||||
pdf::pdf_write,
|
||||
grammar::grammar_available,
|
||||
grammar::grammar_check,
|
||||
fonts::fonts_list_system,
|
||||
])
|
||||
.run(context)
|
||||
.expect("error while running Margin Docs");
|
||||
|
||||
@@ -41,6 +41,46 @@ static FACES: [&[u8]; 9] = [
|
||||
include_bytes!("../fonts/HankenGrotesk-BoldItalic.ttf"),
|
||||
];
|
||||
|
||||
// The other four families src/model/fonts.ts offers, in the variable files public/fonts already
|
||||
// ships for the editor, and loaded only when a document is actually set in one.
|
||||
//
|
||||
// These carry the caveat the nine above exist to avoid, and it is not worth cutting eight more
|
||||
// static instances to fix. Typst lays a variable file out at its default instance, so a document
|
||||
// set in EB Garamond exports with its headings at the same weight as its body: the face is right,
|
||||
// the hierarchy is flatter than the screen. The two the app defaults to are the two that export
|
||||
// perfectly, and choosing one of these is choosing a face rather than a fallback.
|
||||
static EB_GARAMOND: [&[u8]; 2] = [
|
||||
include_bytes!("../../public/fonts/EBGaramond-VF.ttf"),
|
||||
include_bytes!("../../public/fonts/EBGaramond-Italic-VF.ttf"),
|
||||
];
|
||||
static LORA: [&[u8]; 2] = [
|
||||
include_bytes!("../../public/fonts/Lora-VF.ttf"),
|
||||
include_bytes!("../../public/fonts/Lora-Italic-VF.ttf"),
|
||||
];
|
||||
static SOURCE_SERIF: [&[u8]; 2] = [
|
||||
include_bytes!("../../public/fonts/SourceSerif4-VF.ttf"),
|
||||
include_bytes!("../../public/fonts/SourceSerif4-Italic-VF.ttf"),
|
||||
];
|
||||
static FRAUNCES: [&[u8]; 2] = [
|
||||
include_bytes!("../../public/fonts/Fraunces-VF.ttf"),
|
||||
include_bytes!("../../public/fonts/Fraunces-Italic-VF.ttf"),
|
||||
];
|
||||
|
||||
/// The bytes behind one `BundledFont` id from src/model/fonts.ts.
|
||||
///
|
||||
/// Literata and Hanken Grotesk are absent on purpose rather than missing: `FACES` already holds
|
||||
/// their nine static instances and they are loaded for every export, so naming either here would
|
||||
/// hand Typst the same family twice and let the variable copy win.
|
||||
fn bundled_faces(id: &str) -> &'static [&'static [u8]] {
|
||||
match id {
|
||||
"eb-garamond" => &EB_GARAMOND,
|
||||
"lora" => &LORA,
|
||||
"source-serif" => &SOURCE_SERIF,
|
||||
"fraunces" => &FRAUNCES,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
// mitex 0.2.5, vendored under src-tauri/vendor/mitex with its LICENSE, and served as ordinary
|
||||
// paths under `/mitex/` rather than through Typst's package system. A package spec would mean a
|
||||
// download on first export and a cache directory to keep, for a dependency that is 380K and never
|
||||
@@ -242,7 +282,7 @@ fn compile_once(
|
||||
Ok((bytes, warnings.into_iter().collect()))
|
||||
}
|
||||
|
||||
/// Compiles Typst source to PDF bytes, with whatever the compiler had to work around.
|
||||
/// Compiles Typst source to PDF bytes, in the app's default pair of faces.
|
||||
///
|
||||
/// Separate from the command so a test can reach it: a `#[tauri::command]` taking an `AppHandle`
|
||||
/// needs a running app, and none of the work below wants one.
|
||||
@@ -250,6 +290,22 @@ pub fn compile(
|
||||
source: String,
|
||||
images: &[ImageInput],
|
||||
root_paths: &[String],
|
||||
) -> Result<(Vec<u8>, Vec<PdfWarning>), String> {
|
||||
compile_in(source, images, root_paths, &[], &[])
|
||||
}
|
||||
|
||||
/// The same, for a document set in something other than the default.
|
||||
///
|
||||
/// `bundled` are ids from src/model/fonts.ts and `system` are family names off this machine, both
|
||||
/// as the converter named them in the preamble it wrote. Naming a face Typst has no bytes for is a
|
||||
/// warning and a fallback rather than a failure, so an id this build does not know and a family
|
||||
/// that has since been uninstalled both end in a PDF.
|
||||
pub fn compile_in(
|
||||
source: String,
|
||||
images: &[ImageInput],
|
||||
root_paths: &[String],
|
||||
bundled: &[String],
|
||||
system: &[String],
|
||||
) -> Result<(Vec<u8>, Vec<PdfWarning>), String> {
|
||||
let mut warnings: Vec<PdfWarning> = Vec::new();
|
||||
|
||||
@@ -273,6 +329,16 @@ pub fn compile(
|
||||
let mut fonts: Vec<Vec<u8>> = FACES.iter().map(|face| face.to_vec()).collect();
|
||||
fonts.append(&mut fallbacks.fonts);
|
||||
|
||||
// The face the author chose, if it is not one of the two already in `FACES`. A system family is
|
||||
// read off this machine, which is the one place an export can differ between two computers, and
|
||||
// is the caveat the picker states next to the "System" group.
|
||||
for id in bundled {
|
||||
fonts.extend(bundled_faces(id).iter().map(|face| face.to_vec()));
|
||||
}
|
||||
for family in system {
|
||||
fonts.extend(crate::fonts::system_faces(family));
|
||||
}
|
||||
|
||||
let source = format!("{}{source}", font_preamble(&fallbacks));
|
||||
|
||||
let failure = match compile_once(&source, MITEX_LIB, &binaries, &fonts) {
|
||||
@@ -322,6 +388,8 @@ pub fn pdf_compile(
|
||||
app: tauri::AppHandle,
|
||||
source: String,
|
||||
images: Vec<ImageInput>,
|
||||
bundled_fonts: Vec<String>,
|
||||
system_fonts: Vec<String>,
|
||||
) -> Result<tauri::ipc::Response, String> {
|
||||
// The same gate `fs::checked` puts in front of every other read, reached the same way it is:
|
||||
// the open roots out of shared state, then `resolve_in_roots`. The lock is dropped before the
|
||||
@@ -332,7 +400,7 @@ pub fn pdf_compile(
|
||||
open.iter().map(|root| root.path.clone()).collect()
|
||||
};
|
||||
|
||||
let (bytes, warnings) = compile(source, &images, &root_paths)?;
|
||||
let (bytes, warnings) = compile_in(source, &images, &root_paths, &bundled_fonts, &system_fonts)?;
|
||||
if !warnings.is_empty() {
|
||||
app.emit("pdf-warnings", warnings).ok();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use margin_docs_lib::dto::ImageInput;
|
||||
use margin_docs_lib::pdf::compile;
|
||||
use margin_docs_lib::pdf::{compile, compile_in};
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// A real 4x4 PNG, so an image that is meant to be read is one Typst can actually decode, and one
|
||||
@@ -314,3 +314,49 @@ fn every_weight_the_converter_asks_for_has_a_face_of_its_own() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A document set in one of the four extra bundled families really is typeset in it.
|
||||
///
|
||||
/// The bytes are compiled into the binary but only loaded when the front end names the id, so the
|
||||
/// thing worth proving is that naming it changes the page: the same word in EB Garamond and in the
|
||||
/// default face has to be two different documents. A family Typst cannot see is not an error, it is
|
||||
/// a warning and a fallback, so the negative case would pass without the loading working at all.
|
||||
#[test]
|
||||
fn a_document_set_in_a_bundled_face_is_typeset_in_it() {
|
||||
let (_dir, roots) = root();
|
||||
let word = |family: &str, bundled: &[String]| {
|
||||
let source = format!("{PREAMBLE}#text(font: \"{family}\")[Margin]\n");
|
||||
compile_in(source, &[], &roots, bundled, &[]).expect("the face typesets").0
|
||||
};
|
||||
|
||||
let default = word("Literata", &[]);
|
||||
for (id, family) in [
|
||||
("eb-garamond", "EB Garamond"),
|
||||
("lora", "Lora"),
|
||||
("source-serif", "Source Serif 4"),
|
||||
("fraunces", "Fraunces"),
|
||||
] {
|
||||
let chosen = word(family, &[id.to_string()]);
|
||||
assert_ne!(default, chosen, "{family} came out as the default face");
|
||||
}
|
||||
}
|
||||
|
||||
/// A face the compiler cannot find costs a warning and never the export.
|
||||
///
|
||||
/// Both halves reach here in the ordinary run of things. A bundled id is a preference written by
|
||||
/// some other version of the app, and a system family is one the user has uninstalled since they
|
||||
/// chose it; neither is a reason to refuse somebody their PDF.
|
||||
#[test]
|
||||
fn a_face_that_is_not_there_still_exports() {
|
||||
let (_dir, roots) = root();
|
||||
let source = format!("{PREAMBLE}#text(font: \"Nothing Installed\")[Margin]\n");
|
||||
let (bytes, _) = compile_in(
|
||||
source,
|
||||
&[],
|
||||
&roots,
|
||||
&["a-face-no-build-has".to_string()],
|
||||
&["Nothing Installed".to_string()],
|
||||
)
|
||||
.expect("an unknown family falls back rather than failing");
|
||||
assert!(bytes.starts_with(b"%PDF-"), "the export still happened");
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { Backlinks } from "./components/Backlinks";
|
||||
import { CommandPalette } from "./components/CommandPalette";
|
||||
import { ConflictDialog } from "./components/ConflictDialog";
|
||||
import { ExportPreview } from "./components/ExportPreview";
|
||||
import { FindBar } from "./components/FindBar";
|
||||
import { FindInFiles } from "./components/FindInFiles";
|
||||
import { ProofPopover } from "./components/ProofPopover";
|
||||
@@ -36,6 +37,7 @@ import { handleMenuAction } from "./keys/menu";
|
||||
import { openLink } from "./links";
|
||||
import { documentKindForPath } from "./model/doc";
|
||||
import { useDocument } from "./store/useDocument";
|
||||
import { useDocumentFonts } from "./store/useDocumentFonts";
|
||||
import { notify } from "./store/useToast";
|
||||
import { useWorkspace } from "./store/useWorkspace";
|
||||
import { startUpdateChecks } from "./update";
|
||||
@@ -97,6 +99,14 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The face the page is set in belongs to the document rather than to the app, so it is applied
|
||||
// here, on the path, rather than restored once at boot the way the theme and the width are. A
|
||||
// closed document takes the app back to its default pair, which is what the empty pane behind it
|
||||
// is drawn in anyway.
|
||||
useEffect(() => {
|
||||
useDocumentFonts.getState().openFor(path);
|
||||
}, [path]);
|
||||
|
||||
useEffect(() => {
|
||||
const stops = [
|
||||
onCommand("editor-width-narrow", () => applyWidth("narrow")),
|
||||
@@ -176,6 +186,7 @@ function App() {
|
||||
<QuickOpen />
|
||||
<FindInFiles />
|
||||
<CommandPalette />
|
||||
<ExportPreview />
|
||||
<ProofPopover />
|
||||
<Shortcuts />
|
||||
<Settings />
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// The machine's font book, for the half of the font picker this app does not ship.
|
||||
|
||||
import { call } from "../ipc";
|
||||
|
||||
/**
|
||||
* Every family installed on this machine, sorted and deduplicated by the backend.
|
||||
*
|
||||
* Answers with an empty list rather than rejecting when there is no backend to ask, because the
|
||||
* picker's bundled half works perfectly well without this one and an empty "System" group is the
|
||||
* honest way to say the machine was not asked. The list is worth caching in the caller: it walks
|
||||
* several directories on the Rust side and cannot change while the app is running.
|
||||
*/
|
||||
export const fontsListSystem = (): Promise<string[]> =>
|
||||
call<string[]>("fonts_list_system").catch(() => []);
|
||||
@@ -1,13 +1,26 @@
|
||||
// PDF export. The source is built here and compiled there; these two calls are the boundary.
|
||||
|
||||
import { call, type ImageInput } from "../ipc";
|
||||
import { fontsUsed, type DocumentFonts } from "../model/fonts";
|
||||
|
||||
/**
|
||||
* Compiles Typst source to PDF bytes. Warnings do not come back through the return value, because
|
||||
* the answer is raw bytes and has no room for a second one: they arrive on `pdf-warnings` instead.
|
||||
*
|
||||
* `fonts` is the document's own pair, and what crosses the boundary is which faces to load rather
|
||||
* than their bytes: a bundled id the backend already has compiled in, a system family name it can
|
||||
* look up. The source names the families itself, so this only has to make sure the compiler can
|
||||
* see them.
|
||||
*/
|
||||
export const pdfCompile = (source: string, images: readonly ImageInput[]) =>
|
||||
call<ArrayBuffer>("pdf_compile", { source, images });
|
||||
export const pdfCompile = (source: string, images: readonly ImageInput[], fonts: DocumentFonts) => {
|
||||
const used = fontsUsed(fonts);
|
||||
return call<ArrayBuffer>("pdf_compile", {
|
||||
source,
|
||||
images,
|
||||
bundledFonts: used.bundled,
|
||||
systemFonts: used.system,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Writes the finished file wherever the native save panel pointed.
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
// Document setup: the panel the filename in the title bar opens.
|
||||
//
|
||||
// The sibling book app puts the same thing behind the same gesture. Its title bar shows the book's
|
||||
// title, clicking it opens Book setup, and the faces the book is set in are a section of that
|
||||
// panel. This is that panel for a document editor, so the two apps answer a click on the title the
|
||||
// same way, and the two font pickers are the same picker.
|
||||
//
|
||||
// What a document has to set up is a shorter list than a book's. There is no author, no ISBN and no
|
||||
// trim size, because none of those are things a markdown file holds: its metadata is frontmatter,
|
||||
// which src/model/doc.ts carries opaque and never parses. What is left is the two things that are
|
||||
// genuinely the document's own, its name on disk and the faces it is set in.
|
||||
//
|
||||
// A draft, applied on Save, which is again the sibling's shape. Nothing here touches the open
|
||||
// document until the button is pressed: changing a face and pressing Escape leaves the page exactly
|
||||
// as it was. The specimen at the foot of the panel is what stands in for a live preview, and it is
|
||||
// the better trade for a rename, where "applied as you type" would mean a file moving on disk once
|
||||
// per keystroke.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { icons } from "margin-shared";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { useKeyContext } from "../keys/keymap";
|
||||
import {
|
||||
BUNDLED_FONTS,
|
||||
DEFAULT_FONTS,
|
||||
FONT_PAIRINGS,
|
||||
decodeRef,
|
||||
encodeRef,
|
||||
fontStack,
|
||||
fontsEqual,
|
||||
pairingFor,
|
||||
type DocumentFonts,
|
||||
type FontRef,
|
||||
} from "../model/fonts";
|
||||
import { fontsListSystem } from "../api/fonts";
|
||||
import { useDocumentFonts } from "../store/useDocumentFonts";
|
||||
import { notify } from "../store/useToast";
|
||||
import { useWorkspace } from "../store/useWorkspace";
|
||||
import { splitExtension } from "./FileTree";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
/**
|
||||
* The machine's font book, asked once per launch and kept for every later open of this panel.
|
||||
*
|
||||
* It walks several directories on the Rust side and cannot change while the app is running, so a
|
||||
* second ask would be the same list at the same cost.
|
||||
*/
|
||||
let systemFontCache: string[] | null = null;
|
||||
|
||||
interface Draft {
|
||||
name: string;
|
||||
fonts: DocumentFonts;
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field-label">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One slot's picker: the six that travel with the app, then whatever this machine has.
|
||||
*
|
||||
* A stored family the machine no longer has is put back at the top of its own group rather than
|
||||
* dropped, so the select shows what the document actually asks for instead of silently reading as
|
||||
* some other face.
|
||||
*/
|
||||
function FontSelect({
|
||||
value,
|
||||
system,
|
||||
onChange,
|
||||
}: {
|
||||
value: FontRef;
|
||||
system: readonly string[];
|
||||
onChange: (ref: FontRef) => void;
|
||||
}) {
|
||||
const missing = value.kind === "system" && !system.includes(value.family) ? [value.family] : [];
|
||||
return (
|
||||
<select
|
||||
value={encodeRef(value)}
|
||||
style={{ fontFamily: fontStack(value) }}
|
||||
onChange={(e) => onChange(decodeRef(e.target.value))}
|
||||
>
|
||||
<optgroup label="Bundled">
|
||||
{BUNDLED_FONTS.map((f) => (
|
||||
<option key={f.id} value={`b:${f.id}`}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{(system.length > 0 || missing.length > 0) && (
|
||||
<optgroup label="System">
|
||||
{[...missing, ...system].map((family) => (
|
||||
<option key={family} value={`s:${family}`}>
|
||||
{family}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentSetup({ path, onClose }: { path: string; onClose: () => void }) {
|
||||
const fonts = useDocumentFonts((s) => s.fonts);
|
||||
const setFonts = useDocumentFonts((s) => s.setFonts);
|
||||
const renameEntry = useWorkspace((s) => s.renameEntry);
|
||||
|
||||
const fileName = path.slice(path.lastIndexOf("/") + 1);
|
||||
const { base, hidden } = splitExtension(fileName);
|
||||
|
||||
const [draft, setDraft] = useState<Draft>(() => ({ name: base, fonts }));
|
||||
const [system, setSystem] = useState<string[]>(() => systemFontCache ?? []);
|
||||
// Open on the two selects when the document is in a pair no preset names, because that is the
|
||||
// state somebody arrived at by using them and the panel should not hide the controls that got
|
||||
// them there.
|
||||
const [advanced, setAdvanced] = useState(() => pairingFor(fonts) === null);
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEscapeLayer(true, onClose);
|
||||
useKeyContext("overlay");
|
||||
|
||||
useEffect(() => {
|
||||
nameRef.current?.focus();
|
||||
nameRef.current?.select();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (systemFontCache) return;
|
||||
let live = true;
|
||||
void fontsListSystem().then((list) => {
|
||||
systemFontCache = list;
|
||||
if (live) setSystem(list);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const set = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
|
||||
const setSlot = (slot: "body" | "heading", ref: FontRef) =>
|
||||
set({ fonts: { ...draft.fonts, [slot]: ref } });
|
||||
|
||||
const activePreset = pairingFor(draft.fonts);
|
||||
const usesSystem = draft.fonts.body.kind === "system" || draft.fonts.heading.kind === "system";
|
||||
|
||||
const save = () => {
|
||||
if (!fontsEqual(draft.fonts, fonts)) setFonts(draft.fonts);
|
||||
|
||||
// The rename goes last and the panel closes either way. A file that could not be renamed is a
|
||||
// toast and not a reason to throw away the face the user just chose, and the two are unrelated
|
||||
// enough that failing one must not roll back the other.
|
||||
const trimmed = draft.name.trim();
|
||||
if (trimmed && trimmed !== base) {
|
||||
renameEntry(path, `${trimmed}${hidden}`).catch((e) =>
|
||||
notify(`Could not rename: ${String(e)}`),
|
||||
);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={onClose}>
|
||||
<div
|
||||
className="panel panel-setup"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Document setup"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="panel-head">
|
||||
<h2>Document setup</h2>
|
||||
<button className="icon-button" onClick={onClose} title="Close (⎋)" aria-label="Close">
|
||||
<Icon d={icons.CLOSE} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="panel-body">
|
||||
{/* The file on disk, and the only thing that ever renames it. A document's H1 is content
|
||||
and is left alone: a path is what git, every other editor and every relative link from
|
||||
another document already agreed on. Markdown's own extension is hidden here the same
|
||||
way the file tree hides it, and put back on the way out. */}
|
||||
<Field label="Name">
|
||||
<input
|
||||
ref={nameRef}
|
||||
value={draft.name}
|
||||
placeholder={base}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
onChange={(e) => set({ name: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
e.preventDefault();
|
||||
save();
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="field">
|
||||
<span className="field-label">Typography</span>
|
||||
<div className="font-presets">
|
||||
{FONT_PAIRINGS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className="font-preset"
|
||||
data-on={activePreset === p.id}
|
||||
onClick={() => set({ fonts: { body: p.body, heading: p.heading } })}
|
||||
>
|
||||
<span className="font-preset-demo" style={{ fontFamily: fontStack(p.heading) }}>
|
||||
Ag
|
||||
</span>
|
||||
<span className="font-preset-name">{p.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="font-advanced" onClick={() => setAdvanced((v) => !v)}>
|
||||
{advanced ? "Hide custom fonts" : "Customize fonts"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{advanced && (
|
||||
<>
|
||||
<Field label="Body font">
|
||||
<FontSelect
|
||||
value={draft.fonts.body}
|
||||
system={system}
|
||||
onChange={(ref) => setSlot("body", ref)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Heading font">
|
||||
<FontSelect
|
||||
value={draft.fonts.heading}
|
||||
system={system}
|
||||
onChange={(ref) => setSlot("heading", ref)}
|
||||
/>
|
||||
</Field>
|
||||
{usesSystem && (
|
||||
<p className="font-note">
|
||||
A font off this machine looks right here and in a PDF exported here, but it is not
|
||||
part of the app, so another computer may not have it.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* A heading and a line of prose in the pair being chosen, which is the smallest sample
|
||||
that shows both slots and the relationship between them. */}
|
||||
<div className="font-sample" style={{ fontFamily: fontStack(draft.fonts.body) }}>
|
||||
<span
|
||||
className="font-sample-title"
|
||||
style={{ fontFamily: fontStack(draft.fonts.heading) }}
|
||||
>
|
||||
What lives where
|
||||
</span>
|
||||
She read the first line twice, and the whole quiet house seemed to lean in to listen.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-foot">
|
||||
{!fontsEqual(draft.fonts, DEFAULT_FONTS) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost"
|
||||
onClick={() => set({ fonts: DEFAULT_FONTS })}
|
||||
>
|
||||
Reset fonts
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-primary" onClick={save}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
// The PDF preview: what Cmd-E opens now, and where Save PDF lives.
|
||||
//
|
||||
// The sibling book app answers Export with this same panel, and both apps answer it this way for
|
||||
// the same reason. A PDF is the one thing either of them produces that its author cannot see
|
||||
// before it exists: the editor draws a document on a scrolling sheet with no page breaks in it, and
|
||||
// where the pages fall, whether a diagram drew, whether a formula typeset and whether a table fits
|
||||
// the measure are all only knowable after the compiler has run. Sending that straight to a save
|
||||
// panel means the first look at the result happens in Preview.app, after a file has been written.
|
||||
// So the compile happens first, the pages go on screen, and the save panel is a button somebody
|
||||
// presses once they have seen what they are about to write.
|
||||
//
|
||||
// It is drawn over the editor pane rather than over the window, which is the sibling's shape too:
|
||||
// the sidebar stays where it is, and the pages appear where the page was. That is also why the
|
||||
// panel measures `.editor-pane` and pins itself to it instead of being a child of it. The pane
|
||||
// scrolls, and a child of a scrolling container that has to stay still is a fight with the
|
||||
// scrollbar rather than a layout.
|
||||
//
|
||||
// The document cannot change under a preview: nothing in this panel edits, and the pane behind it
|
||||
// is covered. The sidebar is not, so switching documents closes the panel rather than leaving the
|
||||
// pages of one document over the name of another.
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { PDFDocumentProxy, RenderTask } from "pdfjs-dist";
|
||||
import { icons } from "margin-shared";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { compile, save, suggestedName, type CompiledPdf } from "../export/run";
|
||||
import { onCommand } from "../keys/commands";
|
||||
import { useKeyContext } from "../keys/keymap";
|
||||
import { useDocument } from "../store/useDocument";
|
||||
import { notify } from "../store/useToast";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
/**
|
||||
* pdf.js, fetched the first time somebody previews something and kept for every preview after.
|
||||
*
|
||||
* Imported here rather than at the top of the file because it is a megabyte and a half of reader
|
||||
* and a separate worker, and an editor that has not been asked for a PDF has no use for either. It
|
||||
* is the same call mermaid gets in src/export/typst.ts, for the same reason. The types above are
|
||||
* `import type` and cost nothing: they are erased before anything runs.
|
||||
*
|
||||
* The worker is a bundled asset URL rather than a path, so the bundler emits it and the app's CSP
|
||||
* sees it as its own origin. `worker-src 'self' blob:` in tauri.conf.json is what lets pdf.js
|
||||
* start it at all.
|
||||
*/
|
||||
type Pdfjs = typeof import("pdfjs-dist");
|
||||
|
||||
let reader: Promise<Pdfjs> | null = null;
|
||||
|
||||
function loadPdfjs(): Promise<Pdfjs> {
|
||||
reader ??= (async () => {
|
||||
const [pdfjs, worker] = await Promise.all([
|
||||
import("pdfjs-dist"),
|
||||
import("pdfjs-dist/build/pdf.worker.min.mjs?url"),
|
||||
]);
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = worker.default;
|
||||
return pdfjs;
|
||||
})();
|
||||
return reader;
|
||||
}
|
||||
|
||||
const ZOOM_MIN = 0.5;
|
||||
const ZOOM_MAX = 3;
|
||||
const ZOOM_STEP = 0.25;
|
||||
|
||||
/** Zoom glyphs, which are a minus and a plus and are not worth a name in margin-shared. */
|
||||
const MINUS = "M5 12h14";
|
||||
const PLUS = "M12 5v14M5 12h14";
|
||||
|
||||
/** How far outside the viewport a page is drawn before it is scrolled to. */
|
||||
const AHEAD = "1400px 0px";
|
||||
|
||||
interface Frame {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function measurePane(): Frame | null {
|
||||
const pane = document.querySelector(".editor-pane");
|
||||
if (!pane) return null;
|
||||
const r = pane.getBoundingClientRect();
|
||||
return {
|
||||
left: Math.round(r.left),
|
||||
top: Math.round(r.top),
|
||||
width: Math.round(r.width),
|
||||
height: Math.round(r.height),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The mount, which is one `onCommand` and nothing else.
|
||||
*
|
||||
* src/keys/commands.ts dispatches `export-pdf` once it has checked there is a document and a window
|
||||
* to put a file in, exactly as it does for quick open and the command palette. Nothing about the
|
||||
* panel's existence is a precondition for that table to compile.
|
||||
*/
|
||||
export function ExportPreview() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const path = useDocument((s) => s.path);
|
||||
|
||||
useEffect(() => onCommand("export-pdf", () => setOpen(true)), []);
|
||||
useEffect(() => setOpen(false), [path]);
|
||||
|
||||
if (!open || path === null) return null;
|
||||
return <Preview path={path} onClose={() => setOpen(false)} />;
|
||||
}
|
||||
|
||||
function Preview({ path, onClose }: { path: string; onClose: () => void }) {
|
||||
const [pdf, setPdf] = useState<CompiledPdf | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pages, setPages] = useState(0);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [frame, setFrame] = useState<Frame | null>(measurePane);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const zoomRef = useRef(zoom);
|
||||
zoomRef.current = zoom;
|
||||
const started = useRef(false);
|
||||
const live = useRef(true);
|
||||
|
||||
useEscapeLayer(true, onClose);
|
||||
useKeyContext("overlay");
|
||||
|
||||
// Where the editor pane is, now and whenever it moves. Dragging the sidebar's resize handle
|
||||
// moves it without the window changing size, hence the observer as well as the resize listener.
|
||||
useLayoutEffect(() => {
|
||||
const update = () => setFrame(measurePane());
|
||||
update();
|
||||
const pane = document.querySelector(".editor-pane");
|
||||
const observer = new ResizeObserver(update);
|
||||
if (pane) observer.observe(pane);
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Once, on the way in. The document behind the panel cannot change while it is up, so there is
|
||||
// nothing to recompile for, and a compile per keystroke is not what a preview of a fifty page
|
||||
// document should cost.
|
||||
//
|
||||
// Two refs rather than the usual `let cancelled` because of what React's development StrictMode
|
||||
// does to an effect: it runs it, tears it down and runs it again on the same instance, to shake
|
||||
// out cleanups that do not clean up. That is the right test for a subscription and the wrong one
|
||||
// for a typesetter. `started` is what makes the compile happen once per open, and `live` is what
|
||||
// an in-flight result is checked against, because the plain closure flag would have been set by
|
||||
// the teardown between the two runs and the only answer this panel ever gets would be dropped.
|
||||
// Both reset with the component, which is unmounted when the panel closes.
|
||||
useEffect(() => {
|
||||
live.current = true;
|
||||
if (!started.current) {
|
||||
started.current = true;
|
||||
compile()
|
||||
.then((result) => live.current && setPdf(result))
|
||||
.catch((e) => live.current && setError(String(e)));
|
||||
}
|
||||
return () => {
|
||||
live.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Pinch on a trackpad and Ctrl-scroll on a mouse, which are the two gestures a page of a document
|
||||
// is expected to answer. Both have to be taken off the window rather than left to the browser:
|
||||
// the webview's own zoom would scale the chrome around the pages as well.
|
||||
useEffect(() => {
|
||||
const el = panelRef.current;
|
||||
if (!el) return;
|
||||
const clamp = (z: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z));
|
||||
const snap = (z: number) => clamp(Math.round(z / ZOOM_STEP) * ZOOM_STEP);
|
||||
let start = 1;
|
||||
let accumulated = 0;
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (!e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
accumulated += e.deltaY;
|
||||
if (accumulated <= -40) {
|
||||
accumulated = 0;
|
||||
setZoom((z) => clamp(z + ZOOM_STEP));
|
||||
} else if (accumulated >= 40) {
|
||||
accumulated = 0;
|
||||
setZoom((z) => clamp(z - ZOOM_STEP));
|
||||
}
|
||||
};
|
||||
const onGestureStart = (e: Event) => {
|
||||
e.preventDefault();
|
||||
start = zoomRef.current;
|
||||
};
|
||||
const onGestureChange = (e: Event) => {
|
||||
e.preventDefault();
|
||||
const next = snap(start * (e as unknown as { scale: number }).scale);
|
||||
setZoom((z) => (z === next ? z : next));
|
||||
};
|
||||
const onGestureEnd = (e: Event) => e.preventDefault();
|
||||
|
||||
el.addEventListener("wheel", onWheel, { passive: false });
|
||||
el.addEventListener("gesturestart", onGestureStart);
|
||||
el.addEventListener("gesturechange", onGestureChange);
|
||||
el.addEventListener("gestureend", onGestureEnd);
|
||||
return () => {
|
||||
el.removeEventListener("wheel", onWheel);
|
||||
el.removeEventListener("gesturestart", onGestureStart);
|
||||
el.removeEventListener("gesturechange", onGestureChange);
|
||||
el.removeEventListener("gestureend", onGestureEnd);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The file that is about to be written, rather than the document it came from. The title bar an
|
||||
// inch above already says which document this is.
|
||||
const name = suggestedName(path);
|
||||
|
||||
// Closes on a written file and stays put on a cancelled panel, because cancelling means the user
|
||||
// is not finished with the preview yet.
|
||||
const write = async () => {
|
||||
if (!pdf) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
if (await save(pdf)) onClose();
|
||||
} catch (e) {
|
||||
notify(`Could not export: ${String(e)}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const adjustZoom = (delta: number) =>
|
||||
setZoom((z) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round((z + delta) * 100) / 100)));
|
||||
|
||||
// Under this the bar has to drop the words and keep the buttons, which happens when the sidebar
|
||||
// is wide and the window is not.
|
||||
const compact = (frame?.width ?? 1000) < 460;
|
||||
|
||||
return (
|
||||
<div className="overlay preview-overlay" onClick={onClose}>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="panel preview-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="PDF preview"
|
||||
style={
|
||||
frame
|
||||
? { position: "fixed", left: frame.left, top: frame.top, width: frame.width, height: frame.height }
|
||||
: undefined
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="preview-bar">
|
||||
<button className="icon-button" onClick={onClose} title="Close (⎋)" aria-label="Close">
|
||||
<Icon d={icons.CLOSE} />
|
||||
</button>
|
||||
<div className="preview-title">{name}</div>
|
||||
{pages > 0 && !compact && (
|
||||
<span className="preview-count">
|
||||
{pages} {pages === 1 ? "page" : "pages"}
|
||||
</span>
|
||||
)}
|
||||
<div className="preview-zoom">
|
||||
<button
|
||||
className="icon-button"
|
||||
disabled={!pdf || zoom <= ZOOM_MIN}
|
||||
onClick={() => adjustZoom(-ZOOM_STEP)}
|
||||
title="Zoom out"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<Icon d={MINUS} />
|
||||
</button>
|
||||
{!compact && <span>{zoom === 1 ? "Fit" : `${Math.round(zoom * 100)}%`}</span>}
|
||||
<button
|
||||
className="icon-button"
|
||||
disabled={!pdf || zoom >= ZOOM_MAX}
|
||||
onClick={() => adjustZoom(ZOOM_STEP)}
|
||||
title="Zoom in"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<Icon d={PLUS} />
|
||||
</button>
|
||||
</div>
|
||||
<button className="btn-primary" disabled={!pdf || saving} onClick={() => void write()}>
|
||||
{saving ? "Saving…" : compact ? "Save" : "Save PDF…"}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* What the compiler worked around, said before the file is written rather than after.
|
||||
These are the same sentences the toast used to carry alone, and they are worth more
|
||||
here: a diagram that did not draw is a thing to look at on the page below. */}
|
||||
{pdf && pdf.trouble.length > 0 && (
|
||||
<div className="preview-warn">Exported with {pdf.trouble.join("; ")}.</div>
|
||||
)}
|
||||
|
||||
{error !== null ? (
|
||||
<div className="preview-stage">
|
||||
<pre className="preview-error">{error}</pre>
|
||||
</div>
|
||||
) : pdf ? (
|
||||
<Pages bytes={pdf.bytes} zoom={zoom} onPages={setPages} />
|
||||
) : (
|
||||
<div className="preview-stage">
|
||||
<div className="preview-loading">
|
||||
<div className="preview-spinner" />
|
||||
<p>Typesetting…</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The scroller, and the arithmetic that decides how wide a page is drawn.
|
||||
*
|
||||
* At zoom 1 a page is whichever of the stage's two dimensions runs out first, so one whole page is
|
||||
* visible and "Fit" means what it says. Every other zoom is a multiple of that, which is why the
|
||||
* column is `width: max-content` and the stage scrolls in both directions.
|
||||
*/
|
||||
function Pages({
|
||||
bytes,
|
||||
zoom,
|
||||
onPages,
|
||||
}: {
|
||||
bytes: Uint8Array;
|
||||
zoom: number;
|
||||
onPages: (n: number) => void;
|
||||
}) {
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
|
||||
const [ratio, setRatio] = useState<number | null>(null);
|
||||
const [stage, setStage] = useState({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let task: ReturnType<Pdfjs["getDocument"]> | undefined;
|
||||
void (async () => {
|
||||
try {
|
||||
const pdfjs = await loadPdfjs();
|
||||
if (cancelled) return;
|
||||
// A copy, because pdf.js takes ownership of the buffer it is handed and the same bytes
|
||||
// still have to go through the IPC boundary when Save is pressed.
|
||||
task = pdfjs.getDocument({ data: bytes.slice() });
|
||||
const opened = await task.promise;
|
||||
if (cancelled) return;
|
||||
const first = (await opened.getPage(1)).getViewport({ scale: 1 });
|
||||
if (cancelled) return;
|
||||
setDoc(opened);
|
||||
setRatio(first.height / first.width);
|
||||
onPages(opened.numPages);
|
||||
} catch {
|
||||
// A PDF this build cannot parse still saves: the bytes came from the compiler and the
|
||||
// reader is the only thing that failed. The stage stays empty and Save stays live.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void task?.destroy();
|
||||
};
|
||||
}, [bytes, onPages]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = stageRef.current;
|
||||
if (!el) return;
|
||||
const update = () => setStage({ width: el.clientWidth, height: el.clientHeight });
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [doc]);
|
||||
|
||||
const fit = Math.max(240, Math.min(stage.width - 56, (stage.height - 56) / (ratio ?? 1)));
|
||||
const width = Math.round(fit * zoom);
|
||||
|
||||
return (
|
||||
<div className="preview-stage" ref={stageRef}>
|
||||
{doc && ratio !== null && stage.width > 0 && (
|
||||
<div className="preview-col">
|
||||
{Array.from({ length: doc.numPages }, (_, i) => (
|
||||
<Page
|
||||
key={i + 1}
|
||||
doc={doc}
|
||||
number={i + 1}
|
||||
root={stageRef.current}
|
||||
width={width}
|
||||
ratio={ratio}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One page, drawn when it comes near the viewport and thrown away when it leaves.
|
||||
*
|
||||
* A hundred page document is a hundred canvases at device resolution, which is more memory than a
|
||||
* preview is worth, so the holder keeps the page's shape and the canvas inside it comes and goes.
|
||||
* The shape is the first page's until this one has been opened, which is right for every document
|
||||
* whose pages are the same size and self-corrects for the ones that are not.
|
||||
*/
|
||||
function Page({
|
||||
doc,
|
||||
number,
|
||||
root,
|
||||
width,
|
||||
ratio,
|
||||
}: {
|
||||
doc: PDFDocumentProxy;
|
||||
number: number;
|
||||
root: HTMLElement | null;
|
||||
width: number;
|
||||
ratio: number;
|
||||
}) {
|
||||
const holder = useRef<HTMLDivElement>(null);
|
||||
const [near, setNear] = useState(false);
|
||||
const [shape, setShape] = useState(ratio);
|
||||
|
||||
useEffect(() => {
|
||||
const el = holder.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(([entry]) => setNear(entry.isIntersecting), {
|
||||
root,
|
||||
rootMargin: AHEAD,
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [root]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = holder.current;
|
||||
if (!el) return;
|
||||
if (!near) {
|
||||
el.replaceChildren();
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let task: RenderTask | undefined;
|
||||
void (async () => {
|
||||
try {
|
||||
const page = await doc.getPage(number);
|
||||
if (cancelled) return;
|
||||
const natural = page.getViewport({ scale: 1 });
|
||||
setShape(natural.height / natural.width);
|
||||
// Capped at 2, because a 3x screen would draw nine times the pixels for a difference
|
||||
// nobody can see on a page of body text.
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const viewport = page.getViewport({ scale: (width / natural.width) * dpr });
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.className = "preview-canvas";
|
||||
canvas.width = Math.ceil(viewport.width);
|
||||
canvas.height = Math.ceil(viewport.height);
|
||||
task = page.render({ canvas, viewport });
|
||||
await task.promise;
|
||||
if (cancelled) return;
|
||||
el.replaceChildren(canvas);
|
||||
} catch {
|
||||
// Cancelled by a zoom that arrived mid-render, or a page that would not draw. The canvas
|
||||
// already on screen is the better thing to leave up either way.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
task?.cancel();
|
||||
};
|
||||
}, [near, doc, number, width]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={holder}
|
||||
className="preview-page"
|
||||
style={{ width, height: Math.round(width * shape) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,43 @@
|
||||
// The macOS overlay title bar. The traffic lights float over its left end, which is why the row
|
||||
// itself carries `data-tauri-drag-region` and every control inside it does not: an interactive
|
||||
// element that also drags the window swallows its own click.
|
||||
//
|
||||
// The glyphs come from margin-shared, which is where the sibling book app takes them from too, so
|
||||
// that the two do not each have their own idea of what a search or a moon looks like. They are
|
||||
// paths and not a dependency: an icon set is six hundred kilobytes for the handful of shapes a
|
||||
// title bar needs.
|
||||
//
|
||||
// What is in the row is a shorter list than what the app can do, and the cut is on purpose. A
|
||||
// button here is for something whose result the user then looks at on this screen: the sidebar, the
|
||||
// page's measure, its theme, whether it is underlined, where it goes as a PDF. Making a file is not
|
||||
// that, which is why New Document is a keystroke and a row in the menu at the end and not a button
|
||||
// of its own.
|
||||
//
|
||||
// The filename is the way in to everything the document itself holds, which is what the sibling
|
||||
// book app does with the title it shows here. Clicking it opens Document setup, where the name is
|
||||
// the first field and the faces the page is set in are the second, so neither spends a button on
|
||||
// the row and neither is somewhere a reader would not think to look.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { icons } from "margin-shared";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { keyLabel, keysFor } from "../keys/bindings";
|
||||
import { commandLabel, onCommand, runCommand, type CommandId } from "../keys/commands";
|
||||
import { useDocument } from "../store/useDocument";
|
||||
import { useProofing } from "../store/useProofing";
|
||||
import { useTheme } from "../store/useTheme";
|
||||
import { notify } from "../store/useToast";
|
||||
import { useWorkspace } from "../store/useWorkspace";
|
||||
import { DocumentSetup } from "./DocumentSetup";
|
||||
import { splitExtension } from "./FileTree";
|
||||
import { Icon } from "./Icon";
|
||||
import { WidthMenu } from "./WidthMenu";
|
||||
|
||||
|
||||
const SIDEBAR_KEY = "margindocs-sidebar";
|
||||
|
||||
const SIDEBAR_ICON = "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z M9 3v18";
|
||||
const NEW_DOC = "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z M14 3v5h5 M12 12v5 M9.5 14.5h5";
|
||||
const SEARCH = "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14z M20 20l-3.6-3.6";
|
||||
const SUN = "M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10z M12 1v2 M12 21v2 M4.2 4.2l1.4 1.4 M18.4 18.4l1.4 1.4 M1 12h2 M21 12h2 M4.2 19.8l1.4-1.4 M18.4 5.6l1.4-1.4";
|
||||
const MOON = "M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z";
|
||||
const MORE = "M5 12h.01M12 12h.01M19 12h.01";
|
||||
// The glyphs themselves live in margin-shared, so the two apps cannot drift into two ideas of what
|
||||
// a search or a moon looks like. Which of them this row uses, and in what order, is still this
|
||||
// app's decision.
|
||||
const { SIDEBAR: SIDEBAR_ICON, SEARCH, SPELLING, GRAMMAR, EXPORT, MOON, MORE, SUN_RAYS, SUN_DISC } =
|
||||
icons;
|
||||
|
||||
/** A tooltip that names the action and prints its key in the glyphs the sheet uses. */
|
||||
export function shortcutTitle(id: CommandId): string {
|
||||
@@ -30,19 +45,36 @@ export function shortcutTitle(id: CommandId): string {
|
||||
return keys.length ? `${commandLabel(id)} (${keyLabel(keys[0])})` : commandLabel(id);
|
||||
}
|
||||
|
||||
/** The sun, which is the one glyph here that is not a single path. */
|
||||
function Sun() {
|
||||
return (
|
||||
<Icon>
|
||||
<circle cx={SUN_DISC.cx} cy={SUN_DISC.cy} r={SUN_DISC.r} />
|
||||
<path d={SUN_RAYS} />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function Titlebar() {
|
||||
const path = useDocument((s) => s.path);
|
||||
const dirty = useDocument((s) => s.dirty);
|
||||
const externalChange = useDocument((s) => s.externalChange);
|
||||
const renameEntry = useWorkspace((s) => s.renameEntry);
|
||||
const theme = useTheme((s) => s.theme);
|
||||
const toggleTheme = useTheme((s) => s.toggle);
|
||||
|
||||
const spelling = useProofing((s) => s.enabled);
|
||||
const toggleSpelling = useProofing((s) => s.toggle);
|
||||
const spellingAvailability = useProofing((s) => s.availability);
|
||||
const grammar = useProofing((s) => s.grammar);
|
||||
const toggleGrammar = useProofing((s) => s.toggleGrammar);
|
||||
const grammarAvailability = useProofing((s) => s.grammarAvailability);
|
||||
const ensureAvailable = useProofing((s) => s.ensureAvailable);
|
||||
|
||||
const [sidebar, setSidebar] = useState(
|
||||
() => document.documentElement.getAttribute("data-sidebar") !== "false",
|
||||
);
|
||||
const [menu, setMenu] = useState(false);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [setup, setSetup] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-sidebar", String(sidebar));
|
||||
@@ -54,24 +86,22 @@ export function Titlebar() {
|
||||
}, [sidebar]);
|
||||
|
||||
useEffect(() => onCommand("toggle-sidebar", () => setSidebar((v) => !v)), []);
|
||||
useEffect(() => setRenaming(false), [path]);
|
||||
useEffect(() => setSetup(false), [path]);
|
||||
|
||||
// Asked once per launch, and asked here because this is the first thing on screen that has to
|
||||
// know: a machine with no checker gets no button rather than a button that toggles a setting
|
||||
// nothing acts on. The store answers each question once whichever of its callers gets there
|
||||
// first, so this costs nothing when the settings panel or the editor asked already.
|
||||
useEffect(() => ensureAvailable(), [ensureAvailable]);
|
||||
|
||||
useEscapeLayer(menu, () => setMenu(false));
|
||||
|
||||
// What the title bar shows is the file on disk, never the document's H1. The two are unrelated
|
||||
// and this is the place that promise is easiest to break: editing a heading is a content edit and
|
||||
// nothing else, because a path is what git, every other editor and every relative link from
|
||||
// another document already agreed on.
|
||||
const fileName = path ? path.slice(path.lastIndexOf("/") + 1) : "";
|
||||
const { base, hidden } = splitExtension(fileName);
|
||||
|
||||
// The filename and the document's H1 are unrelated, and this is the place that promise is
|
||||
// easiest to break. What the title bar shows is the file on disk, and the only thing that ever
|
||||
// renames it is the user typing here. Editing a heading is a content edit and nothing else: it
|
||||
// does not move the file, because a path is what git, every other editor and every relative
|
||||
// link from another document already agreed on.
|
||||
const commitRename = (next: string) => {
|
||||
setRenaming(false);
|
||||
const trimmed = next.trim();
|
||||
if (!path || !trimmed || trimmed === base) return;
|
||||
renameEntry(path, `${trimmed}${hidden}`).catch((e) => notify(`Could not rename: ${String(e)}`));
|
||||
};
|
||||
const { base } = splitExtension(fileName);
|
||||
|
||||
const menuItem = (id: CommandId) => (
|
||||
<button
|
||||
@@ -85,6 +115,13 @@ export function Titlebar() {
|
||||
</button>
|
||||
);
|
||||
|
||||
// A checker this machine does not have takes its button off the row rather than showing a dead
|
||||
// one, which is the same call src/store/useProofing.ts makes about the underlines themselves. The
|
||||
// settings panel keeps its row and says which checker is missing, because a panel is where
|
||||
// somebody goes to look for a setting and a row that is simply absent reads as a missing feature.
|
||||
const canSpell = spellingAvailability !== "missing";
|
||||
const canCheckGrammar = grammarAvailability !== "missing";
|
||||
|
||||
return (
|
||||
<header className="titlebar" data-tauri-drag-region>
|
||||
<div className="lead">
|
||||
@@ -100,34 +137,26 @@ export function Titlebar() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{path &&
|
||||
(renaming ? (
|
||||
<TitleRename value={base} onCommit={commitRename} onCancel={() => setRenaming(false)} />
|
||||
) : (
|
||||
{path && (
|
||||
<button
|
||||
className="doc-title"
|
||||
data-active={setup}
|
||||
data-external={externalChange === "changed-on-disk"}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={setup}
|
||||
title={
|
||||
externalChange === "changed-on-disk"
|
||||
? `${fileName} (changed on disk). Click to rename.`
|
||||
: `${fileName}. Click to rename.`
|
||||
? `${fileName} (changed on disk). Document setup.`
|
||||
: `${fileName}. Document setup.`
|
||||
}
|
||||
onClick={() => setRenaming(true)}
|
||||
onClick={() => setSetup(true)}
|
||||
>
|
||||
{base}
|
||||
{dirty && <span className="dirty-dot" />}
|
||||
</button>
|
||||
))}
|
||||
)}
|
||||
|
||||
<div className="actions">
|
||||
<button
|
||||
className="icon-button"
|
||||
title={shortcutTitle("new-doc")}
|
||||
aria-label={commandLabel("new-doc")}
|
||||
onClick={() => runCommand("new-doc")}
|
||||
>
|
||||
<Icon d={NEW_DOC} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
title={shortcutTitle("quick-open")}
|
||||
@@ -136,15 +165,59 @@ export function Titlebar() {
|
||||
>
|
||||
<Icon d={SEARCH} />
|
||||
</button>
|
||||
|
||||
{/* The two checkers, which are two buttons because they are two checkers: spelling is the
|
||||
system's and grammar is Harper's, and wanting one underlined without the other is not a
|
||||
strange thing to want. Both are settings rather than actions, so they are pressed
|
||||
toggles and say so. */}
|
||||
{canSpell && (
|
||||
<button
|
||||
className="icon-button"
|
||||
data-active={spelling}
|
||||
title={shortcutTitle("toggle-spelling")}
|
||||
aria-label={commandLabel("toggle-spelling")}
|
||||
aria-pressed={spelling}
|
||||
onClick={toggleSpelling}
|
||||
>
|
||||
<Icon d={SPELLING} />
|
||||
</button>
|
||||
)}
|
||||
{canCheckGrammar && (
|
||||
<button
|
||||
className="icon-button"
|
||||
data-active={grammar}
|
||||
title={shortcutTitle("toggle-grammar")}
|
||||
aria-label={commandLabel("toggle-grammar")}
|
||||
aria-pressed={grammar}
|
||||
onClick={toggleGrammar}
|
||||
>
|
||||
<Icon d={GRAMMAR} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* The measure, which belongs to the app's view of every document. The face, which belongs
|
||||
to the one document, is in the setup panel behind the filename instead: that is the
|
||||
difference between them, and src/store/useDocumentFonts.ts is where it is spelled out. */}
|
||||
<WidthMenu />
|
||||
|
||||
<button
|
||||
className="icon-button"
|
||||
title={shortcutTitle("export-pdf")}
|
||||
aria-label={commandLabel("export-pdf")}
|
||||
onClick={() => runCommand("export-pdf")}
|
||||
>
|
||||
<Icon d={EXPORT} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="icon-button"
|
||||
title={theme === "dark" ? "Light theme" : "Dark theme"}
|
||||
aria-label={commandLabel("toggle-theme")}
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
<Icon d={theme === "dark" ? SUN : MOON} />
|
||||
{theme === "dark" ? <Sun /> : <Icon d={MOON} />}
|
||||
</button>
|
||||
<WidthMenu />
|
||||
|
||||
<div className="menu-wrap">
|
||||
<button
|
||||
className="icon-button"
|
||||
@@ -161,6 +234,7 @@ export function Titlebar() {
|
||||
<div className="menu-backdrop" onClick={() => setMenu(false)} />
|
||||
<div className="menu" role="menu">
|
||||
{menuItem("open-folder")}
|
||||
{menuItem("new-doc")}
|
||||
{menuItem("new-folder")}
|
||||
<div className="menu-sep" />
|
||||
{menuItem("find-in-files")}
|
||||
@@ -174,54 +248,7 @@ export function Titlebar() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{setup && path && <DocumentSetup path={path} onClose={() => setSetup(false)} />}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function TitleRename({
|
||||
value,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: {
|
||||
value: string;
|
||||
onCommit: (next: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const settled = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const input = ref.current;
|
||||
if (!input) return;
|
||||
input.focus();
|
||||
input.select();
|
||||
}, []);
|
||||
|
||||
const commit = (next: string) => {
|
||||
if (settled.current) return;
|
||||
settled.current = true;
|
||||
onCommit(next);
|
||||
};
|
||||
|
||||
useEscapeLayer(true, () => {
|
||||
settled.current = true;
|
||||
onCancel();
|
||||
});
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className="doc-title title-rename"
|
||||
defaultValue={value}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
aria-label="Rename this file"
|
||||
onBlur={(e) => commit(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
e.preventDefault();
|
||||
commit(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// The width control there was no way to click: a title bar button that shows the applied width and
|
||||
// opens the three named steps with the current one marked.
|
||||
// The width control there was no way to click: a title bar button that opens the three named steps
|
||||
// with the applied one ticked, and names it in its own tooltip.
|
||||
//
|
||||
// It belongs beside the theme toggle rather than in the editor pill. Everything in the pill edits
|
||||
// the file; this edits the app's view of it and touches no byte on disk, and the title bar already
|
||||
@@ -21,24 +21,18 @@
|
||||
// the menu, and closing puts it back where it came from.
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { icons } from "margin-shared";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { applyWidth, WIDTHS, type EditorWidth } from "../width";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
const ITEM = ".width-menu-item";
|
||||
|
||||
const CHECK_D = "M20 6L9 17l-5-5";
|
||||
|
||||
/** The page's two edges with three lines of text between them, so the button says which width is
|
||||
* applied without spending a word of the title bar on it. The edges never move and only the
|
||||
* measure does, which is the whole of what the setting changes. The sibling's glyph for this is a
|
||||
* double headed arrow, and it is not ported: an arrow six units long is a smudge at 16px, which is
|
||||
* the only size this is ever drawn at. */
|
||||
const WIDTH_ICON: Record<EditorWidth, string> = {
|
||||
narrow: "M3 4v16M21 4v16M9 7h6M9 12h6M9 17h6",
|
||||
normal: "M3 4v16M21 4v16M7 7h10M7 12h10M7 17h10",
|
||||
wide: "M3 4v16M21 4v16M5 7h14M5 12h14M5 17h14",
|
||||
};
|
||||
// Both glyphs are margin-shared's, so this control looks the same in the book app. The arrow does
|
||||
// not change with the applied width and deliberately does not: an earlier version drew three lines
|
||||
// of text at three measures so the button said which width was on, and the two apps then had
|
||||
// different pictures of the same control. The width is on the menu, ticked, one click away.
|
||||
const { CHECK: CHECK_D, WIDTH: WIDTH_ICON } = icons;
|
||||
|
||||
function isWidth(value: string | null): value is EditorWidth {
|
||||
return WIDTHS.includes(value as EditorWidth);
|
||||
@@ -146,7 +140,7 @@ export function WidthMenu() {
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={toggle}
|
||||
>
|
||||
<Icon d={WIDTH_ICON[width]} />
|
||||
<Icon d={WIDTH_ICON} />
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
|
||||
@@ -563,6 +563,12 @@ export async function mockCall<T>(command: string, args?: Record<string, unknown
|
||||
case "pdf_write":
|
||||
return undefined as T;
|
||||
|
||||
// Three families a browser is certain to resolve, so the "System" half of the font picker has
|
||||
// rows in it and can be clicked. The real list is whatever this machine has installed, which is
|
||||
// not something a fixture gets to pretend to know.
|
||||
case "fonts_list_system":
|
||||
return ["Courier New", "Georgia", "Helvetica"] as unknown as T;
|
||||
|
||||
default:
|
||||
throw new Error(`dev mock has no handler for ${command}`);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
// tree it is already holding. That is not an accident of the current code, it is the requirement.
|
||||
// A save before an export would mean choosing to export a document turns into writing over a file
|
||||
// the user had not decided to write yet, and there is no undo for that on disk.
|
||||
//
|
||||
// The export is two halves, and the seam between them is the point. `compile` produces the bytes;
|
||||
// `save` puts them on disk; between the two sits src/components/ExportPreview.tsx, which draws the
|
||||
// pages the compiler actually produced and does not call `save` until somebody has looked at them
|
||||
// and asked for a file. The sibling book app puts the same panel behind the same command, and the
|
||||
// reason is the same in both: a PDF is the one thing either app makes that its author cannot see
|
||||
// before it exists, and a save panel is a poor place to find out that a diagram did not draw.
|
||||
//
|
||||
// The guard that used to live here, a module-level phase refusing a second export while the first
|
||||
// was running, is gone with it. The panel is the guard now: there is one of it, opening it while
|
||||
// it is open is nothing happening, and the compile belongs to its lifetime.
|
||||
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { save as savePanel } from "@tauri-apps/plugin-dialog";
|
||||
@@ -14,22 +25,34 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { pdfCompile, pdfWrite } from "../api/pdf";
|
||||
import { PDF_WARNINGS_EVENT, isDesktop, isTauri, type PdfWarning } from "../ipc";
|
||||
import { useDocument } from "../store/useDocument";
|
||||
import { useDocumentFonts } from "../store/useDocumentFonts";
|
||||
import { notify } from "../store/useToast";
|
||||
import type { DocumentFonts } from "../model/fonts";
|
||||
import { diagramSources, documentToTypst, renderDiagrams, type Diagrams, type TypstDocument } from "./typst";
|
||||
|
||||
type Phase = "idle" | "converting" | "compiling" | "writing";
|
||||
|
||||
/**
|
||||
* Where the export has got to. A phase rather than a boolean because there are four answers and
|
||||
* only one of them is "nothing is happening", and because the second Cmd-E while a fifty page
|
||||
* document is compiling has to be refused rather than queued behind the first.
|
||||
* A compiled document, waiting to be looked at and then written.
|
||||
*
|
||||
* `path` travels with the bytes rather than being read again at save time, so the file the panel
|
||||
* offers to write is named after the document the pages on screen came from, even if the sidebar
|
||||
* has moved on underneath.
|
||||
*/
|
||||
let phase: Phase = "idle";
|
||||
export interface CompiledPdf {
|
||||
path: string;
|
||||
bytes: Uint8Array;
|
||||
/** What the compile worked around, phrased to be joined into the sentence a toast ends with. */
|
||||
trouble: string[];
|
||||
}
|
||||
|
||||
const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1) || path;
|
||||
|
||||
/** The same name with a .pdf on it, which is what the save panel opens with. */
|
||||
function suggestedName(path: string): string {
|
||||
/**
|
||||
* The same name with a .pdf on it, which is what the save panel opens with.
|
||||
*
|
||||
* Exported because the preview's bar shows it: naming the file that is about to be written is more
|
||||
* use there than repeating the document name the title bar is already showing an inch above.
|
||||
*/
|
||||
export function suggestedName(path: string): string {
|
||||
const name = baseName(path);
|
||||
const dot = name.lastIndexOf(".");
|
||||
return `${dot > 0 ? name.slice(0, dot) : name}.pdf`;
|
||||
@@ -95,79 +118,103 @@ async function compileWithFallback(
|
||||
doc: ProseMirrorNode,
|
||||
path: string,
|
||||
diagrams: Diagrams,
|
||||
fonts: DocumentFonts,
|
||||
first: TypstDocument,
|
||||
): Promise<{ bytes: ArrayBuffer; warnings: PdfWarning[]; retried: boolean }> {
|
||||
try {
|
||||
const attempt = await withWarnings(() => pdfCompile(first.source, first.images));
|
||||
const attempt = await withWarnings(() => pdfCompile(first.source, first.images, fonts));
|
||||
return { bytes: attempt.result, warnings: attempt.warnings, retried: false };
|
||||
} catch (e) {
|
||||
if (!hasMath(doc)) throw e;
|
||||
const literal = documentToTypst(doc, path, { diagrams, math: "literal" });
|
||||
const attempt = await withWarnings(() => pdfCompile(literal.source, literal.images));
|
||||
const literal = documentToTypst(doc, path, { diagrams, math: "literal", fonts });
|
||||
const attempt = await withWarnings(() => pdfCompile(literal.source, literal.images, fonts));
|
||||
return { bytes: attempt.result, warnings: attempt.warnings, retried: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole export, from the tree in the editor to a file on disk.
|
||||
* Whether there is anything to export, and the sentence saying why not when there is not.
|
||||
*
|
||||
* Bound to the `export-pdf` command in src/keys/commands.ts, which is the only caller.
|
||||
* Asked by the `export-pdf` entry in src/keys/commands.ts before it opens the panel, because a
|
||||
* panel that opens onto "no document" is a worse answer than never opening. The save panel is a
|
||||
* window API, so it is not merely absent in a browser, it is refused, and there is no point
|
||||
* compiling a PDF this build has nowhere to put.
|
||||
*/
|
||||
export async function exportPdf(): Promise<void> {
|
||||
if (phase !== "idle") {
|
||||
notify("An export is already running.");
|
||||
return;
|
||||
}
|
||||
|
||||
export function canExport(): boolean {
|
||||
const { path, content } = useDocument.getState();
|
||||
if (path === null || content === null) {
|
||||
notify("Open a document to export it.");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
// The save panel is a window API, so it is not merely absent in a browser, it is refused. There
|
||||
// is no point compiling a PDF this build has nowhere to put.
|
||||
if (!isDesktop) {
|
||||
notify("PDF export needs the desktop app.");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The document on screen, typeset.
|
||||
*
|
||||
* Throws rather than notifying, because its caller is a panel with somewhere to put the message:
|
||||
* a compile that failed is the whole of what that panel has to show, and a toast behind an empty
|
||||
* preview would be the wrong place to read it.
|
||||
*/
|
||||
export async function compile(): Promise<CompiledPdf> {
|
||||
const { path, content } = useDocument.getState();
|
||||
if (path === null || content === null) throw new Error("no document is open");
|
||||
|
||||
phase = "converting";
|
||||
try {
|
||||
// Drawn before the conversion rather than during it, because mermaid is asynchronous and wants
|
||||
// a DOM, and the converter is neither. A diagram that will not draw is simply missing from the
|
||||
// map and comes out of the converter as the fence it always was.
|
||||
const drawings = await renderDiagrams(content);
|
||||
const undrawn = diagramSources(content).length - drawings.size;
|
||||
|
||||
const converted = documentToTypst(content, path, { diagrams: drawings });
|
||||
// The faces the page is in on screen, so an export is the document as its author sees it.
|
||||
const fonts = useDocumentFonts.getState().fonts;
|
||||
const converted = documentToTypst(content, path, { diagrams: drawings, fonts });
|
||||
|
||||
phase = "compiling";
|
||||
const { bytes, warnings, retried } = await compileWithFallback(content, path, drawings, converted);
|
||||
const { bytes, warnings, retried } = await compileWithFallback(
|
||||
content,
|
||||
path,
|
||||
drawings,
|
||||
fonts,
|
||||
converted,
|
||||
);
|
||||
|
||||
phase = "writing";
|
||||
const target = await savePanel({
|
||||
title: "Export as PDF",
|
||||
defaultPath: suggestedName(path),
|
||||
filters: [{ name: "PDF", extensions: ["pdf"] }],
|
||||
});
|
||||
if (target === null) return;
|
||||
|
||||
await pdfWrite(target, new Uint8Array(bytes));
|
||||
|
||||
const trouble = [
|
||||
return {
|
||||
path,
|
||||
bytes: new Uint8Array(bytes),
|
||||
trouble: [
|
||||
...converted.warnings,
|
||||
...(undrawn > 0 ? [`${undrawn} diagram${undrawn === 1 ? "" : "s"} could not be drawn`] : []),
|
||||
...(retried ? ["every formula shown as the source it was written in"] : []),
|
||||
...(warnings.length > 0 ? [describe(warnings)] : []),
|
||||
];
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The native save panel, and the write behind it.
|
||||
*
|
||||
* Answers whether a file was written, which is not the same question as whether anything went
|
||||
* wrong: cancelling the panel is an ordinary way to end an export and gets no toast, because
|
||||
* nothing happened and the user is the one who decided that.
|
||||
*/
|
||||
export async function save(pdf: CompiledPdf): Promise<boolean> {
|
||||
const target = await savePanel({
|
||||
title: "Export as PDF",
|
||||
defaultPath: suggestedName(pdf.path),
|
||||
filters: [{ name: "PDF", extensions: ["pdf"] }],
|
||||
});
|
||||
if (target === null) return false;
|
||||
|
||||
await pdfWrite(target, pdf.bytes);
|
||||
|
||||
notify(
|
||||
trouble.length === 0
|
||||
pdf.trouble.length === 0
|
||||
? `Exported ${baseName(target)}`
|
||||
: `Exported ${baseName(target)}, with ${trouble.join("; ")}`,
|
||||
: `Exported ${baseName(target)}, with ${pdf.trouble.join("; ")}`,
|
||||
);
|
||||
} catch (e) {
|
||||
notify(`Could not export: ${String(e)}`);
|
||||
} finally {
|
||||
phase = "idle";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import type { ImageInput } from "../ipc";
|
||||
import { resolveRelative } from "../links";
|
||||
import { DEFAULT_FONTS, fontFamilyName, type DocumentFonts } from "../model/fonts";
|
||||
import { CALLOUT_LABELS, calloutKindFromLabel, type CalloutKind } from "../model/doc";
|
||||
|
||||
export interface TypstDocument {
|
||||
@@ -48,6 +49,10 @@ export type MathMode = "typeset" | "literal";
|
||||
export interface TypstOptions {
|
||||
diagrams?: Diagrams;
|
||||
math?: MathMode;
|
||||
/** The two faces the document is set in, which default to what tokens.css sets an untouched one
|
||||
* in. Only the body and heading slots follow the document: code and formulas are set in the
|
||||
* machine's own faces either way, which is src-tauri/src/pdf.rs's business and not this one's. */
|
||||
fonts?: DocumentFonts;
|
||||
}
|
||||
|
||||
const MERMAID = "mermaid";
|
||||
@@ -128,15 +133,20 @@ function flow(value: string): string {
|
||||
// chosen here, because an export is the document on screen and not a second design. The light
|
||||
// palette specifically: a PDF is a white page whatever `data-theme` says.
|
||||
//
|
||||
// Literata and Hanken Grotesk are the two families src-tauri/src/pdf.rs compiles into the binary,
|
||||
// so they are always there and can be named here. Monospace and the maths face are not named here
|
||||
// at all: neither is bundled, both come off the machine, and Typst warns once for every family it
|
||||
// was asked for and could not find, so a hopeful list written on this side would end every export
|
||||
// with a toast about fonts nobody chose. src-tauri/src/fonts.rs asks the system what it actually
|
||||
// has, and pdf.rs puts the answer in front of this preamble.
|
||||
// The body and heading faces are the document's own and arrive in `TypstOptions`, because a font
|
||||
// is a per-document setting here the way it is in src/store/useDocumentFonts.ts. Whichever family
|
||||
// they name, src-tauri/src/pdf.rs is handed its bytes before this preamble reaches the compiler.
|
||||
//
|
||||
// Monospace and the maths face are not named here at all: neither is bundled, both come off the
|
||||
// machine, and Typst warns once for every family it was asked for and could not find, so a hopeful
|
||||
// list written on this side would end every export with a toast about fonts nobody chose.
|
||||
// src-tauri/src/fonts.rs asks the system what it actually has, and pdf.rs puts the answer in front
|
||||
// of this preamble.
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
|
||||
const BODY_FONT = "Literata";
|
||||
// The app's own face, and the one face on the page that is not a document decision. prose.css does
|
||||
// not make it one either: the fifth and sixth heading levels are labels rather than headings, and
|
||||
// the sheet sets them in the UI family on screen whatever the document is set in.
|
||||
const UI_FONT = "Hanken Grotesk";
|
||||
|
||||
// The light palette of src/styles/tokens.css, written out because Typst source cannot read a CSS
|
||||
@@ -183,11 +193,13 @@ const CALLOUT_COLOURS: Record<CalloutKind, { edge: string; fill: string; label:
|
||||
* with the backend, and a missing package is a hard compile error rather than a warning, so a
|
||||
* document with no maths in it is not made to depend on it.
|
||||
*/
|
||||
function preamble(title: string, math: boolean): string {
|
||||
function preamble(title: string, math: boolean, fonts: DocumentFonts): string {
|
||||
const mitex = math ? `#import "/mitex/lib.typ": mitex, mi\n\n` : "";
|
||||
const body = fontFamilyName(fonts.body);
|
||||
const heading = fontFamilyName(fonts.heading);
|
||||
return `${mitex}#set document(title: ${str(title)})
|
||||
#set page(paper: "a4", margin: (x: 2.4cm, y: 2.6cm), numbering: "1", number-align: center)
|
||||
#set text(font: ${str(BODY_FONT)}, size: 11pt, fill: ${hex(INK)}, lang: "en")
|
||||
#set text(font: ${str(body)}, size: 11pt, fill: ${hex(INK)}, lang: "en")
|
||||
#set par(leading: 0.7em, spacing: 1.05em, justify: false)
|
||||
#set heading(numbering: none)
|
||||
|
||||
@@ -195,6 +207,7 @@ function preamble(title: string, math: boolean): string {
|
||||
// the reader stops seeing a hierarchy. Size for the first three, a weight step for the fourth, the
|
||||
// UI face for the last two, case for the sixth. prose.css's scale, in print units.
|
||||
#show heading: set block(above: 1.9em, below: 0.55em)
|
||||
#show heading: set text(font: ${str(heading)})
|
||||
#show heading.where(level: 1): set text(size: 1.77em, weight: 600, tracking: -0.016em)
|
||||
#show heading.where(level: 2): set text(size: 1.35em, weight: 600, tracking: -0.008em)
|
||||
#show heading.where(level: 3): set text(size: 1.12em, weight: 600)
|
||||
@@ -766,7 +779,7 @@ export function documentToTypst(doc: ProseMirrorNode, path: string, options: Typ
|
||||
};
|
||||
const rendered = blocks(ctx, doc);
|
||||
return {
|
||||
source: `${preamble(baseName(path), ctx.math)}\n${rendered}\n`,
|
||||
source: `${preamble(baseName(path), ctx.math, options.fonts ?? DEFAULT_FONTS)}\n${rendered}\n`,
|
||||
images: ctx.images,
|
||||
warnings: ctx.warnings,
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { runWritingTool } from "../editor/writing";
|
||||
import { exportPdf } from "../export/run";
|
||||
import { canExport } from "../export/run";
|
||||
import { checkForUpdates } from "../update";
|
||||
import { useDocument } from "../store/useDocument";
|
||||
import { useProofing } from "../store/useProofing";
|
||||
@@ -248,7 +248,16 @@ const TABLE: Record<CommandId, Omit<Command, "id">> = {
|
||||
"new-folder": { label: "New Folder", palette: true, run: () => void createFolder() },
|
||||
"close-folder": { label: "Close Folder", palette: false, run: closeActiveFolder },
|
||||
save: { label: "Save", palette: true, run: () => void saveDocument() },
|
||||
"export-pdf": { label: "Export as PDF…", palette: true, run: () => void exportPdf() },
|
||||
// Dispatched rather than run, because what Export produces first is a panel: the pages go on
|
||||
// screen and the save panel is a button on that panel. The guard stays here, because a preview
|
||||
// that opens onto "no document open" is a worse answer than one that never opens.
|
||||
"export-pdf": {
|
||||
label: "Export as PDF…",
|
||||
palette: true,
|
||||
run: () => {
|
||||
if (canExport()) dispatch("export-pdf");
|
||||
},
|
||||
},
|
||||
"rename-file": { label: "Rename", palette: false, run: () => void renameSelected() },
|
||||
"duplicate-file": { label: "Duplicate", palette: false, run: () => void duplicateSelected() },
|
||||
"delete-file": { label: "Delete", palette: false, run: () => void deleteSelected() },
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
// order their rules expect to win: the document's typography, then the three block sheets that
|
||||
// answer it for a construct with a node view or a decoration of its own, then the page the document
|
||||
// sits on and the section drawn under the end of it, the pill that floats over that page and the
|
||||
// two menus that hang off it, and last the shell, which is the one sheet that reaches back into
|
||||
// the title bar app.css already styled. The palettes come last of all: they override .overlay from
|
||||
// three menus that hang off the title bar, and last the shell, which is the one sheet that reaches
|
||||
// back into the title bar app.css already styled. The palettes come last of all: they override .overlay from
|
||||
// app.css and .key-cap from tree.css, so they have to be able to see both.
|
||||
//
|
||||
// katex.min.css is not here. src/editor/blocks/math.ts imports it itself so that the bundler
|
||||
@@ -26,6 +26,8 @@ import "./styles/sheet.css";
|
||||
import "./styles/backlinks.css";
|
||||
import "./styles/toolbar.css";
|
||||
import "./styles/width-menu.css";
|
||||
import "./styles/document-setup.css";
|
||||
import "./styles/export-preview.css";
|
||||
import "./styles/link-picker.css";
|
||||
import "./styles/tree.css";
|
||||
import "./styles/palette.css";
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// The faces a document can be set in.
|
||||
//
|
||||
// The catalogue itself is margin-shared's, because Margin and Margin Docs have to offer the same
|
||||
// six families under the same ids: a book and a document set in "EB Garamond" must mean the same
|
||||
// file, and a stored preference written by one app has to be readable by the other. What is left
|
||||
// here is the one thing that is this app's own, which is the noun.
|
||||
|
||||
export type {
|
||||
BundledFont,
|
||||
FontCategory,
|
||||
FontPairing,
|
||||
FontRef,
|
||||
} from "margin-shared/fonts";
|
||||
|
||||
export {
|
||||
BUNDLED_FONTS,
|
||||
DEFAULT_FONTS,
|
||||
FONT_PAIRINGS,
|
||||
bundledFont,
|
||||
decodeRef,
|
||||
encodeRef,
|
||||
fontFamilyName,
|
||||
fontLabel,
|
||||
fontStack,
|
||||
fontsEqual,
|
||||
fontsUsed,
|
||||
isFontRef,
|
||||
pairingFor,
|
||||
refsEqual,
|
||||
} from "margin-shared/fonts";
|
||||
|
||||
import type { FontPair } from "margin-shared/fonts";
|
||||
|
||||
/**
|
||||
* What one document is set in.
|
||||
*
|
||||
* An alias rather than a second interface, so every call site in this app keeps reading in the
|
||||
* app's own noun while the shape stays the shared one. Margin calls the same pair `BookFonts`.
|
||||
*/
|
||||
export type DocumentFonts = FontPair;
|
||||
@@ -0,0 +1,153 @@
|
||||
// What the open document is set in, remembered per file.
|
||||
//
|
||||
// Per file and not per app, which is the whole point of the feature: a set of meeting notes and a
|
||||
// short story are not the same kind of page, and a preference that applies to both is the one the
|
||||
// app already made in src/styles/tokens.css. The model is Google Docs', where the face belongs to
|
||||
// the document rather than to the editor.
|
||||
//
|
||||
// Where it is kept, and why it is not the file
|
||||
// --------------------------------------------
|
||||
// A path to a `DocumentFonts`, in localStorage, beside the theme and the width. The obvious other
|
||||
// home is the document's own frontmatter, and it is deliberately not used: src/model/doc.ts holds
|
||||
// frontmatter as an opaque string precisely so that the app never re-emits somebody's YAML, and
|
||||
// writing a key into it would mean parsing and re-serialising the one part of the file this editor
|
||||
// promises not to touch. A font is also not content. `margin-docs-fonts: literata` committed into a
|
||||
// repository is a line every other markdown tool has to ignore, and a diff on every document whose
|
||||
// author tried a face and changed their mind.
|
||||
//
|
||||
// The cost is honest and worth stating: this is a preference on this machine, so the same file
|
||||
// opened on another one, or after the app's storage is cleared, opens in the default pair. Nothing
|
||||
// is lost when that happens, because a font is not text.
|
||||
//
|
||||
// A renamed or moved file loses its entry, for the same reason: the key is the path, and the app
|
||||
// has no identity for a document other than where it lives.
|
||||
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
DEFAULT_FONTS,
|
||||
fontStack,
|
||||
fontsEqual,
|
||||
isFontRef,
|
||||
type DocumentFonts,
|
||||
type FontRef,
|
||||
} from "../model/fonts";
|
||||
|
||||
const KEY = "margindocs-fonts";
|
||||
|
||||
/**
|
||||
* How many documents are remembered, most recently set first.
|
||||
*
|
||||
* A cap because this is a map that only ever grows: every document somebody tries a face on stays
|
||||
* in it forever otherwise, and localStorage is a few megabytes shared with everything else the app
|
||||
* keeps. Two hundred is far past the number of documents anybody sets a font on by hand, and the
|
||||
* entry that falls off the end is the one nobody has touched in the longest time.
|
||||
*/
|
||||
const LIMIT = 200;
|
||||
|
||||
type Stored = Record<string, DocumentFonts>;
|
||||
|
||||
/**
|
||||
* The map as it sits on disk, or an empty one.
|
||||
*
|
||||
* Guarded for the Node test environment and for a webview with storage denied, the same way
|
||||
* src/store/useProofing.ts guards its two booleans. Anything that is not a pair of `FontRef`s is
|
||||
* dropped rather than repaired: a preference written by a version that named its faces differently
|
||||
* is not worth guessing at, and the default pair is always a correct answer.
|
||||
*/
|
||||
function read(): Stored {
|
||||
if (typeof localStorage === "undefined") return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (!raw) return {};
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (typeof parsed !== "object" || parsed === null) return {};
|
||||
const out: Stored = {};
|
||||
for (const [path, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (typeof value !== "object" || value === null) continue;
|
||||
const { body, heading } = value as { body?: unknown; heading?: unknown };
|
||||
if (isFontRef(body) && isFontRef(heading)) out[path] = { body, heading };
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function write(map: Stored): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(map));
|
||||
} catch {
|
||||
// A webview with storage denied still changes the face, it just forgets between launches.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the two custom properties src/styles/prose.css sets the page from, and nothing else.
|
||||
*
|
||||
* The default pair removes them rather than restating the tokens.css values, so there is one place
|
||||
* that decides what an unset document looks like. Same shape as src/width.ts and src/theme.ts: a
|
||||
* setting is an attribute or a property on the root element, never a length or a family written
|
||||
* into a component.
|
||||
*/
|
||||
function apply(fonts: DocumentFonts): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
if (fontsEqual(fonts, DEFAULT_FONTS)) {
|
||||
root.style.removeProperty("--font-book");
|
||||
root.style.removeProperty("--font-heading");
|
||||
return;
|
||||
}
|
||||
root.style.setProperty("--font-book", fontStack(fonts.body));
|
||||
root.style.setProperty("--font-heading", fontStack(fonts.heading));
|
||||
}
|
||||
|
||||
interface DocumentFontsState {
|
||||
/** What the open document is set in, or the default pair when nothing is open. */
|
||||
fonts: DocumentFonts;
|
||||
/** Which file `fonts` is about, so a write cannot land on the document that has since replaced
|
||||
* it. Null between a close and the next open. */
|
||||
path: string | null;
|
||||
|
||||
/** Called when the open document changes, including to nothing. Applies what was remembered. */
|
||||
openFor: (path: string | null) => void;
|
||||
setFonts: (fonts: DocumentFonts) => void;
|
||||
setSlot: (slot: "body" | "heading", ref: FontRef) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useDocumentFonts = create<DocumentFontsState>((set, get) => ({
|
||||
fonts: DEFAULT_FONTS,
|
||||
path: null,
|
||||
|
||||
openFor: (path) => {
|
||||
const fonts = path === null ? DEFAULT_FONTS : (read()[path] ?? DEFAULT_FONTS);
|
||||
apply(fonts);
|
||||
set({ fonts, path });
|
||||
},
|
||||
|
||||
// Applied before it is stored, because the face on screen is the thing the user asked for and
|
||||
// storage is the part that is allowed to fail.
|
||||
setFonts: (fonts) => {
|
||||
const { path } = get();
|
||||
apply(fonts);
|
||||
set({ fonts });
|
||||
if (path === null) return;
|
||||
|
||||
const map = read();
|
||||
// Reinserted rather than assigned in place, so the key order is least-recently-set first and
|
||||
// the trim below drops the right entries. A document set back to the default is removed
|
||||
// instead of stored: an entry that says "what everything else already says" is only there to
|
||||
// be evicted later.
|
||||
delete map[path];
|
||||
if (!fontsEqual(fonts, DEFAULT_FONTS)) map[path] = fonts;
|
||||
|
||||
const paths = Object.keys(map);
|
||||
for (const stale of paths.slice(0, Math.max(0, paths.length - LIMIT))) delete map[stale];
|
||||
write(map);
|
||||
},
|
||||
|
||||
setSlot: (slot, ref) => get().setFonts({ ...get().fonts, [slot]: ref }),
|
||||
|
||||
reset: () => get().setFonts(DEFAULT_FONTS),
|
||||
}));
|
||||
@@ -144,9 +144,13 @@ select {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* On, or open. The two are the same picture on purpose: every one of these is a control whose
|
||||
result is on this screen, so "this button is doing something right now" is the only distinction
|
||||
worth drawing. The inset ring is what separates it from a hover, which shares the wash. */
|
||||
.icon-button[data-active="true"] {
|
||||
background: var(--accent-wash);
|
||||
color: var(--ink);
|
||||
color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px var(--line-strong);
|
||||
}
|
||||
|
||||
:root[data-touch] .icon-button {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/* The Document setup panel, opened by the filename in the title bar.
|
||||
*
|
||||
* It borrows .overlay, .panel, .panel-head, .panel-body and .panel-foot from app.css and the two
|
||||
* buttons from tree.css, for the same reason src/styles/settings.css does: a dialog that looked
|
||||
* like its own app would be the only thing on screen that did. What is here is the labelled field
|
||||
* and the typography section, both ported from the sibling book app's Book setup so that the two
|
||||
* panels are the same object rather than two designs of one idea. */
|
||||
|
||||
.panel-setup {
|
||||
width: min(520px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
/* A label over its control, which is the shape every field in this panel has. The settings panel
|
||||
next door puts its label beside a switch instead, because a switch is an answer and these are
|
||||
values. */
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-1);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-4);
|
||||
color: var(--ink);
|
||||
background: var(--raised);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 9px 11px;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* A select showing a font is set in that font, which is the point of the control. The rule above
|
||||
would otherwise put the UI face on it and every option would read the same. */
|
||||
.field select {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.font-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* A specimen with its name under it, rather than a row of five names. Pairing two faces is the part
|
||||
of this that takes an eye, and five words set in one face say nothing about what any of them
|
||||
looks like. */
|
||||
.font-preset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 9px 12px;
|
||||
min-width: 70px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--raised);
|
||||
transition: border-color 120ms var(--ease), box-shadow 120ms var(--ease);
|
||||
}
|
||||
|
||||
.font-preset:hover {
|
||||
border-color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.font-preset[data-on="true"] {
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
.font-preset-demo {
|
||||
font-size: 23px;
|
||||
line-height: 1;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.font-preset-name {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-1);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
/* The way down to the two selects, and a link rather than a button because what it does is reveal
|
||||
rather than change. Nothing in the panel moves when it is pressed except what appears below it. */
|
||||
.font-advanced {
|
||||
align-self: flex-start;
|
||||
margin-top: 4px;
|
||||
padding: 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-2);
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.font-note {
|
||||
margin: 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-1);
|
||||
line-height: 1.5;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* What the page will look like, since the page itself does not change until Save. A heading and a
|
||||
line of prose, which is the smallest sample that shows both slots and the relation between
|
||||
them. On --paper rather than --raised, because that is what a document sits on. */
|
||||
.font-sample {
|
||||
padding: 15px 17px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.font-sample-title {
|
||||
display: block;
|
||||
font-size: 21px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* The title bar's own button, which is now a way into this panel and says so while it is open. */
|
||||
.titlebar .doc-title[data-active="true"] {
|
||||
color: var(--ink);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/* The PDF preview, drawn over the editor pane.
|
||||
|
||||
Square and unshadowed, unlike every other panel in the app, because it is not floating over the
|
||||
page: it is standing where the page was. The pane it covers runs to the window's edges on three
|
||||
sides, so a rounded corner would show a sliver of the document underneath and read as a bug. The
|
||||
scrim behind it still dims the sidebar, which is what says the app is waiting on this.
|
||||
|
||||
The pages themselves are white on --shell rather than on --paper, in both themes. A PDF is white
|
||||
paper, that is what the file will look like when it is opened anywhere else, and a preview that
|
||||
inverted with the app's theme would be showing something that does not exist. */
|
||||
|
||||
.preview-overlay {
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.preview-bar {
|
||||
flex: none;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
background: var(--paper);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: var(--font-book);
|
||||
font-size: var(--t-4);
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-count {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-2);
|
||||
color: var(--ink-faint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.preview-zoom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* A fixed width and tabular figures, so stepping through 75%, 100%, 125% does not shuffle the
|
||||
buttons either side of it. */
|
||||
.preview-zoom span {
|
||||
width: 44px;
|
||||
text-align: center;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-2);
|
||||
color: var(--ink-soft);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.preview-bar .icon-button:disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.preview-warn {
|
||||
flex: none;
|
||||
padding: 9px 18px;
|
||||
background: var(--accent-wash);
|
||||
color: var(--accent-ink);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-2);
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.preview-stage {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 28px;
|
||||
background: var(--shell);
|
||||
}
|
||||
|
||||
/* max-content so a page zoomed past the stage's width scrolls sideways instead of being squeezed,
|
||||
and min-width 100% so one narrower than the stage still centres. */
|
||||
.preview-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.preview-page {
|
||||
flex: none;
|
||||
background: #fff;
|
||||
border-radius: 2px;
|
||||
box-shadow: var(--shadow-page);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.preview-loading {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.preview-loading p {
|
||||
margin: 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-3);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.preview-spinner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 3px solid var(--line-strong);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: preview-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes preview-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* A compile that failed says why, in the compiler's own words and on its own lines. Wrapped rather
|
||||
than scrolled sideways: a Typst error is a paragraph, not a line of code. */
|
||||
.preview-error {
|
||||
margin: 0 auto;
|
||||
max-width: 60ch;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--t-3);
|
||||
line-height: 1.6;
|
||||
color: var(--danger-ink);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.preview-spinner {
|
||||
animation-duration: 2.4s;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,4 @@
|
||||
@font-face {
|
||||
font-family: "Hanken Grotesk";
|
||||
src: url("/fonts/HankenGrotesk-VF.ttf") format("truetype-variations");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Hanken Grotesk";
|
||||
src: url("/fonts/HankenGrotesk-Italic-VF.ttf") format("truetype-variations");
|
||||
font-weight: 100 900;
|
||||
font-style: italic;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Literata";
|
||||
src: url("/fonts/Literata-VF.ttf") format("truetype-variations");
|
||||
font-weight: 200 900;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Literata";
|
||||
src: url("/fonts/Literata-Italic-VF.ttf") format("truetype-variations");
|
||||
font-weight: 200 900;
|
||||
font-style: italic;
|
||||
font-display: block;
|
||||
}
|
||||
/* The six families, declared once in margin-shared so that Margin and Margin Docs bind the same
|
||||
files to the same family names. The faces themselves are served from this app's public/fonts,
|
||||
which `pnpm fonts:sync` fills from the package and `pnpm fonts:check` verifies. */
|
||||
@import "margin-shared/css/fonts.css";
|
||||
@@ -1,92 +1,23 @@
|
||||
/* The palette, the type scale and the shape constants come from margin-shared, which is the one
|
||||
copy of the values both apps have to agree on. Everything below is what only a document editor
|
||||
needs: a six level heading scale, the rules and code surfaces a page of prose draws, and the
|
||||
measures the sheet is laid out at. */
|
||||
@import "margin-shared/css/tokens.css";
|
||||
|
||||
/* The handful of shape and scale tokens this app adds to the shared set. */
|
||||
:root {
|
||||
--font-ui: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-book: "Literata", Georgia, "Times New Roman", serif;
|
||||
--font-heading: "Literata", Georgia, "Times New Roman", serif;
|
||||
--font-mono: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
|
||||
--r-sm: 5px;
|
||||
--r-md: 8px;
|
||||
--r-lg: 12px;
|
||||
--r-pill: 999px;
|
||||
|
||||
--pane-sidebar: 248px;
|
||||
--measure: 46em;
|
||||
--titlebar-h: 46px;
|
||||
--traffic-pad: 84px;
|
||||
--touch-h: 44px;
|
||||
|
||||
--t-1: 11px;
|
||||
--t-2: 12px;
|
||||
--t-3: 13px;
|
||||
--t-4: 15px;
|
||||
--t-5: 16px;
|
||||
|
||||
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
}
|
||||
|
||||
:root,
|
||||
:root[data-theme="light"] {
|
||||
--paper: #fcfbf7;
|
||||
--shell: #f1ece2;
|
||||
--sidebar: #ece6da;
|
||||
--raised: #fbfaf6;
|
||||
|
||||
--ink: #23201b;
|
||||
--ink-soft: #6b6458;
|
||||
--ink-faint: #9b9484;
|
||||
|
||||
--line: #e3ddce;
|
||||
--line-strong: #d6cfbd;
|
||||
|
||||
--accent: #2a2622;
|
||||
--accent-ink: #100e0b;
|
||||
--accent-wash: rgba(35, 32, 27, 0.08);
|
||||
--accent-contrast: #faf7f0;
|
||||
--selection: rgba(35, 32, 27, 0.14);
|
||||
--glass: rgba(252, 251, 247, 0.86);
|
||||
|
||||
--danger: #b4453a;
|
||||
--danger-ink: #963327;
|
||||
--danger-wash: rgba(180, 69, 58, 0.1);
|
||||
--danger-contrast: #faf7f0;
|
||||
|
||||
--shadow-page: 0 1px 2px rgba(35, 32, 27, 0.06), 0 14px 30px rgba(35, 32, 27, 0.09);
|
||||
--shadow-pop: 0 8px 24px rgba(35, 32, 27, 0.14);
|
||||
--shadow-raised: 0 1px 2px rgba(35, 32, 27, 0.05);
|
||||
|
||||
--scrim: rgba(35, 32, 27, 0.28);
|
||||
--shadow-raised: 0 1px 2px rgba(35, 32, 27, 0.05);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--paper: #1d1a16;
|
||||
--shell: #151310;
|
||||
--sidebar: #1a1713;
|
||||
--raised: #232019;
|
||||
|
||||
--ink: #ece6da;
|
||||
--ink-soft: #a89f8e;
|
||||
--ink-faint: #756d5e;
|
||||
|
||||
--line: #2c2823;
|
||||
--line-strong: #3a352d;
|
||||
|
||||
--accent: #efe9dc;
|
||||
--accent-ink: #ffffff;
|
||||
--accent-wash: rgba(239, 233, 220, 0.12);
|
||||
--accent-contrast: #1a1714;
|
||||
--selection: rgba(239, 233, 220, 0.18);
|
||||
--glass: rgba(33, 30, 25, 0.88);
|
||||
|
||||
--danger: #d97a6f;
|
||||
--danger-ink: #e58e84;
|
||||
--danger-wash: rgba(217, 122, 111, 0.16);
|
||||
--danger-contrast: #1a1714;
|
||||
|
||||
--shadow-page: 0 1px 2px rgba(0, 0, 0, 0.4), 0 14px 34px rgba(0, 0, 0, 0.5);
|
||||
--shadow-pop: 0 8px 24px rgba(0, 0, 0, 0.55);
|
||||
--shadow-raised: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||
|
||||
--scrim: rgba(0, 0, 0, 0.5);
|
||||
--shadow-raised: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* Document typography, added for the editor surface. The four step scale above sizes chrome and
|
||||
|
||||
@@ -22,12 +22,23 @@
|
||||
// wrote nothing because it never got past the first paragraph is the shape of green this suite is
|
||||
// built to refuse.
|
||||
//
|
||||
// WHAT A BROWSER CANNOT REACH. `exportPdf` gates on `isDesktop`, which is `isTauri` and not a
|
||||
// The export is two gestures now rather than one. Cmd-E opens the preview in
|
||||
// src/components/ExportPreview.tsx, which compiles and puts the pages on screen; Save PDF is what
|
||||
// reaches the native panel and the write. That changes the shape of this test and not its subject:
|
||||
// the promise is still that nothing between the keystroke and the write touches the document, and
|
||||
// the command log below is still the whole of the evidence. If anything, the window in which a
|
||||
// stray save could hide got longer, because the buffer now sits dirty behind an open panel for as
|
||||
// long as the user looks at the pages.
|
||||
//
|
||||
// WHAT A BROWSER CANNOT REACH. The command gates on `isDesktop`, which is `isTauri` and not a
|
||||
// phone. `isTauri` reads `__TAURI_INTERNALS__` off the window and tests/disk.ts puts that there at
|
||||
// document start, so the gate opens and the whole path runs: convert, draw, compile, panel, write.
|
||||
// What is behind it is the dev fixture, so `pdf_compile` answers with a PDF header instead of a
|
||||
// typeset document and `pdf_write` puts nothing anywhere. That the compiler produces a real PDF and
|
||||
// that the write lands where it was pointed are Rust questions, and they are asked in
|
||||
// document start, so the gate opens and the whole path runs: convert, draw, compile, preview,
|
||||
// panel, write. What is behind it is the dev fixture, so `pdf_compile` answers with a PDF header
|
||||
// instead of a typeset document and `pdf_write` puts nothing anywhere. Eight bytes is not a PDF
|
||||
// pdf.js can open, so the preview's stage stays empty here and the page count stays at zero: what
|
||||
// this suite drives is the bar around it, which is live either way because the bytes it saves came
|
||||
// from the compiler and not from the reader. That the compiler produces a real PDF, that pdf.js
|
||||
// draws it, and that the write lands where it was pointed are questions for a real build and for
|
||||
// src-tauri/tests/export_writes_only_the_pdf.rs. The one piece nothing can drive from either side
|
||||
// is the native save panel itself: it is an AppKit window, so this spec answers the command for it
|
||||
// and cannot say what a real panel would hand back for a name the user typed. That distinction
|
||||
@@ -200,6 +211,23 @@ async function runFromMenu(page: Page, command: string): Promise<void> {
|
||||
expect(delivered, "the app has no menu-action listener to fire at").toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const preview = (page: Page) => page.locator(".preview-panel");
|
||||
const savePdf = (page: Page) => preview(page).locator(".btn-primary");
|
||||
|
||||
/**
|
||||
* Opens the preview and waits for the compile behind it to finish.
|
||||
*
|
||||
* Save is disabled until there are bytes to save, so a live button is the signal that `pdf_compile`
|
||||
* has answered. Waiting on that rather than on the pages is deliberate: the fixture's eight bytes
|
||||
* never become pages, and a test that waited for a canvas would be waiting for the compiler this
|
||||
* build does not have.
|
||||
*/
|
||||
async function openPreview(page: Page): Promise<void> {
|
||||
await runFromMenu(page, "export-pdf");
|
||||
await expect(preview(page)).toBeVisible();
|
||||
await expect(savePdf(page)).toBeEnabled({ timeout: EXPORTED });
|
||||
}
|
||||
|
||||
/**
|
||||
* Types a character into the first paragraph and holds the save that would follow, so the buffer is
|
||||
* dirty and the file is not, for as long as the caller needs. Answers with the log as it stood the
|
||||
@@ -242,8 +270,18 @@ test("an export of a document with unsaved edits writes nothing but the PDF", as
|
||||
window.__savePanel = target;
|
||||
}, TARGET);
|
||||
|
||||
await runFromMenu(page, "export-pdf");
|
||||
await openPreview(page);
|
||||
|
||||
// The compile happened on the way into the preview and nothing has been written yet, which is
|
||||
// the half of this flow that did not exist before. A panel that had reached for the save dialog
|
||||
// on its own would show up here.
|
||||
const compiled = (await sent(page)).slice(line).map((call) => call.command);
|
||||
expect(compiled).toEqual(["plugin:event|listen", "pdf_compile", "plugin:event|unlisten"]);
|
||||
|
||||
await savePdf(page).click();
|
||||
await expect(toast(page)).toContainText("Exported README.pdf", { timeout: EXPORTED });
|
||||
// A written file is the end of the preview's job, so it closes behind the toast.
|
||||
await expect(preview(page)).toHaveCount(0);
|
||||
|
||||
const during = (await sent(page)).slice(line);
|
||||
|
||||
@@ -320,9 +358,10 @@ test("cancelling the save panel leaves the document exactly where it was", async
|
||||
await expect(page.locator(".prose .mermaid-block")).toHaveCount(1);
|
||||
|
||||
const line = await dirtyWithHeldSave(page);
|
||||
// Left as null, which is what the panel answers when the user presses Cancel.
|
||||
// `__savePanel` is left as null, which is what the panel answers when the user presses Cancel.
|
||||
|
||||
await runFromMenu(page, "export-pdf");
|
||||
await openPreview(page);
|
||||
await savePdf(page).click();
|
||||
await expect
|
||||
.poll(async () => (await commands(page)).filter((c) => c === "plugin:dialog|save").length, {
|
||||
timeout: EXPORTED,
|
||||
@@ -340,4 +379,10 @@ test("cancelling the save panel leaves the document exactly where it was", async
|
||||
expect(await disk(page)).toBe(SOURCE);
|
||||
await expect(page.locator(".dirty-dot")).toBeVisible();
|
||||
await expect(toast(page)).toHaveCount(0);
|
||||
|
||||
// And the preview is still up, holding the bytes it already compiled. Cancelling the save panel
|
||||
// is somebody deciding not to write this file yet, not somebody finished looking at it, so a
|
||||
// second Save must not cost a second compile.
|
||||
await expect(preview(page)).toBeVisible();
|
||||
await expect(savePdf(page)).toBeEnabled();
|
||||
});
|
||||