feat(Storybook): add basic Figma integration
After setting Figma private token, Figma desings related to a given Storybook page can be browsed directly via the Storybook app. Closes: #8188
This commit is contained in:
parent
ff9c678a1e
commit
ec38dca735
|
@ -31,10 +31,40 @@ ApplicationWindow {
|
|||
|
||||
ListElement {
|
||||
title: "ProfileDialogView"
|
||||
|
||||
figma: [
|
||||
ListElement {
|
||||
link: "https://www.figma.com/file/ibJOTPlNtIxESwS96vJb06/%F0%9F%91%A4-Profile-%7C-Desktop?node-id=733%3A12552"
|
||||
},
|
||||
ListElement {
|
||||
link: "https://www.figma.com/file/ibJOTPlNtIxESwS96vJb06/%F0%9F%91%A4-Profile-%7C-Desktop?node-id=682%3A15078"
|
||||
},
|
||||
ListElement {
|
||||
link: "https://www.figma.com/file/ibJOTPlNtIxESwS96vJb06/%F0%9F%91%A4-Profile-%7C-Desktop?node-id=682%3A17655"
|
||||
},
|
||||
ListElement {
|
||||
link: "https://www.figma.com/file/ibJOTPlNtIxESwS96vJb06/%F0%9F%91%A4-Profile-%7C-Desktop?node-id=682%3A17087"
|
||||
},
|
||||
ListElement {
|
||||
link: "https://www.figma.com/file/ibJOTPlNtIxESwS96vJb06/%F0%9F%91%A4-Profile-%7C-Desktop?node-id=4%3A23525"
|
||||
},
|
||||
ListElement {
|
||||
link: "https://www.figma.com/file/ibJOTPlNtIxESwS96vJb06/%F0%9F%91%A4-Profile-%7C-Desktop?node-id=4%3A23932"
|
||||
}
|
||||
]
|
||||
section: "Views"
|
||||
}
|
||||
ListElement {
|
||||
title: "CommunitiesPortalLayout"
|
||||
|
||||
figma: [
|
||||
ListElement {
|
||||
link: "https://www.figma.com/file/17fc13UBFvInrLgNUKJJg5/Kuba%E2%8E%9CDesktop?node-id=8159%3A415655"
|
||||
},
|
||||
ListElement {
|
||||
link: "https://www.figma.com/file/17fc13UBFvInrLgNUKJJg5/Kuba%E2%8E%9CDesktop?node-id=8159%3A415935"
|
||||
}
|
||||
]
|
||||
section: "Views"
|
||||
}
|
||||
ListElement {
|
||||
|
@ -55,6 +85,15 @@ ApplicationWindow {
|
|||
}
|
||||
ListElement {
|
||||
title: "StatusCommunityCard"
|
||||
|
||||
figma: [
|
||||
ListElement {
|
||||
link: "https://www.figma.com/file/17fc13UBFvInrLgNUKJJg5/Kuba%E2%8E%9CDesktop?node-id=8159%3A416159"
|
||||
},
|
||||
ListElement {
|
||||
link: "https://www.figma.com/file/17fc13UBFvInrLgNUKJJg5/Kuba%E2%8E%9CDesktop?node-id=8159%3A416160"
|
||||
}
|
||||
]
|
||||
section: "Panels"
|
||||
}
|
||||
ListElement {
|
||||
|
@ -87,48 +126,54 @@ ApplicationWindow {
|
|||
SplitView {
|
||||
anchors.fill: parent
|
||||
|
||||
ColumnLayout {
|
||||
SplitView.preferredWidth: 240
|
||||
Pane {
|
||||
SplitView.preferredWidth: 270
|
||||
|
||||
CheckBox {
|
||||
id: loadAsyncCheckBox
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
height: parent.height
|
||||
|
||||
Layout.fillWidth: true
|
||||
Button {
|
||||
Layout.fillWidth: true
|
||||
|
||||
text: "Load asynchronously"
|
||||
}
|
||||
text: "Settings"
|
||||
|
||||
CheckBox {
|
||||
id: darkModeCheckBox
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
text: "Dark mode"
|
||||
|
||||
StatusLightTheme { id: lightTheme }
|
||||
StatusDarkTheme { id: darkTheme }
|
||||
|
||||
Binding {
|
||||
target: Theme
|
||||
property: "palette"
|
||||
value: darkModeCheckBox.checked ? darkTheme : lightTheme
|
||||
onClicked: settingsPopup.open()
|
||||
}
|
||||
}
|
||||
|
||||
HotReloaderControls {
|
||||
id: hotReloaderControls
|
||||
CheckBox {
|
||||
id: darkModeCheckBox
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.fillWidth: true
|
||||
|
||||
onForceReloadClicked: reloader.forceReload()
|
||||
}
|
||||
text: "Dark mode"
|
||||
|
||||
Pane {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
StatusLightTheme { id: lightTheme }
|
||||
StatusDarkTheme { id: darkTheme }
|
||||
|
||||
Binding {
|
||||
target: Theme
|
||||
property: "palette"
|
||||
value: darkModeCheckBox.checked ? darkTheme : lightTheme
|
||||
}
|
||||
}
|
||||
|
||||
HotReloaderControls {
|
||||
id: hotReloaderControls
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
onForceReloadClicked: reloader.forceReload()
|
||||
}
|
||||
|
||||
MenuSeparator {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
FilteredPagesList {
|
||||
anchors.fill: parent
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
currentPage: root.currentPage
|
||||
model: pagesModel
|
||||
|
||||
|
@ -137,7 +182,7 @@ ApplicationWindow {
|
|||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Page {
|
||||
SplitView.fillWidth: true
|
||||
|
||||
Loader {
|
||||
|
@ -147,7 +192,7 @@ ApplicationWindow {
|
|||
clip: true
|
||||
|
||||
source: `pages/${root.currentPage}Page.qml`
|
||||
asynchronous: loadAsyncCheckBox.checked
|
||||
asynchronous: settingsLayout.loadAsynchronously
|
||||
visible: status === Loader.Ready
|
||||
|
||||
// force reload when `asynchronous` changes
|
||||
|
@ -167,13 +212,107 @@ ApplicationWindow {
|
|||
visible: viewLoader.status === Loader.Error
|
||||
text: "Loading page failed"
|
||||
}
|
||||
|
||||
footer: PageToolBar {
|
||||
id: pageToolBar
|
||||
|
||||
title: `pages/${root.currentPage}Page.qml`
|
||||
figmaPagesCount: currentPageModelItem.object
|
||||
? currentPageModelItem.object.figmaCount : 0
|
||||
|
||||
Instantiator {
|
||||
id: currentPageModelItem
|
||||
|
||||
model: SingleItemProxyModel {
|
||||
sourceModel: pagesModel
|
||||
roleName: "title"
|
||||
value: root.currentPage
|
||||
}
|
||||
|
||||
delegate: QtObject {
|
||||
readonly property string title: model.title
|
||||
readonly property var figma: model.figma
|
||||
readonly property int figmaCount: figma ? figma.count : 0
|
||||
}
|
||||
}
|
||||
|
||||
onFigmaPreviewClicked: {
|
||||
if (!settingsLayout.figmaToken) {
|
||||
noFigmaTokenDialog.open()
|
||||
return
|
||||
}
|
||||
|
||||
const window = figmaWindow.createObject(root, {
|
||||
figmaModel: currentPageModelItem.object.figma,
|
||||
title: currentPageModelItem.object.title + " - Figma"
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dialog {
|
||||
id: settingsPopup
|
||||
|
||||
anchors.centerIn: Overlay.overlay
|
||||
width: 420
|
||||
modal: true
|
||||
|
||||
header: Pane {
|
||||
background: null
|
||||
|
||||
Label {
|
||||
text: "Settings"
|
||||
}
|
||||
}
|
||||
|
||||
SettingsLayout {
|
||||
id: settingsLayout
|
||||
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
|
||||
Dialog {
|
||||
id: noFigmaTokenDialog
|
||||
|
||||
anchors.centerIn: Overlay.overlay
|
||||
|
||||
title: "Figma token not set"
|
||||
standardButtons: Dialog.Ok
|
||||
|
||||
Label {
|
||||
text: "Please set Figma personal token in \"Settings\""
|
||||
}
|
||||
}
|
||||
|
||||
FigmaLinksCache {
|
||||
id: figmaImageLinksCache
|
||||
|
||||
figmaToken: settingsLayout.figmaToken
|
||||
}
|
||||
|
||||
Component {
|
||||
id: figmaWindow
|
||||
|
||||
FigmaPreviewWindow {
|
||||
property alias figmaModel: figmaImagesProxyModel.sourceModel
|
||||
|
||||
model: FigmaImagesProxyModel {
|
||||
id: figmaImagesProxyModel
|
||||
|
||||
figmaLinksCache: figmaImageLinksCache
|
||||
}
|
||||
|
||||
onClosing: Qt.callLater(destroy)
|
||||
}
|
||||
}
|
||||
|
||||
Settings {
|
||||
property alias currentPage: root.currentPage
|
||||
property alias loadAsynchronously: loadAsyncCheckBox.checked
|
||||
property alias loadAsynchronously: settingsLayout.loadAsynchronously
|
||||
property alias darkMode: darkModeCheckBox.checked
|
||||
property alias hotReloading: hotReloaderControls.enabled
|
||||
property alias figmaToken: settingsLayout.figmaToken
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
import QtQuick 2.14
|
||||
|
||||
ListModel {
|
||||
/* required */ property FigmaLinksCache figmaLinksCache
|
||||
property alias sourceModel: d.model
|
||||
|
||||
readonly property Instantiator _d: Instantiator {
|
||||
id: d
|
||||
|
||||
model: 0
|
||||
|
||||
delegate: QtObject {
|
||||
id: delegate
|
||||
|
||||
Component.onCompleted: {
|
||||
append({
|
||||
rawLink: model.link,
|
||||
imageLink: ""
|
||||
})
|
||||
|
||||
figmaLinksCache.getImageUrl(model.link, link => {
|
||||
if (delegate)
|
||||
setProperty(model.index, "imageLink", link)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onObjectRemoved: console.warn("FigmaImagesProxyModel: removing items from the source model is not supported!")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import QtQml 2.14
|
||||
|
||||
QtObject {
|
||||
id: root
|
||||
|
||||
property string figmaToken
|
||||
|
||||
readonly property QtObject _d: QtObject {
|
||||
id: d
|
||||
|
||||
readonly property var linksMap: new Map()
|
||||
|
||||
function createKey(file, nodeId) {
|
||||
return file + "/" + nodeId
|
||||
}
|
||||
}
|
||||
|
||||
function getImageUrl(figmaLink, cb) {
|
||||
const { file, nodeId } = FigmaUtils.decomposeLink(figmaLink)
|
||||
const key = d.createKey(file, nodeId);
|
||||
|
||||
if (d.linksMap.has(key)) {
|
||||
cb(d.linksMap.get(key))
|
||||
} else {
|
||||
FigmaUtils.getLinks(root.figmaToken, file, [nodeId],
|
||||
(err, result) => {
|
||||
if (err)
|
||||
return cb(null)
|
||||
|
||||
for (const value of Object.values(result)) {
|
||||
d.linksMap.set(key, value)
|
||||
return cb(value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
import QtQuick 2.14
|
||||
import QtQuick.Controls 2.14
|
||||
|
||||
ApplicationWindow {
|
||||
id: root
|
||||
|
||||
width: 1024
|
||||
height: 768
|
||||
visible: true
|
||||
|
||||
property var model
|
||||
|
||||
SwipeView {
|
||||
id: topSwipeView
|
||||
|
||||
anchors.fill: parent
|
||||
|
||||
orientation: Qt.Vertical
|
||||
interactive: false
|
||||
|
||||
ImagesGridView {
|
||||
clip: true
|
||||
model: root.model
|
||||
|
||||
onClicked: {
|
||||
imagesSwipeView.setCurrentIndex(index)
|
||||
topSwipeView.incrementCurrentIndex()
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
SwipeView {
|
||||
id: imagesSwipeView
|
||||
|
||||
anchors.fill: parent
|
||||
currentIndex: imageNavigationLayout.currentIndex
|
||||
|
||||
Repeater {
|
||||
id: repeater
|
||||
|
||||
model: root.model
|
||||
|
||||
FlickableImage {
|
||||
source: model.imageLink
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImagesNavigationLayout {
|
||||
id: imageNavigationLayout
|
||||
|
||||
anchors.bottom: imagesSwipeView.bottom
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
count: imagesSwipeView.count
|
||||
currentIndex: imagesSwipeView.currentIndex
|
||||
|
||||
onUp: topSwipeView.decrementCurrentIndex()
|
||||
onLeft: imagesSwipeView.decrementCurrentIndex()
|
||||
onRight: imagesSwipeView.incrementCurrentIndex()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
pragma Singleton
|
||||
|
||||
import QtQml 2.14
|
||||
|
||||
QtObject {
|
||||
function decomposeLink(link) {
|
||||
const fileRegex = /www\.figma\.com\/file\/([a-zA-Z0-9]+)/
|
||||
const fileMatch = link.match(fileRegex)
|
||||
|
||||
const nodeIdRegex = /node-id=([0-9A-Za-z%]+)/
|
||||
const nodeIdMatch = link.match(nodeIdRegex)
|
||||
|
||||
return {
|
||||
file: fileMatch[1],
|
||||
nodeId: nodeIdMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
function getLinks(token, file, nodeIds, cb) {
|
||||
|
||||
console.assert(nodeIds.length > 0)
|
||||
|
||||
const ids = nodeIds.join()
|
||||
const url = `https://api.figma.com/v1/images/${file}?ids=${ids}`
|
||||
|
||||
const http = new XMLHttpRequest()
|
||||
http.open("GET", url, true)
|
||||
http.setRequestHeader("X-FIGMA-TOKEN", token)
|
||||
|
||||
http.onreadystatechange = () => {
|
||||
if (http.readyState !== 4)
|
||||
return
|
||||
|
||||
if (http.status === 200)
|
||||
cb(null, JSON.parse(http.response).images)
|
||||
else
|
||||
cb(`Failed to fetch figma image links, status: ${http.status}`)
|
||||
}
|
||||
|
||||
http.send()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
import QtQuick 2.14
|
||||
import QtQuick.Controls 2.14
|
||||
|
||||
Item {
|
||||
property alias source: image.source
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: "lightgray"
|
||||
}
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
|
||||
contentWidth: image.implicitWidth
|
||||
contentHeight: image.implicitHeight
|
||||
|
||||
clip: true
|
||||
|
||||
ScrollIndicator.vertical: ScrollIndicator {}
|
||||
ScrollIndicator.horizontal: ScrollIndicator {}
|
||||
|
||||
Image {
|
||||
id: image
|
||||
}
|
||||
}
|
||||
|
||||
BusyIndicator {
|
||||
anchors.centerIn: parent
|
||||
running: image.status !== Image.Ready
|
||||
}
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
import QtQuick 2.14
|
||||
import QtQuick.Controls 2.14
|
||||
|
||||
GridView {
|
||||
id: root
|
||||
|
||||
cellWidth: 400
|
||||
cellHeight: 300
|
||||
|
||||
signal clicked(int index)
|
||||
|
||||
delegate: Item {
|
||||
width: root.cellWidth
|
||||
height: root.cellHeight
|
||||
|
||||
Frame {
|
||||
anchors.fill: parent
|
||||
anchors.margins: padding / 2
|
||||
|
||||
Image {
|
||||
id: image
|
||||
|
||||
anchors.fill: parent
|
||||
mipmap: true
|
||||
|
||||
source: model.imageLink
|
||||
fillMode: Image.PreserveAspectFit
|
||||
}
|
||||
|
||||
BusyIndicator {
|
||||
anchors.centerIn: parent
|
||||
running: image.status !== Image.Ready
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
|
||||
onClicked: root.clicked(model.index)
|
||||
}
|
||||
|
||||
RoundButton {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.right: parent.right
|
||||
|
||||
text: "🔗"
|
||||
|
||||
onClicked: Qt.openUrlExternally(model.rawLink)
|
||||
|
||||
ToolTip.delay: 1500
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: model.rawLink
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
import QtQuick 2.14
|
||||
import QtQuick.Controls 2.14
|
||||
import QtQuick.Layouts 1.14
|
||||
|
||||
ColumnLayout {
|
||||
id: root
|
||||
|
||||
property alias count: indicator.count
|
||||
property alias currentIndex: indicator.currentIndex
|
||||
|
||||
signal up
|
||||
signal left
|
||||
signal right
|
||||
|
||||
RowLayout {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
|
||||
RoundButton {
|
||||
text: "⬅"
|
||||
enabled: root.currentIndex !== 0
|
||||
onClicked: root.left()
|
||||
}
|
||||
RoundButton {
|
||||
text: "⬆"
|
||||
onClicked: root.up()
|
||||
}
|
||||
RoundButton {
|
||||
text: "➡"
|
||||
enabled: root.currentIndex !== root.count - 1
|
||||
onClicked: root.right()
|
||||
}
|
||||
}
|
||||
|
||||
PageIndicator {
|
||||
id: indicator
|
||||
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
|
||||
interactive: true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
import QtQuick 2.14
|
||||
import QtQuick.Controls 2.14
|
||||
import QtQuick.Layouts 1.14
|
||||
|
||||
ToolBar {
|
||||
id: root
|
||||
|
||||
property string title
|
||||
property int figmaPagesCount: 0
|
||||
|
||||
signal figmaPreviewClicked
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
|
||||
TextField {
|
||||
Layout.fillWidth: true
|
||||
|
||||
text: root.title
|
||||
horizontalAlignment: Qt.AlignHCenter
|
||||
verticalAlignment: Qt.AlignVCenter
|
||||
|
||||
selectByMouse: true
|
||||
readOnly: true
|
||||
background: null
|
||||
}
|
||||
|
||||
ToolSeparator {}
|
||||
|
||||
ToolButton {
|
||||
id: openFigmaButton
|
||||
|
||||
enabled: root.figmaPagesCount
|
||||
text: `Figma designs (${root.figmaPagesCount})`
|
||||
|
||||
onClicked: root.figmaPreviewClicked()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
import QtQuick 2.14
|
||||
import QtQuick.Controls 2.14
|
||||
import QtQuick.Layouts 1.14
|
||||
|
||||
ColumnLayout {
|
||||
property alias loadAsynchronously: loadAsyncCheckBox.checked
|
||||
property alias figmaToken: figmaTokenTextInput.text
|
||||
|
||||
CheckBox {
|
||||
id: loadAsyncCheckBox
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
text: "Load pages asynchronously"
|
||||
}
|
||||
|
||||
GroupBox {
|
||||
Layout.fillWidth: true
|
||||
|
||||
title: "Figma token"
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
|
||||
text: `Figma token can be obtained <a href=\"https://www.figma.com/developers/api#access-tokens\">here</a>
|
||||
by clicking \"Get personal access token\". It's necessary to fetch figma data via Figma API.`
|
||||
|
||||
onLinkActivated: Qt.openUrlExternally(link)
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: figmaTokenTextInput
|
||||
|
||||
Layout.fillWidth: true
|
||||
|
||||
placeholderText: "Figma personal access token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import SortFilterProxyModel 0.2
|
||||
|
||||
SortFilterProxyModel {
|
||||
property alias roleName: valueFilter.roleName
|
||||
property alias value: valueFilter.value
|
||||
|
||||
filters: ValueFilter {
|
||||
id: valueFilter
|
||||
}
|
||||
}
|
|
@ -1,14 +1,24 @@
|
|||
CompilationErrorsBox 1.0 CompilationErrorsBox.qml
|
||||
FigmaImagesProxyModel 1.0 FigmaImagesProxyModel.qml
|
||||
FigmaLinksCache 1.0 FigmaLinksCache.qml
|
||||
FigmaPreviewWindow 1.0 FigmaPreviewWindow.qml
|
||||
FilteredPagesList 1.0 FilteredPagesList.qml
|
||||
FlickableImage 1.0 FlickableImage.qml
|
||||
HotComponentFromSource 1.0 HotComponentFromSource.qml
|
||||
HotLoader 1.0 HotLoader.qml
|
||||
HotReloader 1.0 HotReloader.qml
|
||||
HotReloaderControls 1.0 HotReloaderControls.qml
|
||||
ImageSelectPopup 1.0 ImageSelectPopup.qml
|
||||
ImagesGridView 1.0 ImagesGridView.qml
|
||||
ImagesNavigationLayout 1.0 ImagesNavigationLayout.qml
|
||||
Logs 1.0 Logs.qml
|
||||
LogsAndControlsPanel 1.0 LogsAndControlsPanel.qml
|
||||
LogsView 1.0 LogsView.qml
|
||||
PageToolBar 1.0 PageToolBar.qml
|
||||
PagesList 1.0 PagesList.qml
|
||||
PopupBackground 1.0 PopupBackground.qml
|
||||
SettingsLayout 1.0 SettingsLayout.qml
|
||||
SingleItemProxyModel 1.0 SingleItemProxyModel.qml
|
||||
SourceCodeBox 1.0 SourceCodeBox.qml
|
||||
singleton FigmaUtils 1.0 FigmaUtils.qml
|
||||
singleton StorybookUtils 1.0 StorybookUtils.qml
|
||||
|
|
Loading…
Reference in New Issue